Skip to main content

manabrew_engine/staticability/
static_ability.rs

1//! Static ability parsing and types.
2//!
3//! Mirrors the Java Forge `forge/game/staticability/` package, specifically
4//! `StaticAbility.java` and `StaticAbilityContinuous.java`.
5//!
6//! Card scripts encode static abilities as `S$`-prefixed lines, e.g.:
7//! ```text
8//! S$ Mode$ Continuous | Affected$ Creature.YouControl | AddPower$ 1 | AddToughness$ 1 | Description$ Creatures you control get +1/+1.
9//! S$ Mode$ ETBTapped | Description$ This permanent enters the battlefield tapped.
10//! S$ Mode$ CantAttack | Affected$ Creature.YouControl | Description$ Creatures you control can't attack.
11//! ```
12
13use std::collections::HashMap;
14
15use forge_foundation::ColorSet;
16use forge_foundation::ZoneType;
17use serde::{Deserialize, Serialize};
18use strum_macros::EnumString;
19
20use super::static_ability_mode;
21use crate::card::valid_filter::CardTraitRequirementsIr;
22use crate::card::Card;
23use crate::card::CounterType;
24use crate::card_trait_base::{CardTrait, CardTraitBase, CardTraitIrOwner};
25use crate::core::HasSVars;
26use crate::game::GameState;
27use crate::ids::{CardId, PlayerId};
28use crate::parsing::keys;
29use crate::parsing::{CompiledSelector, Params};
30
31const STATIC_ZONE_KEYS: &[&str] = &[keys::ACTIVE_ZONES, keys::EFFECT_ZONE];
32
33const STATIC_CONDITION_KEYS: &[&str] = &[
34    keys::PHASES,
35    keys::CONDITION,
36    keys::PLAYER_TURN,
37    "TopCardOfLibraryIs",
38    "ClassLevel",
39    "CheckSecondSVar",
40    "CheckThirdSVar",
41    "CheckFourthSVar",
42];
43
44// ── Mode ────────────────────────────────────────────────────────────────────
45
46/// The mode of a static ability.
47///
48/// Mirrors Java `StaticAbilityMode` enum. Each variant corresponds to a
49/// `Mode$ <Value>` entry in the card script.
50#[derive(
51    Debug, Clone, PartialEq, Eq, Hash, PartialOrd, Ord, Serialize, Deserialize, EnumString,
52)]
53#[strum(ascii_case_insensitive)]
54pub enum StaticMode {
55    /// `Mode$ Continuous` — layer-based continuous effects (anthems, keyword
56    /// grants, P/T setting). The most common category; handled by the layer
57    /// system in `layer.rs`.
58    Continuous,
59
60    /// `Mode$ CantAttack` — affected creatures cannot attack.
61    CantAttack,
62
63    /// `Mode$ CantBlock` — affected creatures cannot block.
64    CantBlock,
65
66    /// `Mode$ ETBTapped` — this permanent (or matching permanents) enters the
67    /// battlefield tapped. Applied once at ETB time, not continuously.
68    ETBTapped,
69
70    /// `Mode$ CantBeCast` — matching spells cannot be cast.
71    CantBeCast,
72    /// `Mode$ CantBeActivated` — matching abilities cannot be activated.
73    CantBeActivated,
74    /// `Mode$ CantPlayLand` — matching lands cannot be played.
75    CantPlayLand,
76
77    /// `Mode$ ReduceCost` — reduce the mana cost of matching spells.
78    ReduceCost,
79
80    /// `Mode$ RaiseCost` — increase the mana cost of matching spells.
81    RaiseCost,
82
83    /// `Mode$ SetCost` — raise cost to a minimum (Trinisphere). Used with `RaiseTo$`.
84    SetCost,
85    CantTarget,
86    CantAttach,
87    MustAttack,
88    MustBlock,
89    Panharmonicon,
90    CantGainLosePayLife,
91    CantDraw,
92    CantExile,
93    CantSacrifice,
94    CantRegenerate,
95    DisableTriggers,
96    CantPutCounter,
97    CastWithFlash,
98    BlockRestrict,
99    AttackRestrict,
100    CanAttackDefender,
101    IgnoreHexproof,
102    IgnoreShroud,
103    IgnoreLegendRule,
104    MustTarget,
105    AssignCombatDamageAsUnblocked,
106    AssignNoCombatDamage,
107    CombatDamageToughness,
108    NoCleanupDamage,
109    InfectDamage,
110    WitherDamage,
111    ColorlessDamageSource,
112    CountersRemain,
113    MaxCounter,
114    /// `Mode$ CantAttackUnless` — attacker must pay a cost to attack (Propaganda, Ghostly Prison).
115    CantAttackUnless,
116    /// `Mode$ OptionalAttackCost` — optional attack payment like Exert/Enlist.
117    OptionalAttackCost,
118    /// `Mode$ OptionalCost` — generic optional cost on a spell/ability (GameActionUtil).
119    OptionalCost,
120    /// `Mode$ AttackRequirement` — adds an attack requirement (StaticAbilityAttackRequirement).
121    AttackRequirement,
122    /// `Mode$ PlayerMustAttack` — controller of affected creatures must attack.
123    PlayerMustAttack,
124    /// `Mode$ CantBlockUnless` — blocker must pay a cost to block (War Cadence).
125    CantBlockUnless,
126    /// `Mode$ CantBlockBy` — restricts which blockers can block an attacker
127    /// (Flying, Fear, Intimidate, Skulk, or card-specific restrictions).
128    CantBlockBy,
129    /// `Mode$ ManaConvert` — spend mana as though it were mana of any color/type.
130    ManaConvert,
131    /// `Mode$ UnspentMana` — mana of specified type doesn't empty from pool.
132    UnspentMana,
133    /// `Mode$ ManaBurn` — losing unspent mana causes life loss (Yurlok of Scorch Thrash).
134    ManaBurn,
135    ActivateAbilityAsIfHaste,
136    CanAdapt,
137    AlternativeCost,
138    CantBeCopied,
139    CantBeSuspected,
140    CantBecomeMonarch,
141    CantChangeDayTime,
142    CantCrew,
143    CantDiscard,
144    CantPhaseIn,
145    CantPhaseOut,
146    CantTransform,
147    CantVenture,
148    Devotion,
149    CanExhaust,
150    FlipCoinMod,
151    GainLifeRadiation,
152    IgnoreLandwalk,
153    NumLoyaltyAct,
154    PlotZone,
155    SurveilNum,
156    TapPowerValue,
157    TurnReversed,
158    PhaseReversed,
159    UntapOtherPlayer,
160    CanBlockIfReach,
161    BlockTapped,
162    CanAttackIfHaste,
163    MinMaxBlocker,
164    AttackVigilance,
165    CantPreventDamage,
166    CantGainLife,
167    CantLoseLife,
168    CantPayLife,
169    CantChangeLife,
170
171    /// Any mode not yet recognised — stored but not applied.
172    #[strum(default)]
173    Other(String),
174}
175
176// ── Layer ────────────────────────────────────────────────────────────────────
177
178/// CR 613 layer ordering for continuous effects.
179///
180/// Effects are applied in ascending numeric order. Timestamp ordering within
181/// the same layer is preserved by the order in which effects are collected
182/// (battlefield entry order in `GameState.cards`).
183///
184/// Reference: <https://magic.wizards.com/en/rules> CR 613
185#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, PartialOrd, Ord, Serialize, Deserialize)]
186pub enum Layer {
187    /// Layer 2 — control-changing effects.
188    Control = 2,
189    /// Layer 3 — text-changing effects.
190    Text = 3,
191    /// Layer 4 — type-changing effects.
192    Type = 4,
193    /// Layer 5 — color-changing effects.
194    Color = 5,
195    /// Layer 6 — ability-adding / removing.
196    Ability = 6,
197    /// Layer 7a — characteristic-defining P/T (CDAs).
198    Characteristic = 70,
199    /// Layer 7b — P/T set to an absolute value.
200    SetPT = 71,
201    /// Layer 7c — P/T modifications: bonuses and penalties.
202    ModifyPT = 72,
203    /// Layer 7d / rules-modifying — MayPlay, AdjustLandPlays, Goad, etc.
204    Rules = 80,
205}
206
207// ── StaticAbility ────────────────────────────────────────────────────────────
208
209/// A parsed static ability from an `S$` line in a card script.
210///
211/// Params are stored exactly as they appear in the script so that new param
212/// types can be added without changing this struct.
213#[derive(Debug, Clone, Serialize, Deserialize)]
214pub struct StaticAbility {
215    #[serde(default)]
216    pub base: Box<CardTraitBase>,
217    pub modes: Vec<StaticMode>,
218    pub ignore_effect_cards: Vec<CardId>,
219    pub ignore_effect_players: Vec<PlayerId>,
220    pub may_play_turn: i32,
221    /// Mirrors `CardTraitBase.sVars` in Java. Populated by the card factory
222    /// with the host card's SVar map so that `$`-expressions evaluated under
223    /// the ability resolve against the card's SVars (not the ability's
224    /// mapParams).
225    #[serde(default)]
226    pub svars: HashMap<String, String>,
227    #[serde(skip)]
228    pub ir: StaticAbilityIr,
229}
230
231#[derive(Debug, Clone, Default)]
232pub struct StaticAbilityIr {
233    pub card_trait_requirements: CardTraitRequirementsIr,
234    pub valid_card: Option<CompiledSelector>,
235    pub valid_card_text: Option<String>,
236    pub valid_cards_text: Option<String>,
237    pub valid_player: Option<CompiledSelector>,
238    pub affected: Option<CompiledSelector>,
239    pub affected_text: Option<String>,
240    pub affected_zone: Option<ZoneType>,
241    pub affected_zone_text: Option<String>,
242    pub affected_zones: Vec<ZoneType>,
243    pub valid_zone: Vec<ZoneType>,
244    pub may_play: bool,
245    /// Mana cost string from `MayPlayAltManaCost$` (e.g. Airbend's `2`).
246    /// When `may_play` is true and this is set, the granted cast option uses
247    /// this cost in place of the card's printed mana cost.
248    pub may_play_alt_mana_cost: Option<String>,
249    pub counter_type_text: Option<String>,
250    pub counter_type: Option<CounterType>,
251    pub caster: Option<CompiledSelector>,
252    pub activator: Option<CompiledSelector>,
253    pub player: Option<CompiledSelector>,
254    pub valid_sa: Option<String>,
255    pub valid_mode: Option<String>,
256    pub valid_trigger: Option<String>,
257    pub valid_cause: Option<CompiledSelector>,
258    pub valid_cause_text: Option<String>,
259    pub valid_source: Option<CompiledSelector>,
260    pub valid_activator: Option<CompiledSelector>,
261    pub for_cost: Option<bool>,
262    pub is_combat: Option<bool>,
263    pub origin_zones: Vec<ZoneType>,
264    pub destination_zones: Vec<ZoneType>,
265    pub combat_damage: Option<bool>,
266    pub sorcery_speed: bool,
267    pub only_sorcery_speed: bool,
268    pub cmc_gt: Option<String>,
269    pub num_limit_each_turn: Option<i32>,
270    pub activator_raw: Option<String>,
271    pub cost: Option<String>,
272    pub gain_control_text: Option<String>,
273    pub add_power_text: Option<String>,
274    pub add_toughness_text: Option<String>,
275    pub add_type_text: Option<String>,
276    pub set_power_text: Option<String>,
277    pub set_toughness_text: Option<String>,
278    pub add_keyword_text: Option<String>,
279    pub remove_all_abilities: bool,
280    pub add_ability_text: Option<String>,
281    pub add_trigger_text: Option<String>,
282    pub add_static_ability_text: Option<String>,
283    pub adjust_land_plays_text: Option<String>,
284    pub type_filter: Option<String>,
285    pub mana_conversion: Option<String>,
286    pub except_cause_text: Option<String>,
287    pub restriction_text: Option<String>,
288    pub x_alternative_text: Option<String>,
289    pub announce_text: Option<String>,
290    pub mana_restriction_text: Option<String>,
291    pub stack_description_text: Option<String>,
292    pub cost_desc_text: Option<String>,
293    pub description_text: Option<String>,
294    pub draw_limit: Option<i32>,
295    /// `SetMaxHandSize$ Unlimited` (Reliquary Tower) or a numeric override.
296    /// Stored as a raw string because Forge accepts both `Unlimited` and a
297    /// number (and deck-builder cards use SVar expressions, though no
298    /// staticability_test card currently does).
299    pub set_max_hand_size: Option<String>,
300    /// `RaiseMaxHandSize$ N` (Spellbook etc.) — additive to the player's
301    /// max hand size.
302    pub raise_max_hand_size: Option<String>,
303    pub named_text: Option<String>,
304    pub trigger_text: Option<String>,
305    pub valid_defender: Option<CompiledSelector>,
306    pub valid_defender_text: Option<String>,
307    pub max_attackers: Option<String>,
308    pub max_blockers: Option<String>,
309    pub valid_attacked: Option<CompiledSelector>,
310    pub valid_creature: Option<CompiledSelector>,
311    pub valid_entity: Option<CompiledSelector>,
312    pub is_present: Option<CompiledSelector>,
313    pub valid_attacker: Option<CompiledSelector>,
314    pub valid_blocker: Option<CompiledSelector>,
315    pub valid_blocker_text: Option<String>,
316    pub valid_attacker_relative: Option<CompiledSelector>,
317    pub has_valid_attacker_relative: bool,
318    pub valid_blocker_relative: Option<CompiledSelector>,
319    pub has_valid_blocker_relative: bool,
320    pub unless_defender_text: Option<String>,
321    pub kw_text: Option<String>,
322    pub valid_keyword_text: Option<String>,
323    pub value_text: Option<String>,
324    pub mana_type_text: Option<String>,
325    pub result_text: Option<String>,
326    pub new_time_text: Option<String>,
327    pub present_compare_text: Option<String>,
328    pub check_svar_text: Option<String>,
329    pub svar_compare_text: Option<String>,
330    pub min_text: Option<String>,
331    pub max_text: Option<String>,
332    pub additional_text: Option<String>,
333    pub attacker_text: Option<String>,
334    pub target_text: Option<String>,
335    pub trigger: bool,
336    pub twice: bool,
337    pub only_source_abs: bool,
338    pub optional: bool,
339    pub num_value: Option<i32>,
340    pub defender_not_nearest_to_you_in_chosen_direction: bool,
341    pub effect_zone_all: bool,
342    pub effect_zones: Vec<ZoneType>,
343    pub valid_target: Option<CompiledSelector>,
344    pub valid_target_text: Option<String>,
345    pub valid_spell: Option<String>,
346    pub for_each_shard: Option<String>,
347    pub amount: Option<String>,
348    pub min_mana: Option<i32>,
349    pub raise_to: bool,
350    pub may_play_ignore_type: bool,
351    pub may_play_ignore_color: bool,
352    pub may_play_snow_ignore_color: bool,
353    pub color: Option<String>,
354    pub ignore_generic: bool,
355    pub only_first_spell: bool,
356    pub unless_valid_target: bool,
357    pub relative: bool,
358    pub target: Option<CompiledSelector>,
359    pub valid_card_to_target: Option<CompiledSelector>,
360    pub exception_sba: bool,
361    pub exceptions: Option<CompiledSelector>,
362    pub has_valid_card: bool,
363    pub has_valid_player: bool,
364    pub max_num: Option<i32>,
365    pub active_zones: Vec<ZoneType>,
366    pub phases_text: Option<String>,
367    pub condition_text: Option<String>,
368    pub player_turn_text: Option<String>,
369    pub top_card_of_library_is: Option<String>,
370    pub class_level_min: Option<i32>,
371    pub check_third_svar: Option<String>,
372    pub third_svar_compare: Option<String>,
373    pub check_fourth_svar: Option<String>,
374    pub fourth_svar_compare: Option<String>,
375    pub add_power: bool,
376    pub add_toughness: bool,
377    pub set_power: bool,
378    pub set_toughness: bool,
379    pub add_keyword: bool,
380    pub gain_control_param: bool,
381    pub add_type: bool,
382    pub remove_type: bool,
383    pub add_color: bool,
384    pub remove_color: bool,
385    pub set_color: bool,
386    pub characteristic_defining: bool,
387    pub has_text_layer_key: bool,
388    pub has_type_layer_key: bool,
389    pub has_color_layer_key: bool,
390    pub has_ability_layer_key: bool,
391    pub has_rules_layer_key: bool,
392    pub has_zone_keys: bool,
393    pub has_condition_keys: bool,
394}
395
396impl StaticAbilityIr {
397    fn from_params(params: &Params) -> Self {
398        let raw = params.inner();
399        Self {
400            card_trait_requirements: CardTraitRequirementsIr::from_key_values(
401                params.iter(),
402                params.selector_untracked(keys::IS_PRESENT).cloned(),
403                params.selector_untracked("IsPresent2").cloned(),
404            ),
405            valid_card: params.selector_untracked(keys::VALID_CARD).cloned(),
406            valid_card_text: raw.get(keys::VALID_CARD).map(String::to_string),
407            valid_cards_text: raw.get(keys::VALID_CARDS).map(String::to_string),
408            valid_player: params.selector_untracked(keys::VALID_PLAYER).cloned(),
409            affected: params.selector_untracked(keys::AFFECTED).cloned(),
410            affected_text: raw.get(keys::AFFECTED).map(String::to_string),
411            affected_zone: raw
412                .get(keys::AFFECTED_ZONE)
413                .map(String::as_str)
414                .and_then(ZoneType::from_str_compat),
415            affected_zone_text: raw.get(keys::AFFECTED_ZONE).map(String::to_string),
416            affected_zones: zone_list(raw.get(keys::AFFECTED_ZONE).map(String::as_str)),
417            valid_zone: zone_list(raw.get(keys::VALID_ZONE).map(String::as_str)),
418            may_play: raw
419                .get(keys::MAY_PLAY)
420                .is_some_and(|value| value.eq_ignore_ascii_case("True")),
421            may_play_alt_mana_cost: raw.get(keys::MAY_PLAY_ALT_MANA_COST).map(String::to_string),
422            counter_type_text: raw.get(keys::COUNTER_TYPE).map(String::to_string),
423            counter_type: raw
424                .get(keys::COUNTER_TYPE)
425                .map(|value| crate::card::counter_type::parse_counter_type(value)),
426            caster: params.selector_untracked(keys::CASTER).cloned(),
427            activator: params.selector_untracked(keys::ACTIVATOR).cloned(),
428            player: params.selector_untracked(keys::PLAYER).cloned(),
429            valid_sa: raw.get(keys::VALID_SA).map(String::to_string),
430            valid_mode: raw.get(keys::VALID_MODE).map(String::to_string),
431            valid_trigger: raw.get(keys::VALID_TRIGGER).map(String::to_string),
432            valid_cause: params.selector_untracked(keys::VALID_CAUSE).cloned(),
433            valid_cause_text: raw.get(keys::VALID_CAUSE).map(String::to_string),
434            valid_source: params.selector_untracked(keys::VALID_SOURCE).cloned(),
435            valid_activator: params.selector_untracked(keys::VALID_ACTIVATOR).cloned(),
436            for_cost: raw
437                .get(keys::FOR_COST)
438                .map(|value| value.eq_ignore_ascii_case("True")),
439            is_combat: raw
440                .get(keys::IS_COMBAT)
441                .map(|value| value.eq_ignore_ascii_case("True")),
442            origin_zones: zone_list(raw.get(keys::ORIGIN).map(String::as_str)),
443            destination_zones: zone_list(raw.get(keys::DESTINATION).map(String::as_str)),
444            combat_damage: raw
445                .get(keys::COMBAT_DAMAGE)
446                .map(|value| value.eq_ignore_ascii_case("True")),
447            sorcery_speed: raw.get(keys::SORCERY_SPEED).is_some(),
448            only_sorcery_speed: raw.get("OnlySorcerySpeed").is_some(),
449            cmc_gt: raw.get("cmcGT").map(String::to_string),
450            num_limit_each_turn: raw
451                .get("NumLimitEachTurn")
452                .and_then(|value| value.parse().ok()),
453            activator_raw: raw.get(keys::ACTIVATOR).map(String::to_string),
454            cost: raw.get(keys::COST).map(String::to_string),
455            gain_control_text: raw.get(keys::GAIN_CONTROL).map(String::to_string),
456            add_power_text: raw.get(keys::ADD_POWER).map(String::to_string),
457            add_toughness_text: raw.get(keys::ADD_TOUGHNESS).map(String::to_string),
458            add_type_text: raw.get(keys::ADD_TYPE).map(String::to_string),
459            set_power_text: raw.get(keys::SET_POWER).map(String::to_string),
460            set_toughness_text: raw.get(keys::SET_TOUGHNESS).map(String::to_string),
461            add_keyword_text: raw.get(keys::ADD_KEYWORD).map(String::to_string),
462            remove_all_abilities: raw
463                .get(keys::REMOVE_ALL_ABILITIES)
464                .is_some_and(|value| value.eq_ignore_ascii_case("true")),
465            add_ability_text: raw.get(keys::ADD_ABILITY).map(String::to_string),
466            add_trigger_text: raw.get(keys::ADD_TRIGGER).map(String::to_string),
467            add_static_ability_text: raw.get("AddStaticAbility").map(String::to_string),
468            adjust_land_plays_text: raw.get(keys::ADJUST_LAND_PLAYS).map(String::to_string),
469            type_filter: raw.get(keys::TYPE).map(String::to_string),
470            mana_conversion: raw.get(keys::MANA_CONVERSION).map(String::to_string),
471            except_cause_text: raw.get(keys::EXCEPT_CAUSE).map(String::to_string),
472            restriction_text: raw.get(keys::RESTRICTION).map(String::to_string),
473            x_alternative_text: raw.get("XAlternative").map(String::to_string),
474            announce_text: raw.get("Announce").map(String::to_string),
475            mana_restriction_text: raw.get("ManaRestriction").map(String::to_string),
476            stack_description_text: raw.get("StackDescription").map(String::to_string),
477            cost_desc_text: raw.get("CostDesc").map(String::to_string),
478            description_text: raw.get(keys::DESCRIPTION).map(String::to_string),
479            draw_limit: raw
480                .get(keys::DRAW_LIMIT)
481                .and_then(|value| value.parse().ok()),
482            set_max_hand_size: raw.get("SetMaxHandSize").map(String::to_string),
483            raise_max_hand_size: raw.get("RaiseMaxHandSize").map(String::to_string),
484            named_text: raw.get("Named").map(String::to_string),
485            trigger_text: raw.get(keys::TRIGGER).map(String::to_string),
486            valid_defender: params.selector_untracked(keys::VALID_DEFENDER).cloned(),
487            valid_defender_text: raw.get(keys::VALID_DEFENDER).map(String::to_string),
488            max_attackers: raw.get(keys::MAX_ATTACKERS).map(String::to_string),
489            max_blockers: raw.get(keys::MAX_BLOCKERS).map(String::to_string),
490            valid_attacked: params.selector_untracked(keys::VALID_ATTACKED).cloned(),
491            valid_creature: params.selector_untracked(keys::VALID_CREATURE).cloned(),
492            valid_entity: params.selector_untracked(keys::VALID_ENTITY).cloned(),
493            is_present: params.selector_untracked(keys::IS_PRESENT).cloned(),
494            valid_attacker: params.selector_untracked(keys::VALID_ATTACKER).cloned(),
495            valid_blocker: params.selector_untracked(keys::VALID_BLOCKER).cloned(),
496            valid_blocker_text: raw.get(keys::VALID_BLOCKER).map(String::to_string),
497            valid_attacker_relative: params
498                .selector_untracked(keys::VALID_ATTACKER_RELATIVE)
499                .cloned(),
500            has_valid_attacker_relative: raw.contains_key(keys::VALID_ATTACKER_RELATIVE),
501            valid_blocker_relative: params
502                .selector_untracked(keys::VALID_BLOCKER_RELATIVE)
503                .cloned(),
504            has_valid_blocker_relative: raw.contains_key(keys::VALID_BLOCKER_RELATIVE),
505            unless_defender_text: raw.get(keys::UNLESS_DEFENDER).map(String::to_string),
506            kw_text: raw.get(keys::KW).map(String::to_string),
507            valid_keyword_text: raw.get(keys::VALID_KEYWORD).map(String::to_string),
508            value_text: raw.get(keys::VALUE).map(String::to_string),
509            mana_type_text: raw.get(keys::MANA_TYPE).map(String::to_string),
510            result_text: raw.get(keys::RESULT).map(String::to_string),
511            new_time_text: raw.get(keys::NEW_TIME).map(String::to_string),
512            present_compare_text: raw.get(keys::PRESENT_COMPARE).map(String::to_string),
513            check_svar_text: raw.get(keys::CHECK_SVAR).map(String::to_string),
514            svar_compare_text: raw.get(keys::SVAR_COMPARE).map(String::to_string),
515            min_text: raw.get(keys::MIN).map(String::to_string),
516            max_text: raw.get(keys::MAX).map(String::to_string),
517            additional_text: raw.get(keys::ADDITIONAL).map(String::to_string),
518            attacker_text: raw.get(keys::ATTACKER).map(String::to_string),
519            target_text: raw.get(keys::TARGET).map(String::to_string),
520            trigger: raw.contains_key(keys::TRIGGER),
521            twice: raw.contains_key(keys::TWICE),
522            only_source_abs: raw.contains_key(keys::ONLY_SOURCE_ABS),
523            optional: raw.contains_key(keys::OPTIONAL),
524            num_value: raw.get(keys::NUM).and_then(|value| value.parse().ok()),
525            defender_not_nearest_to_you_in_chosen_direction: raw
526                .contains_key(keys::DEFENDER_NOT_NEAREST_TO_YOU_IN_CHOSEN_DIRECTION),
527            effect_zone_all: raw
528                .get(keys::EFFECT_ZONE)
529                .or_else(|| raw.get(keys::AFFECTED_ZONE))
530                .is_some_and(|value| value.eq_ignore_ascii_case("All")),
531            effect_zones: zone_list(
532                raw.get(keys::EFFECT_ZONE)
533                    .or_else(|| raw.get(keys::AFFECTED_ZONE))
534                    .map(String::as_str),
535            ),
536            valid_target: params.selector_untracked(keys::VALID_TARGET).cloned(),
537            valid_target_text: raw.get(keys::VALID_TARGET).map(String::to_string),
538            valid_spell: raw.get(keys::VALID_SPELL).map(String::to_string),
539            for_each_shard: raw.get(keys::FOR_EACH_SHARD).map(String::to_string),
540            amount: raw.get(keys::AMOUNT).map(String::to_string),
541            min_mana: raw.get(keys::MIN_MANA).and_then(|value| value.parse().ok()),
542            raise_to: raw
543                .get("RaiseTo")
544                .is_some_and(|value| value.eq_ignore_ascii_case("True")),
545            may_play_ignore_type: raw.contains_key("MayPlayIgnoreType"),
546            may_play_ignore_color: raw.contains_key("MayPlayIgnoreColor"),
547            may_play_snow_ignore_color: raw.contains_key("MayPlaySnowIgnoreColor"),
548            color: raw.get(keys::COLOR).map(String::to_string),
549            ignore_generic: raw
550                .get("IgnoreGeneric")
551                .is_some_and(|value| value.eq_ignore_ascii_case("True")),
552            only_first_spell: raw
553                .get("OnlyFirstSpell")
554                .is_some_and(|value| value.eq_ignore_ascii_case("True")),
555            unless_valid_target: raw
556                .get("UnlessValidTarget")
557                .is_some_and(|value| value.eq_ignore_ascii_case("True")),
558            relative: raw
559                .get("Relative")
560                .is_some_and(|value| value.eq_ignore_ascii_case("True")),
561            target: params.selector_untracked(keys::TARGET).cloned(),
562            valid_card_to_target: params
563                .selector_untracked(keys::VALID_CARD_TO_TARGET)
564                .cloned(),
565            exception_sba: raw.get(keys::EXCEPTION_SBA).is_some(),
566            exceptions: params.selector_untracked(keys::EXCEPTIONS).cloned(),
567            has_valid_card: raw.contains_key(keys::VALID_CARD),
568            has_valid_player: raw.contains_key(keys::VALID_PLAYER),
569            max_num: raw.get(keys::MAX_NUM).and_then(|value| value.parse().ok()),
570            active_zones: zone_list(raw.get(keys::ACTIVE_ZONES).map(String::as_str)),
571            phases_text: raw.get(keys::PHASES).map(String::to_string),
572            condition_text: raw.get(keys::CONDITION).map(String::to_string),
573            player_turn_text: raw.get(keys::PLAYER_TURN).map(String::to_string),
574            top_card_of_library_is: raw.get("TopCardOfLibraryIs").map(String::to_string),
575            class_level_min: raw.get("ClassLevel").and_then(|value| value.parse().ok()),
576            check_third_svar: raw.get("CheckThirdSVar").map(String::to_string),
577            third_svar_compare: raw.get("ThirdSVarCompare").map(String::to_string),
578            check_fourth_svar: raw.get("CheckFourthSVar").map(String::to_string),
579            fourth_svar_compare: raw.get("FourthSVarCompare").map(String::to_string),
580            add_power: raw.contains_key(keys::ADD_POWER),
581            add_toughness: raw.contains_key(keys::ADD_TOUGHNESS),
582            set_power: raw.contains_key(keys::SET_POWER),
583            set_toughness: raw.contains_key(keys::SET_TOUGHNESS),
584            add_keyword: raw.contains_key(keys::ADD_KEYWORD),
585            gain_control_param: raw.contains_key(keys::GAIN_CONTROL),
586            add_type: raw.contains_key(keys::ADD_TYPE),
587            remove_type: raw.contains_key(keys::REMOVE_TYPE),
588            add_color: raw.contains_key(keys::ADD_COLOR),
589            remove_color: raw.contains_key(keys::REMOVE_COLOR),
590            set_color: raw.contains_key(keys::SET_COLOR),
591            characteristic_defining: raw
592                .get(keys::CHARACTERISTIC_DEFINING)
593                .is_some_and(|v| v.eq_ignore_ascii_case("True")),
594            has_text_layer_key: params.contains_any_key(&[
595                keys::CHANGE_COLOR_WORDS_TO,
596                keys::GAIN_TEXT_OF,
597                keys::ADD_NAMES,
598                keys::SET_NAME,
599                keys::INCORPORATE,
600                keys::MANA_COST,
601            ]),
602            has_type_layer_key: params.contains_any_key(&[
603                keys::ADD_TYPE,
604                keys::REMOVE_TYPE,
605                keys::ADD_ALL_CREATURE_TYPES,
606                keys::REMOVE_CARD_TYPES,
607                keys::REMOVE_SUB_TYPES,
608                keys::REMOVE_SUPER_TYPES,
609                keys::REMOVE_LAND_TYPES,
610                keys::REMOVE_CREATURE_TYPES,
611                keys::REMOVE_ARTIFACT_TYPES,
612                keys::REMOVE_ENCHANTMENT_TYPES,
613            ]),
614            has_color_layer_key: params.contains_any_key(&[
615                keys::ADD_COLOR,
616                keys::REMOVE_COLOR,
617                keys::SET_COLOR,
618            ]),
619            has_ability_layer_key: params.contains_any_key(&[
620                keys::REMOVE_ALL_ABILITIES,
621                keys::REMOVE_NON_MANA_ABILITIES,
622                keys::GAINS_ABILITIES_OF,
623                keys::GAINS_ABILITIES_OF_DEFINED,
624                keys::GAINS_TRIGGER_ABS_OF,
625                keys::ADD_KEYWORD,
626                keys::ADD_ABILITY,
627                keys::ADD_TRIGGER,
628                keys::ADD_REPLACEMENT_EFFECT,
629                keys::ADD_STATIC_ABILITY,
630                keys::ADD_SVAR,
631                keys::CANT_HAVE_KEYWORD,
632                keys::SHARE_REMEMBERED_KEYWORDS,
633                keys::REMOVE_KEYWORD,
634            ]),
635            has_rules_layer_key: params.contains_any_key(&[
636                keys::ADD_HIDDEN_KEYWORD,
637                keys::MAY_PLAY,
638                keys::IGNORE_EFFECT_COST,
639                keys::GOAD,
640                keys::CAN_BLOCK_ANY,
641                keys::CAN_BLOCK_AMOUNT,
642                keys::ADJUST_LAND_PLAYS,
643                keys::CONTROL_VOTE,
644                keys::ADDITIONAL_VOTE,
645                keys::ADDITIONAL_OPTIONAL_VOTE,
646                keys::DECLARES_ATTACKERS,
647                keys::DECLARES_BLOCKERS,
648            ]),
649            has_zone_keys: params.contains_any_key(STATIC_ZONE_KEYS),
650            has_condition_keys: params.contains_any_key(STATIC_CONDITION_KEYS),
651        }
652    }
653}
654
655fn zone_list(raw: Option<&str>) -> Vec<ZoneType> {
656    raw.map(|zones| {
657        zones
658            .split(',')
659            .filter_map(|zone| ZoneType::from_str_compat(zone.trim()))
660            .collect()
661    })
662    .unwrap_or_default()
663}
664
665impl StaticAbility {
666    fn sync_trait_base_params(&mut self) {
667        self.base.set_svars(self.svars.clone());
668    }
669
670    pub fn check_mode(&self, mode: &StaticMode) -> bool {
671        match mode {
672            StaticMode::Other(query) => self
673                .modes
674                .iter()
675                .any(|m| matches!(m, StaticMode::Other(s) if s.eq_ignore_ascii_case(query))),
676            other => self.modes.iter().any(|m| m == other),
677        }
678    }
679
680    pub fn unknown_modes(&self) -> impl Iterator<Item = &str> {
681        self.modes.iter().filter_map(|mode| match mode {
682            StaticMode::Other(value) => Some(value.as_str()),
683            _ => None,
684        })
685    }
686
687    /// Modes that parse with Java parity but do not yet have a Rust runtime
688    /// implementation. This keeps them distinct from `Other`, which means the
689    /// parser has not recognised the Java mode at all.
690    pub fn parsed_but_unimplemented_modes(&self) -> impl Iterator<Item = &StaticMode> {
691        self.modes.iter().filter(|mode| {
692            matches!(
693                mode,
694                StaticMode::OptionalCost
695                    | StaticMode::AttackRequirement
696                    | StaticMode::PlayerMustAttack
697            )
698        })
699    }
700
701    pub fn zones_check(&self, source_zone: ZoneType) -> bool {
702        if !self.ir.has_zone_keys {
703            return source_zone == ZoneType::Battlefield;
704        }
705
706        let _perf_scope = crate::perf::ParamsLookupScopeGuard::enter(
707            crate::perf::ParamsLookupScope::StaticAbility,
708        );
709        if !self.ir.active_zones.is_empty() {
710            return self.ir.active_zones.contains(&source_zone);
711        }
712        if self.ir.effect_zone_all {
713            return true;
714        }
715        if !self.ir.effect_zones.is_empty() {
716            return self.ir.effect_zones.contains(&source_zone);
717        }
718        source_zone == ZoneType::Battlefield
719    }
720
721    pub fn check_conditions(&self, source: &Card, game: &GameState) -> bool {
722        let _perf_scope = crate::perf::ParamsLookupScopeGuard::enter(
723            crate::perf::ParamsLookupScope::StaticAbility,
724        );
725        if !self.zones_check(source.zone) {
726            return false;
727        }
728        if source.phased_out {
729            return false;
730        }
731        if !self.meets_card_trait_requirements(game, source, self) {
732            return false;
733        }
734
735        if !self.ir.has_condition_keys {
736            return true;
737        }
738
739        if let Some(phases) = self.ir.phases_text.as_deref() {
740            let current = format!("{:?}", game.turn.phase);
741            if !phases
742                .split(',')
743                .map(str::trim)
744                .any(|p| p.eq_ignore_ascii_case(&current))
745            {
746                return false;
747            }
748        }
749
750        if let Some(condition) = self.ir.condition_text.as_deref() {
751            if condition.eq_ignore_ascii_case("MaxSpeed")
752                && game.player(source.controller).speed != 4
753            {
754                return false;
755            }
756        }
757
758        if let Some(player_turn) = self.ir.player_turn_text.as_deref() {
759            let active = game.turn.active_player;
760            let defined = crate::ability::effects::helpers::resolve_defined_players(
761                player_turn,
762                source.controller,
763                game,
764            );
765            let ok = defined.contains(&active);
766            if !ok {
767                return false;
768            }
769        }
770
771        if let Some(valid_top) = self.ir.top_card_of_library_is.as_deref() {
772            let top = game
773                .zone(ZoneType::Library, source.controller)
774                .peek_top()
775                .map(|cid| game.card(cid));
776            let Some(top_card) = top else {
777                return false;
778            };
779            if !crate::card::valid_filter::matches_valid_card_opt(Some(valid_top), top_card, source)
780            {
781                return false;
782            }
783        }
784
785        if let Some(min) = self.ir.class_level_min {
786            if source.class_level < min {
787                return false;
788            }
789        }
790
791        if let Some(check_name) = self.ir.check_third_svar.as_deref() {
792            let compare = self.ir.third_svar_compare.as_deref().unwrap_or("GE1");
793            if !crate::card::valid_filter::check_svar_requirement(
794                game, source, source, check_name, compare,
795            ) {
796                return false;
797            }
798        }
799        if let Some(check_name) = self.ir.check_fourth_svar.as_deref() {
800            let compare = self.ir.fourth_svar_compare.as_deref().unwrap_or("GE1");
801            if !crate::card::valid_filter::check_svar_requirement(
802                game, source, source, check_name, compare,
803            ) {
804                return false;
805            }
806        }
807
808        true
809    }
810
811    pub fn check_conditions_full(
812        &self,
813        mode: &StaticMode,
814        source: &Card,
815        game: &GameState,
816    ) -> bool {
817        self.check_mode(mode) && self.check_conditions(source, game)
818    }
819
820    pub fn is_active_for(&self, mode: StaticMode, source_zone: ZoneType) -> bool {
821        self.check_mode(&mode) && self.zones_check(source_zone)
822    }
823
824    pub fn add_ignore_effect_players(&mut self, player: PlayerId) {
825        if !self.ignore_effect_players.contains(&player) {
826            self.ignore_effect_players.push(player);
827        }
828    }
829
830    pub fn clear_ignore_effects(&mut self) {
831        self.ignore_effect_cards.clear();
832        self.ignore_effect_players.clear();
833    }
834
835    pub fn inc_may_play_turn(&mut self) {
836        self.may_play_turn += 1;
837    }
838
839    pub fn reset_may_play_turn(&mut self) {
840        self.may_play_turn = 0;
841    }
842
843    pub fn copy(&self) -> Self {
844        self.clone()
845    }
846}
847
848// ── CardFilter ───────────────────────────────────────────────────────────────
849
850/// Filter for which permanents are affected by a static ability.
851///
852/// Parsed from the `Affected$` or `ValidCards$` parameter, which mirrors the
853/// `Card.isValid()` logic in Java Forge's `StaticAbilityContinuous`.
854///
855/// Format: `BaseType[.Qualifier][+Qualifier...]`
856///
857/// Examples:
858/// - `"Creature.YouControl"` — creatures you control
859/// - `"Creature.White+YouCtrl"` — white creatures you control (Honor of the Pure)
860/// - `"Creature.Other+YouControl"` — creatures you control other than this card
861/// - `"Creature.Goblin+YouControl"` — Goblins you control
862/// - `"Permanent.YouControl"` — all permanents you control
863#[derive(Debug, Clone, Default, Serialize, Deserialize)]
864pub struct CardFilter {
865    /// Only match cards with the `Creature` core type.
866    pub creatures_only: bool,
867    /// Only match cards controlled by the ability source's controller.
868    pub controller_only: bool,
869    /// Only match cards owned by the ability source's controller (`YouOwn`).
870    pub owner_only: bool,
871    /// Exclude the source card itself (`Other` qualifier).
872    pub other_only: bool,
873    /// Only match commanders.
874    pub commander_only: bool,
875    /// Only match cards with this subtype (e.g. `"Goblin"`, `"Warrior"`).
876    pub subtype: Option<String>,
877    /// Only match non-land permanents.
878    pub nonland_only: bool,
879    /// Only match land permanents.
880    pub land_only: bool,
881    /// Only match cards that include this color (e.g. White for Honor of the Pure).
882    /// `None` means no color restriction.
883    pub required_color: Option<ColorSet>,
884    /// Only match colorless cards (`Colorless` qualifier).
885    pub colorless_only: bool,
886    /// Only match creatures currently attacking the source's controller
887    /// (`attackingYou` qualifier, e.g. Watchdog).
888    pub attacking_you: bool,
889    /// Only match cards with this exact name (`named<CardName>` qualifier).
890    pub card_name: Option<String>,
891    /// Only match token permanents.
892    pub token_only: bool,
893    /// Only match cards that share a color with the source card.
894    pub shares_color_with_source: bool,
895    /// Only match cards that share a color with the source's equipped creature.
896    pub shares_color_with_equipped: bool,
897    /// Only match cards that share a creature type with the source card.
898    pub shares_creature_type_with_source: bool,
899    /// Only match cards that share a creature type with the source's equipped creature.
900    pub shares_creature_type_with_equipped: bool,
901}
902
903impl CardFilter {
904    /// Parse an `Affected$` / `ValidCards$` value string into a `CardFilter`.
905    pub fn parse(s: &str) -> Self {
906        let mut f = CardFilter::default();
907        // The string may be "BaseType.Q1.Q2+Q3+Q4".
908        // Split on '+' first, then on '.' within each segment.
909        let mut parts = s.split('+');
910        // First segment contains the base type (possibly with dot qualifiers).
911        let base = parts.next().unwrap_or("").trim();
912        for seg in base.split('.') {
913            Self::apply_segment(&mut f, seg.trim());
914        }
915        // Remaining '+'-separated parts are all qualifiers.
916        for part in parts {
917            Self::apply_segment(&mut f, part.trim());
918        }
919        f
920    }
921
922    fn apply_segment(f: &mut CardFilter, seg: &str) {
923        match seg {
924            "Creature" => f.creatures_only = true,
925            // "Permanent" and "Card" impose no additional restriction.
926            "Permanent" | "Card" | "" => {}
927            "nonLand" | "NonLand" => f.nonland_only = true,
928            "Land" => f.land_only = true,
929            "YouControl" | "YouCtrl" => f.controller_only = true,
930            "YouOwn" => f.owner_only = true,
931            "Other" => f.other_only = true,
932            "IsCommander" => f.commander_only = true,
933            // Color qualifiers (e.g. "Creature.White+YouCtrl" for Honor of the Pure).
934            "White" => f.required_color = Some(ColorSet::WHITE),
935            "Blue" => f.required_color = Some(ColorSet::BLUE),
936            "Black" => f.required_color = Some(ColorSet::BLACK),
937            "Red" => f.required_color = Some(ColorSet::RED),
938            "Green" => f.required_color = Some(ColorSet::GREEN),
939            "Colorless" => f.colorless_only = true,
940            "attackingYou" => f.attacking_you = true,
941            "token" | "Token" => f.token_only = true,
942            "SharesColorWith" => f.shares_color_with_source = true,
943            "sharesCreatureTypeWith" => f.shares_creature_type_with_source = true,
944            "SharesColorWith Equipped" => f.shares_color_with_equipped = true,
945            "sharesCreatureTypeWith Equipped" => f.shares_creature_type_with_equipped = true,
946            s if s.starts_with("named") => {
947                f.card_name = Some(s["named".len()..].to_string());
948            }
949            s => {
950                // Unknown tokens are treated as subtype filters (e.g. "Goblin").
951                if f.subtype.is_none() {
952                    f.subtype = Some(s.to_string());
953                }
954            }
955        }
956    }
957
958    /// Returns `true` if `card` passes this filter given `source` is the
959    /// static ability's host card.
960    pub fn matches(&self, card: &Card, source: &Card) -> bool {
961        if self.creatures_only && !card.is_creature() {
962            return false;
963        }
964        if self.controller_only && card.controller != source.controller {
965            return false;
966        }
967        if self.owner_only && card.owner != source.controller {
968            return false;
969        }
970        if self.other_only && card.id == source.id {
971            return false;
972        }
973        if self.commander_only && !card.is_commander {
974            return false;
975        }
976        if let Some(ref sub) = self.subtype {
977            if !card.has_subtype(sub) {
978                return false;
979            }
980        }
981        if self.nonland_only && card.is_land() {
982            return false;
983        }
984        if self.land_only && !card.is_land() {
985            return false;
986        }
987        if let Some(required) = self.required_color {
988            if !card.color.shares_color_with(required) {
989                return false;
990            }
991        }
992        if self.colorless_only && !card.color.is_colorless() {
993            return false;
994        }
995        if self.attacking_you && card.attacking_player != Some(source.controller) {
996            return false;
997        }
998        if let Some(ref name) = self.card_name {
999            if card.card_name != *name {
1000                return false;
1001            }
1002        }
1003        if self.token_only && !card.is_token {
1004            return false;
1005        }
1006        if self.shares_color_with_source {
1007            if card.color.is_colorless() || source.color.is_colorless() {
1008                return false;
1009            }
1010            if !card.color.shares_color_with(source.color) {
1011                return false;
1012            }
1013        }
1014        if self.shares_creature_type_with_source && !shares_creature_type_with(card, source) {
1015            return false;
1016        }
1017        true
1018    }
1019
1020    /// Context-aware matching for predicates that require game lookups
1021    /// (e.g. `SharesColorWith Equipped`).
1022    pub fn matches_with_game(&self, card: &Card, source: &Card, game: &GameState) -> bool {
1023        if !self.matches(card, source) {
1024            return false;
1025        }
1026        if self.shares_color_with_equipped {
1027            let Some(equipped_id) = source.attached_to else {
1028                return false;
1029            };
1030            let equipped = game.card(equipped_id);
1031            if card.color.is_colorless() || equipped.color.is_colorless() {
1032                return false;
1033            }
1034            if !card.color.shares_color_with(equipped.color) {
1035                return false;
1036            }
1037        }
1038        if self.shares_creature_type_with_equipped {
1039            let Some(equipped_id) = source.attached_to else {
1040                return false;
1041            };
1042            let equipped = game.card(equipped_id);
1043            if !shares_creature_type_with(card, equipped) {
1044                return false;
1045            }
1046        }
1047        true
1048    }
1049}
1050
1051fn shares_creature_type_with(a: &Card, b: &Card) -> bool {
1052    a.shares_creature_type_with(b)
1053}
1054
1055// ── Parser ───────────────────────────────────────────────────────────────────
1056
1057/// Parse a raw `S$` (or `S:`) ability line from a card script into a
1058/// [`StaticAbility`].
1059///
1060/// Returns `None` if the line does not start with the `S$` / `S:` prefix or
1061/// has no recognisable `Mode$` param.
1062///
1063/// # Format
1064///
1065/// ```text
1066/// S$ Mode$ Continuous | Affected$ Creature.YouControl | AddPower$ 1 | AddToughness$ 1
1067/// S$ Mode$ ETBTapped | Description$ Enters tapped.
1068/// ```
1069///
1070/// Reference: Java `StaticAbility.java` in `forge/game/staticability/`.
1071pub fn parse_static_ability(raw: &str) -> Option<StaticAbility> {
1072    let trimmed = raw.trim();
1073    // Accept "S$ ..." or "S: ..." prefixes from card script static lines,
1074    // plus bare "Mode$ ..." SVar payloads used by AddStaticAbility$.
1075    let body = if let Some(rest) = trimmed.strip_prefix("S$ ") {
1076        rest
1077    } else if let Some(rest) = trimmed.strip_prefix("S:") {
1078        rest.trim_start()
1079    } else if trimmed.starts_with("Mode$ ") {
1080        trimmed
1081    } else {
1082        return None;
1083    };
1084
1085    // Parse "|"-separated "Key$ Value" pairs using central parser.
1086    let params = Params::from_raw(body);
1087
1088    let modes = match params.get(keys::MODE) {
1089        Some(value) => static_ability_mode::set_value_of(value),
1090        None => return None,
1091    };
1092    if modes.is_empty() {
1093        return None;
1094    }
1095
1096    let ir = StaticAbilityIr::from_params(&params);
1097    let mut st_ab = StaticAbility {
1098        base: Box::new(CardTraitBase::default()),
1099        modes,
1100        ignore_effect_cards: Vec::new(),
1101        ignore_effect_players: Vec::new(),
1102        may_play_turn: 0,
1103        svars: HashMap::new(),
1104        ir,
1105    };
1106    st_ab.sync_trait_base_params();
1107    Some(st_ab)
1108}
1109
1110impl HasSVars for StaticAbility {
1111    fn get_svar(&self, name: &str) -> Option<&str> {
1112        self.svars.get(name).map(String::as_str)
1113    }
1114
1115    fn set_svar(&mut self, name: String, value: String) {
1116        self.svars.insert(name.clone(), value.clone());
1117        self.base.set_svar(name, value);
1118    }
1119
1120    fn set_svars(&mut self, new_svars: HashMap<String, String>) {
1121        self.svars = new_svars.clone();
1122        self.base.set_svars(new_svars);
1123    }
1124
1125    fn get_svars(&self) -> &HashMap<String, String> {
1126        &self.svars
1127    }
1128
1129    fn remove_svar(&mut self, var: &str) {
1130        self.svars.remove(var);
1131        self.base.remove_svar(var);
1132    }
1133}
1134
1135impl CardTrait for StaticAbility {
1136    fn base(&self) -> &CardTraitBase {
1137        &self.base
1138    }
1139}
1140
1141impl CardTraitIrOwner for StaticAbility {
1142    type Ir = StaticAbilityIr;
1143
1144    fn ir(&self) -> &Self::Ir {
1145        &self.ir
1146    }
1147
1148    fn card_trait_requirements(&self) -> &CardTraitRequirementsIr {
1149        &self.ir.card_trait_requirements
1150    }
1151}
1152
1153// ── Tests ────────────────────────────────────────────────────────────────────
1154
1155#[cfg(test)]
1156mod tests {
1157    use super::*;
1158    use forge_foundation::{CardTypeLine, ColorSet, ManaCost};
1159
1160    use crate::card::Card;
1161    use crate::ids::{CardId, PlayerId};
1162
1163    fn make_creature(id: u32, owner: u32, subtypes: &[&str]) -> Card {
1164        let type_str = if subtypes.is_empty() {
1165            "Creature".to_string()
1166        } else {
1167            format!("Creature - {}", subtypes.join(" "))
1168        };
1169        Card::new(
1170            CardId(id),
1171            "Test".to_string(),
1172            PlayerId(owner),
1173            CardTypeLine::parse(&type_str),
1174            ManaCost::parse("1 G"),
1175            ColorSet::GREEN,
1176            Some(2),
1177            Some(2),
1178            vec![],
1179            vec![],
1180        )
1181    }
1182
1183    fn make_land(id: u32, owner: u32) -> Card {
1184        Card::new(
1185            CardId(id),
1186            "Forest".to_string(),
1187            PlayerId(owner),
1188            CardTypeLine::parse("Basic Land - Forest"),
1189            ManaCost::parse(""),
1190            ColorSet::GREEN,
1191            None,
1192            None,
1193            vec![],
1194            vec![],
1195        )
1196    }
1197
1198    // ── Parser tests ─────────────────────────────────────────────────────
1199
1200    #[test]
1201    fn parse_continuous_anthem() {
1202        let raw = "S$ Mode$ Continuous | Affected$ Creature.YouControl | AddPower$ 1 | AddToughness$ 1 | Description$ Creatures you control get +1/+1.";
1203        let sa = parse_static_ability(raw).expect("should parse");
1204        assert_eq!(sa.modes, vec![StaticMode::Continuous]);
1205        assert_eq!(sa.ir.add_power_text.as_deref(), Some("1"));
1206        assert_eq!(sa.ir.add_toughness_text.as_deref(), Some("1"));
1207        assert_eq!(
1208            crate::staticability::layer::classify_static_layers(&sa),
1209            vec![Layer::ModifyPT]
1210        );
1211    }
1212
1213    #[test]
1214    fn parse_etb_tapped() {
1215        let raw = "S$ Mode$ ETBTapped | Description$ This permanent enters the battlefield tapped.";
1216        let sa = parse_static_ability(raw).expect("should parse");
1217        assert_eq!(sa.modes, vec![StaticMode::ETBTapped]);
1218        assert!(crate::staticability::layer::classify_static_layers(&sa).is_empty());
1219    }
1220
1221    #[test]
1222    fn parse_cant_attack() {
1223        let raw = "S$ Mode$ CantAttack | Affected$ Creature.YouControl | Description$ Creatures you control can't attack.";
1224        let sa = parse_static_ability(raw).expect("should parse");
1225        assert_eq!(sa.modes, vec![StaticMode::CantAttack]);
1226    }
1227
1228    #[test]
1229    fn parse_keyword_grant() {
1230        let raw = "S$ Mode$ Continuous | Affected$ Creature.YouControl | AddKeyword$ Flying | Description$ Creatures you control have flying.";
1231        let sa = parse_static_ability(raw).expect("should parse");
1232        assert_eq!(
1233            crate::staticability::layer::classify_static_layers(&sa),
1234            vec![Layer::Ability]
1235        );
1236        assert_eq!(sa.ir.add_keyword_text.as_deref(), Some("Flying"));
1237    }
1238
1239    #[test]
1240    fn parse_set_pt() {
1241        let raw =
1242            "S$ Mode$ Continuous | Affected$ Creature.YouControl | SetPower$ 0 | SetToughness$ 1";
1243        let sa = parse_static_ability(raw).expect("should parse");
1244        assert_eq!(
1245            crate::staticability::layer::classify_static_layers(&sa),
1246            vec![Layer::SetPT]
1247        );
1248    }
1249
1250    #[test]
1251    fn parse_s_colon_prefix() {
1252        let raw =
1253            "S: Mode$ Continuous | Affected$ Creature.YouControl | AddPower$ 2 | AddToughness$ 2";
1254        let sa = parse_static_ability(raw).expect("should parse S: prefix");
1255        assert_eq!(sa.modes, vec![StaticMode::Continuous]);
1256    }
1257
1258    #[test]
1259    fn parse_multi_mode_cant_attack_block() {
1260        let raw = "S$ Mode$ CantAttack,CantBlock | Affected$ Creature.YouControl";
1261        let sa = parse_static_ability(raw).expect("should parse");
1262        assert_eq!(
1263            sa.modes,
1264            vec![StaticMode::CantAttack, StaticMode::CantBlock]
1265        );
1266        assert!(sa.check_mode(&StaticMode::CantAttack));
1267        assert!(sa.check_mode(&StaticMode::CantBlock));
1268        assert!(!sa.check_mode(&StaticMode::CantBeActivated));
1269    }
1270
1271    #[test]
1272    fn parse_multi_mode_three_restrictions() {
1273        let raw = "S$ Mode$ CantAttack,CantBlock,CantBeActivated | Affected$ Creature.YouControl";
1274        let sa = parse_static_ability(raw).expect("should parse");
1275        assert!(sa.check_mode(&StaticMode::CantAttack));
1276        assert!(sa.check_mode(&StaticMode::CantBlock));
1277        assert!(sa.check_mode(&StaticMode::CantBeActivated));
1278    }
1279
1280    #[test]
1281    fn parse_continuous_with_restrictions() {
1282        let raw = "S$ Mode$ Continuous,CantPlayLand,CantBeCast | Affected$ Card.YouControl";
1283        let sa = parse_static_ability(raw).expect("should parse");
1284        assert!(sa.check_mode(&StaticMode::Continuous));
1285        assert!(sa.check_mode(&StaticMode::CantPlayLand));
1286        assert!(sa.check_mode(&StaticMode::CantBeCast));
1287    }
1288
1289    #[test]
1290    fn non_static_line_returns_none() {
1291        assert!(parse_static_ability("AB$ Mana | Cost$ T | Produced$ G").is_none());
1292        assert!(parse_static_ability("T$ Mode$ ChangesZone").is_none());
1293        assert!(parse_static_ability("").is_none());
1294    }
1295
1296    // ── CardFilter tests ─────────────────────────────────────────────────
1297
1298    #[test]
1299    fn filter_creature_you_control() {
1300        let f = CardFilter::parse("Creature.YouControl");
1301        assert!(f.creatures_only);
1302        assert!(f.controller_only);
1303        assert!(!f.other_only);
1304        assert!(f.subtype.is_none());
1305    }
1306
1307    #[test]
1308    fn filter_creature_other_you_control() {
1309        let f = CardFilter::parse("Creature.Other+YouControl");
1310        assert!(f.creatures_only);
1311        assert!(f.controller_only);
1312        assert!(f.other_only);
1313    }
1314
1315    #[test]
1316    fn filter_goblin_subtype() {
1317        let f = CardFilter::parse("Creature.Goblin+YouControl");
1318        assert!(f.creatures_only);
1319        assert!(f.controller_only);
1320        assert_eq!(f.subtype, Some("Goblin".to_string()));
1321    }
1322
1323    #[test]
1324    fn filter_matches_creature() {
1325        let source = make_creature(0, 0, &[]);
1326        let target = make_creature(1, 0, &[]);
1327        let f = CardFilter::parse("Creature.YouControl");
1328        assert!(f.matches(&target, &source));
1329    }
1330
1331    #[test]
1332    fn filter_excludes_opponent_creatures() {
1333        let source = make_creature(0, 0, &[]);
1334        let mut opp = make_creature(1, 1, &[]); // different controller
1335        opp.controller = PlayerId(1);
1336        let f = CardFilter::parse("Creature.YouControl");
1337        assert!(!f.matches(&opp, &source));
1338    }
1339
1340    #[test]
1341    fn filter_excludes_self_with_other() {
1342        let source = make_creature(0, 0, &[]);
1343        let f = CardFilter::parse("Creature.Other+YouControl");
1344        assert!(!f.matches(&source, &source));
1345    }
1346
1347    #[test]
1348    fn filter_excludes_land_with_nonland() {
1349        let source = make_creature(0, 0, &[]);
1350        let land = make_land(1, 0);
1351        let f = CardFilter::parse("Permanent.nonLand+YouControl");
1352        assert!(!f.matches(&land, &source));
1353    }
1354
1355    #[test]
1356    fn filter_subtype_goblin() {
1357        let source = make_creature(0, 0, &[]);
1358        let goblin = make_creature(1, 0, &["Goblin"]);
1359        let bear = make_creature(2, 0, &["Bear"]);
1360        let f = CardFilter::parse("Creature.Goblin+YouControl");
1361        assert!(f.matches(&goblin, &source));
1362        assert!(!f.matches(&bear, &source));
1363    }
1364
1365    // ── Color filter tests ───────────────────────────────────────────────
1366
1367    fn make_white_creature(id: u32, owner: u32) -> Card {
1368        Card::new(
1369            CardId(id),
1370            "White Knight".to_string(),
1371            PlayerId(owner),
1372            CardTypeLine::parse("Creature - Human Knight"),
1373            ManaCost::parse("W W"),
1374            ColorSet::WHITE,
1375            Some(2),
1376            Some(2),
1377            vec![],
1378            vec![],
1379        )
1380    }
1381
1382    #[test]
1383    fn filter_color_white_parses() {
1384        let f = CardFilter::parse("Creature.White+YouCtrl");
1385        assert!(f.creatures_only);
1386        assert!(f.controller_only);
1387        assert_eq!(f.required_color, Some(ColorSet::WHITE));
1388        assert!(
1389            f.subtype.is_none(),
1390            "White should not be treated as a subtype"
1391        );
1392    }
1393
1394    #[test]
1395    fn filter_honor_of_the_pure_matches_white_creature() {
1396        // Simulate Honor of the Pure: "Creature.White+YouCtrl"
1397        let source = make_white_creature(0, 0); // Honor of the Pure controlled by player 0
1398        let white_ally = make_white_creature(1, 0);
1399        let green_ally = make_creature(2, 0, &[]); // green creature, same controller
1400        let white_opponent = make_white_creature(3, 1); // white but opponent controls it
1401        let mut white_opponent = white_opponent;
1402        white_opponent.controller = PlayerId(1);
1403
1404        let f = CardFilter::parse("Creature.White+YouCtrl");
1405        assert!(f.matches(&white_ally, &source), "white ally should match");
1406        assert!(
1407            !f.matches(&green_ally, &source),
1408            "green creature should not match"
1409        );
1410        assert!(
1411            !f.matches(&white_opponent, &source),
1412            "opponent's white creature should not match"
1413        );
1414    }
1415
1416    #[test]
1417    fn filter_color_white_does_not_match_colorless() {
1418        let source = make_white_creature(0, 0);
1419        let colorless = Card::new(
1420            CardId(1),
1421            "Darksteel Myr".to_string(),
1422            PlayerId(0),
1423            CardTypeLine::parse("Artifact Creature - Myr"),
1424            ManaCost::parse("3"),
1425            ColorSet::COLORLESS,
1426            Some(0),
1427            Some(1),
1428            vec![],
1429            vec![],
1430        );
1431        let f = CardFilter::parse("Creature.White+YouCtrl");
1432        assert!(
1433            !f.matches(&colorless, &source),
1434            "colorless artifact should not be white"
1435        );
1436    }
1437}