Skip to main content

manabrew_engine/staticability/
layer.rs

1//! CR 613 layer system — continuous effect application.
2//!
3//! Mirrors Java Forge's `GameAction.checkStaticAbilities()` and
4//! `StaticAbilityContinuous.applyContinuousAbility()`.
5//!
6//! # How to use
7//!
8//! Call [`apply_continuous_effects`] after any event that could change which
9//! static abilities are active (card entering/leaving the battlefield, spell
10//! resolution, etc.):
11//!
12//! ```ignore
13//! apply_continuous_effects(&mut game);
14//! ```
15//!
16//! The function resets all derived fields (`static_power_modifier`,
17//! `static_toughness_modifier`, `static_set_power`, `static_set_toughness`,
18//! `granted_keywords`, `cant_attack_static`, `cant_block_static`) and
19//! recomputes them from scratch.
20//!
21//! # Layer ordering (CR 613)
22//!
23//! 1. Copy effects (not yet implemented)
24//! 2. Control-changing
25//! 3. Text-changing (not yet implemented)
26//! 4. Type-changing  → [`Layer::Type`]
27//! 5. Color-changing → [`Layer::Color`]
28//! 6. Ability-adding/removing → [`Layer::Ability`]
29//! 7a. CDA P/T → [`Layer::Characteristic`]
30//! 7b. Set P/T → [`Layer::SetPT`]
31//! 7c. Modify P/T → [`Layer::ModifyPT`]
32//! 7d. Counters (handled intrinsically by `Card::power()`)
33//! 8. Forge rules-modifying layer → [`Layer::Rules`]
34
35use std::collections::BTreeMap;
36
37use forge_foundation::{CardTypeLine, CoreType, Supertype, ZoneType};
38
39use crate::agent::PlayerAgent;
40use crate::game::GameState;
41use crate::ids::{CardId, PlayerId};
42use crate::replacement::replacement_effect::ReplacementType;
43use crate::staticability::{CardFilter, Layer, StaticAbility, StaticMode};
44
45// ── Effect collection ────────────────────────────────────────────────────────
46
47/// An effect ready to be applied to a specific target card.
48struct PendingEffect {
49    /// CR 613 layer (used for sort ordering).
50    layer: Layer,
51    /// Target card index.
52    target: CardId,
53    /// Payload.
54    kind: EffectKind,
55}
56
57enum EffectKind {
58    SetController {
59        controller: PlayerId,
60    },
61    AddPT {
62        power: i32,
63        toughness: i32,
64    },
65    SetPT {
66        power: Option<i32>,
67        toughness: Option<i32>,
68    },
69    RemoveAllCardTraits {
70        timestamp: i64,
71        static_id: i64,
72    },
73    GrantKeyword(String),
74    /// Grant an activated ability (from AddAbility$). The string is the ability text.
75    GrantAbility {
76        text: String,
77        svars: BTreeMap<String, String>,
78    },
79    /// Add a type/subtype to the card (`AddType$`). Mirrors Java layer 4.
80    AddType(String),
81    /// Grant a triggered ability (from AddTrigger$). The string is the raw trigger text.
82    GrantTrigger {
83        text: String,
84        svars: BTreeMap<String, String>,
85    },
86}
87
88// ── Public API ───────────────────────────────────────────────────────────────
89
90/// CR 613 layers a `Continuous` static contributes to.
91///
92/// Mirrors Java `StaticAbility.generateLayer()`. The classification is derived
93/// at runtime from the authored params; `StaticAbilityIr` stores the parsed DSL
94/// facts only.
95pub fn classify_static_layers(sa: &StaticAbility) -> Vec<Layer> {
96    if !sa.check_mode(&StaticMode::Continuous) {
97        return Vec::new();
98    }
99
100    let ir = &sa.ir;
101    let mut layers = Vec::new();
102
103    push_layer(&mut layers, ir.gain_control_param, Layer::Control);
104    push_layer(&mut layers, ir.has_text_layer_key, Layer::Text);
105    push_layer(&mut layers, ir.has_type_layer_key, Layer::Type);
106    push_layer(&mut layers, ir.has_color_layer_key, Layer::Color);
107    push_layer(&mut layers, ir.has_ability_layer_key, Layer::Ability);
108
109    if ir.set_power || ir.set_toughness {
110        if ir.characteristic_defining {
111            push_unique_layer(&mut layers, Layer::Characteristic);
112        } else {
113            push_unique_layer(&mut layers, Layer::SetPT);
114        }
115    }
116
117    push_layer(
118        &mut layers,
119        ir.add_power || ir.add_toughness,
120        Layer::ModifyPT,
121    );
122    push_layer(&mut layers, ir.has_rules_layer_key, Layer::Rules);
123
124    if layers.is_empty() {
125        layers.push(Layer::Rules);
126    }
127
128    layers
129}
130
131fn push_layer(layers: &mut Vec<Layer>, condition: bool, layer: Layer) {
132    if condition {
133        push_unique_layer(layers, layer);
134    }
135}
136
137fn static_layer_trait_id(source_id: CardId, sa_idx: usize) -> i64 {
138    -(((source_id.index() as i64) + 1) * 10_000 + sa_idx as i64 + 1)
139}
140
141fn push_unique_layer(layers: &mut Vec<Layer>, layer: Layer) {
142    if !layers.contains(&layer) {
143        layers.push(layer);
144    }
145}
146
147fn type_line_has_token(type_line: &CardTypeLine, token: &str) -> bool {
148    if let Some(st) = Supertype::from_name(token) {
149        return type_line.supertypes.contains(&st);
150    }
151    if let Some(ct) = CoreType::from_name(token) {
152        return type_line.core_types.contains(&ct);
153    }
154    type_line
155        .subtypes
156        .iter()
157        .any(|subtype| subtype.eq_ignore_ascii_case(token))
158}
159
160/// Recompute all continuously-applied static-ability effects for the current
161/// game state.
162///
163/// This is the Rust equivalent of Java Forge's
164/// `GameAction.checkStaticAbilities()` + `StaticAbilityContinuous.applyContinuousAbility()`.
165///
166/// **Call this** after:
167/// - Any permanent enters or leaves the battlefield.
168/// - Any spell or ability resolves.
169/// - Any triggered ability fires.
170/// - Before querying `can_attack()` / `can_block()` for combat legality.
171pub fn apply_continuous_effects(game: &mut GameState) {
172    let _perf_timer = crate::perf::ScopeTimer::start(
173        crate::perf::Metric::ContinuousEffectsCalls,
174        crate::perf::Metric::ContinuousEffectsNs,
175    );
176    let _params_lookup_scope =
177        crate::perf::ParamsLookupScopeGuard::enter(crate::perf::ParamsLookupScope::Continuous);
178    // ── 1. Reset all derived fields ──────────────────────────────────────
179    for card in game.cards.iter_mut() {
180        card.clear_static_layer_changed_card_traits();
181        // Remove abilities granted by continuous effects (AddAbility$).
182        // The base_ability_count tracks how many abilities the card originally had.
183        if card.activated_abilities.len() > card.base_ability_count {
184            card.activated_abilities.truncate(card.base_ability_count);
185        }
186        for (ability_idx, ability) in card.activated_abilities.iter_mut().enumerate() {
187            ability.ability_index = ability_idx;
188        }
189        let intrinsic_trigger_count = card.base_trigger_count + card.pump_trigger_count;
190        if card.triggers.len() > intrinsic_trigger_count {
191            card.triggers.truncate(intrinsic_trigger_count);
192        }
193        card.static_power_modifier = 0;
194        card.static_toughness_modifier = 0;
195        // Preserve face-down morph P/T override (2/2); only reset for face-up cards.
196        if !card.face_down {
197            card.static_set_power = None;
198            card.static_set_toughness = None;
199        }
200        card.granted_keywords.clear();
201        card.granted_svars.clear();
202        // Restore the pre-layer type line before applying AddType$ statics.
203        if let Some(type_line) = card.static_type_line_base.take() {
204            card.set_type_line(type_line);
205        }
206        card.static_added_subtypes.clear();
207        card.cant_attack_static = false;
208        card.cant_block_static = false;
209    }
210    for player in game.players.iter_mut() {
211        player.max_land_plays_per_turn = 1;
212        player.unlimited_land_plays = false;
213    }
214
215    // ── 1b. Keyword-derived restrictions ────────────────────────────────
216    // Unleash: creatures with Unleash keyword and a +1/+1 counter can't block.
217    for card in game.cards.iter_mut() {
218        if card.zone == ZoneType::Battlefield
219            && card.has_keyword("Unleash")
220            && card.counter_count(&crate::card::CounterType::P1P1) > 0
221        {
222            card.cant_block_static = true;
223        }
224    }
225
226    for player_idx in 0..game.player_order.len() {
227        let pid = game.player_order[player_idx];
228        let player = game.player_mut(pid);
229        player.max_hand_size = 7;
230        player.unlimited_hand_size = false;
231    }
232    let player_ids: Vec<PlayerId> = game.player_order.clone();
233    for player_idx in 0..player_ids.len() {
234        let pid = player_ids[player_idx];
235        let battlefield_cards: Vec<CardId> =
236            game.cards_in_zone(ZoneType::Battlefield, pid).to_vec();
237        for source_id in battlefield_cards {
238            let static_ability_count = game.card(source_id).static_abilities.len();
239            for sa_idx in 0..static_ability_count {
240                let card = game.card(source_id);
241                let sa = &card.static_abilities[sa_idx];
242                if !sa.check_conditions(card, game) {
243                    continue;
244                }
245                if !sa.check_mode(&StaticMode::Continuous) {
246                    continue;
247                }
248                let affected = sa.ir.affected_text.as_deref().unwrap_or("");
249                if !affected.eq_ignore_ascii_case("You") {
250                    continue;
251                }
252                let controller = card.controller;
253                let set_value = sa.ir.set_max_hand_size.clone();
254                let raise_value = sa.ir.raise_max_hand_size.clone();
255                if let Some(value) = set_value {
256                    let player = game.player_mut(controller);
257                    if value.eq_ignore_ascii_case("Unlimited") {
258                        player.unlimited_hand_size = true;
259                    } else if let Ok(n) = value.parse::<i32>() {
260                        player.max_hand_size = n;
261                    }
262                }
263                if let Some(value) = raise_value {
264                    if let Ok(n) = value.parse::<i32>() {
265                        let player = game.player_mut(controller);
266                        player.max_hand_size = player.max_hand_size.saturating_add(n);
267                    }
268                }
269            }
270        }
271    }
272
273    // ── 2. Build list of effects-to-apply (deferred to allow sorting) ────
274    let mut pending: Vec<PendingEffect> = Vec::new();
275    let mut cant_attack_targets: Vec<CardId> = Vec::new();
276    let mut cant_block_targets: Vec<CardId> = Vec::new();
277    let mut granted_player_rules: Vec<(CardId, StaticAbility)> = Vec::new();
278
279    let source_ids: Vec<CardId> = game.cards.iter().map(|card| card.id).collect();
280    for source_id in source_ids {
281        let static_ability_count = game.card(source_id).static_abilities.len();
282
283        for sa_idx in 0..static_ability_count {
284            let source_card = game.card(source_id).clone();
285            let sa = game.card(source_id).static_abilities[sa_idx].clone();
286
287            // Full static-ability condition gate (IsPresent$, CheckSVar$, Condition$, etc.).
288            // Mirrors Java static ability checks before applying continuous effects.
289            if !sa.check_conditions(&source_card, game) {
290                continue;
291            }
292
293            if sa.check_mode(&StaticMode::Continuous) {
294                apply_player_rules_effects(game, source_id, &sa);
295            }
296
297            // CharacteristicDefining statics always affect only the host card.
298            // Mirrors Java StaticAbilityContinuous.getAffectedCards() line 1036.
299            let is_cda = sa.ir.characteristic_defining;
300
301            // Determine which cards are affected by this static ability.
302            let affected_str = sa
303                .ir
304                .affected_text
305                .as_deref()
306                .or(sa.ir.valid_cards_text.as_deref())
307                .or(sa.ir.valid_card_text.as_deref())
308                .unwrap_or("Creature.YouControl");
309
310            let mut apply_to_target = |target: CardId| {
311                if sa.check_mode(&StaticMode::Continuous) {
312                    if let Some(gain_control) = sa.ir.gain_control_text.as_deref() {
313                        let new_controller = match gain_control {
314                            "You" | "YouCtrl" => Some(source_card.controller),
315                            "Opponent" => Some(game.opponent_of(source_card.controller)),
316                            _ => None,
317                        };
318                        if let Some(controller) = new_controller {
319                            pending.push(PendingEffect {
320                                layer: Layer::Control,
321                                target,
322                                kind: EffectKind::SetController { controller },
323                            });
324                        }
325                    }
326
327                    let add_power = sa.ir.add_power_text.as_deref();
328                    let add_toughness = sa.ir.add_toughness_text.as_deref();
329                    if add_power.is_some() || add_toughness.is_some() {
330                        let p = resolve_add_pt_value(game, source_id, add_power);
331                        let t = resolve_add_pt_value(game, source_id, add_toughness);
332                        pending.push(PendingEffect {
333                            layer: Layer::ModifyPT,
334                            target,
335                            kind: EffectKind::AddPT {
336                                power: p,
337                                toughness: t,
338                            },
339                        });
340                    }
341
342                    let add_type = sa.ir.add_type_text.as_deref();
343                    let source = game.card(source_id);
344                    for added_type in resolve_added_types(source, add_type) {
345                        pending.push(PendingEffect {
346                            layer: Layer::Type,
347                            target,
348                            kind: EffectKind::AddType(added_type),
349                        });
350                    }
351
352                    let set_power = sa.ir.set_power_text.as_deref();
353                    let set_toughness = sa.ir.set_toughness_text.as_deref();
354                    if set_power.is_some() || set_toughness.is_some() {
355                        let sp = resolve_set_pt_value(game, source_id, set_power);
356                        let st = resolve_set_pt_value(game, source_id, set_toughness);
357                        // Java parity: CharacteristicDefining$ True routes
358                        // SetP/T through layer 7a, otherwise 7b.
359                        let layer = if is_cda {
360                            Layer::Characteristic
361                        } else {
362                            Layer::SetPT
363                        };
364                        pending.push(PendingEffect {
365                            layer,
366                            target,
367                            kind: EffectKind::SetPT {
368                                power: sp,
369                                toughness: st,
370                            },
371                        });
372                    }
373
374                    if let Some(kws) = sa.ir.add_keyword_text.as_deref() {
375                        // AddKeyword$ supports multiple keywords separated by " & ".
376                        for kw in kws.split('&').map(str::trim).filter(|s| !s.is_empty()) {
377                            pending.push(PendingEffect {
378                                layer: Layer::Ability,
379                                target,
380                                kind: EffectKind::GrantKeyword(kw.to_string()),
381                            });
382                        }
383                    }
384
385                    if sa.ir.remove_all_abilities {
386                        pending.push(PendingEffect {
387                            layer: Layer::Ability,
388                            target,
389                            kind: EffectKind::RemoveAllCardTraits {
390                                timestamp: source_card.zone_timestamp as i64,
391                                static_id: static_layer_trait_id(source_id, sa_idx),
392                            },
393                        });
394                    }
395
396                    // AddAbility$ — grant an activated ability to the affected card.
397                    // The value is an SVar name on the source card containing the ability text.
398                    // E.g. Abundant Growth: AddAbility$ AbundantGrowthTap
399                    //   SVar:AbundantGrowthTap:AB$ Mana | Cost$ T | Produced$ Any
400                    if let Some(svar_name) = sa.ir.add_ability_text.as_deref() {
401                        if let Some(ab_text) = source_card.svars.get(svar_name).cloned() {
402                            pending.push(PendingEffect {
403                                layer: Layer::Ability,
404                                target,
405                                kind: EffectKind::GrantAbility {
406                                    text: ab_text,
407                                    svars: source_card.svars.clone(),
408                                },
409                            });
410                        }
411                    }
412
413                    if let Some(add_trigger) = sa.ir.add_trigger_text.as_deref() {
414                        for svar_name in add_trigger
415                            .split(" & ")
416                            .map(str::trim)
417                            .filter(|s| !s.is_empty())
418                        {
419                            if let Some(trig_text) = source_card.svars.get(svar_name).cloned() {
420                                pending.push(PendingEffect {
421                                    layer: Layer::Ability,
422                                    target,
423                                    kind: EffectKind::GrantTrigger {
424                                        text: trig_text,
425                                        svars: source_card.svars.clone(),
426                                    },
427                                });
428                            }
429                        }
430                    }
431
432                    if let Some(add_static) = sa.ir.add_static_ability_text.as_deref() {
433                        for svar_name in add_static
434                            .split(" & ")
435                            .map(str::trim)
436                            .filter(|s| !s.is_empty())
437                        {
438                            if let Some(static_text) = source_card.svars.get(svar_name).cloned() {
439                                if let Some(granted) =
440                                    crate::staticability::parse_static_ability(&static_text)
441                                {
442                                    granted_player_rules.push((target, granted));
443                                }
444                            }
445                        }
446                    }
447
448                    for subtype in resolve_added_basic_land_types(&source_card, add_type) {
449                        if let Some(ab_text) = basic_land_mana_ability_text(&subtype) {
450                            pending.push(PendingEffect {
451                                layer: Layer::Ability,
452                                target,
453                                kind: EffectKind::GrantAbility {
454                                    text: ab_text.to_string(),
455                                    svars: BTreeMap::new(),
456                                },
457                            });
458                        }
459                    }
460                }
461
462                if sa.check_mode(&StaticMode::CantAttack) {
463                    cant_attack_targets.push(target);
464                }
465                if sa.check_mode(&StaticMode::CantBlock) {
466                    cant_block_targets.push(target);
467                }
468            };
469
470            if is_cda {
471                // CDAs always affect only the source card itself.
472                if source_card.zone == ZoneType::Battlefield {
473                    apply_to_target(source_id);
474                }
475            } else if affected_str.eq_ignore_ascii_case("Card.Self")
476                || affected_str.starts_with("Card.Self+")
477            {
478                // Self-referencing static: only affects the source card itself,
479                // but qualifiers after "+" must still be checked (e.g.
480                // "Card.Self+counters_GE2_CHARGE" only matches when the card
481                // has >=2 charge counters). Mirrors Java's
482                // StaticAbilityContinuous.getAffectedCards() which validates
483                // all qualifiers even for self-referencing statics.
484                if source_card.zone == ZoneType::Battlefield
485                    && crate::card::valid_filter::matches_valid_card(
486                        affected_str,
487                        &source_card,
488                        &source_card,
489                    )
490                {
491                    apply_to_target(source_id);
492                }
493            } else if affected_str.eq_ignore_ascii_case("Card.EnchantedBy")
494                || affected_str.contains(".EquippedBy")
495                || affected_str.contains(".EnchantedBy")
496            {
497                // Aura / Equipment static effects: affect what this source is
498                // attached to. Java treats EquippedBy and EnchantedBy
499                // identically: both resolve to the entity the source is
500                // attached to. (e.g. Short Sword: "Creature.EquippedBy",
501                // Control Magic: "Card.EnchantedBy")
502                if let Some(cid) = source_card.attached_to {
503                    if game.card(cid).zone == ZoneType::Battlefield {
504                        apply_to_target(cid);
505                    }
506                }
507            } else {
508                let filter = CardFilter::parse(affected_str);
509                // AffectedZone$ overrides the default Battlefield filter (e.g.
510                // Ashling, the Limitless grants Evoke:4 to Elementals in Hand).
511                let affected_zones = if sa.ir.affected_zones.is_empty() {
512                    None
513                } else {
514                    Some(sa.ir.affected_zones.as_slice())
515                };
516                for card in &game.cards {
517                    let zone_matches = match &affected_zones {
518                        Some(zones) => zones.contains(&card.zone),
519                        None => card.zone == ZoneType::Battlefield,
520                    };
521                    if zone_matches && filter.matches_with_game(card, &source_card, game) {
522                        apply_to_target(card.id);
523                    }
524                }
525            }
526        }
527    }
528
529    for (source_id, granted) in granted_player_rules {
530        apply_player_rules_effects(game, source_id, &granted);
531    }
532
533    for target in cant_attack_targets {
534        game.cards[target.index()].cant_attack_static = true;
535    }
536    for target in cant_block_targets {
537        game.cards[target.index()].cant_block_static = true;
538    }
539
540    // ── 4. Sort by layer then apply ──────────────────────────────────────
541    // CR 613.1: apply layers 1→7c in order. Within the same layer, timestamp
542    // ordering is preserved by the stable sort (sources were collected in
543    // card-declaration order, which approximates timestamp order).
544    pending.sort_by_key(|e| e.layer);
545
546    for effect in pending {
547        match effect.kind {
548            EffectKind::SetController { controller } => {
549                game.change_controller(effect.target, controller);
550            }
551            EffectKind::AddPT { power, toughness } => {
552                let card = &mut game.cards[effect.target.index()];
553                card.static_power_modifier += power;
554                card.static_toughness_modifier += toughness;
555            }
556            EffectKind::SetPT { power, toughness } => {
557                let card = &mut game.cards[effect.target.index()];
558                // Layer 7b: override the base P/T for this calculation cycle.
559                // We use `static_set_power` rather than mutating `base_power`
560                // so the original base value is preserved for the next reset.
561                if let Some(p) = power {
562                    card.static_set_power = Some(p);
563                }
564                if let Some(t) = toughness {
565                    card.static_set_toughness = Some(t);
566                }
567            }
568            EffectKind::RemoveAllCardTraits {
569                timestamp,
570                static_id,
571            } => {
572                game.cards[effect.target.index()].add_changed_card_traits(
573                    crate::card::card_trait_changes::CardTraitChanges::remove_all_layer(
574                        Vec::new(),
575                        Vec::new(),
576                        Vec::new(),
577                        Vec::new(),
578                    ),
579                    timestamp,
580                    static_id,
581                );
582            }
583            EffectKind::GrantKeyword(kw) => {
584                let card = &mut game.cards[effect.target.index()];
585                card.granted_keywords.add(&kw);
586                if let Some(cost_str) = crate::keyword::extract_keyword_cost_str(&kw, "Ward") {
587                    let next_id = card
588                        .triggers
589                        .iter()
590                        .map(|t| t.id)
591                        .max()
592                        .unwrap_or(0)
593                        .saturating_add(1);
594                    let mut next_id_mut = next_id;
595                    let execute = format!("TrigWardGranted{}", next_id);
596                    let raw = format!(
597                        "Mode$ BecomesTarget | ValidSource$ SpellAbility.OppCtrl | ValidTarget$ Card.Self | Secondary$ True | Execute$ {} | TriggerZones$ Battlefield | TriggerDescription$ Ward",
598                        execute
599                    );
600                    if let Some(mut trig) = crate::trigger::parse_trigger(&raw, &mut next_id_mut) {
601                        trig.execute = execute.clone();
602                        card.add_trigger(trig);
603                    }
604                    card.granted_svars.insert(
605                        execute,
606                        format!(
607                            "DB$ Counter | Defined$ TriggeredSourceSA | UnlessCost$ {cost_str}"
608                        ),
609                    );
610                }
611            }
612            EffectKind::AddType(t) => {
613                let card = &mut game.cards[effect.target.index()];
614                if !type_line_has_token(&card.type_line, &t) {
615                    if card.static_type_line_base.is_none() {
616                        card.static_type_line_base = Some(card.type_line.clone());
617                    }
618                    card.add_type(&t);
619                    card.static_added_subtypes.push(t);
620                }
621            }
622            EffectKind::GrantAbility { text, svars } => {
623                // Parse the ability text and add it to the target's activated abilities.
624                // This grants abilities like "{T}: Add one mana of any color."
625                game.cards[effect.target.index()]
626                    .granted_svars
627                    .extend(svars);
628                let target_idx = effect.target.index();
629                let next_idx = game.cards[target_idx].activated_abilities.len();
630                if let Some(ab) =
631                    crate::ability::activated::parse_activated_ability(&text, next_idx)
632                {
633                    game.cards[target_idx].activated_abilities.push(ab);
634                }
635            }
636            EffectKind::GrantTrigger { text, svars } => {
637                game.cards[effect.target.index()]
638                    .granted_svars
639                    .extend(svars);
640                let next_id = game.cards[effect.target.index()]
641                    .triggers
642                    .iter()
643                    .map(|t| t.id)
644                    .max()
645                    .unwrap_or(0)
646                    .saturating_add(1);
647                let mut next_id_mut = next_id;
648                if let Some(trig) = crate::trigger::parse_trigger(&text, &mut next_id_mut) {
649                    game.cards[effect.target.index()].add_trigger(trig);
650                }
651            }
652        }
653    }
654
655    // Rebuild intrinsic basic-land mana abilities after type-changing continuous
656    // effects have been applied (e.g. Urborg making lands into Swamps).
657    for card in game.cards.iter_mut() {
658        if card.zone == ZoneType::Battlefield {
659            card.generate_basic_land_mana_abilities();
660        }
661    }
662}
663
664fn apply_player_rules_effects(game: &mut GameState, source_id: CardId, sa: &StaticAbility) {
665    let Some(adjust_land_plays) = sa.ir.adjust_land_plays_text.as_deref() else {
666        return;
667    };
668    let affected_players = affected_players_for_static(game, source_id, sa);
669    if affected_players.is_empty() {
670        return;
671    }
672    if adjust_land_plays.eq_ignore_ascii_case("Unlimited") {
673        for player in affected_players {
674            game.player_mut(player).unlimited_land_plays = true;
675        }
676        return;
677    }
678    let amount = resolve_rules_amount(game, source_id, adjust_land_plays);
679    for player in affected_players {
680        game.player_mut(player).max_land_plays_per_turn += amount;
681    }
682}
683
684fn affected_players_for_static(
685    game: &GameState,
686    source_id: CardId,
687    sa: &StaticAbility,
688) -> Vec<PlayerId> {
689    let Some(affected) = sa.ir.affected_text.as_deref() else {
690        return Vec::new();
691    };
692    let source = game.card(source_id);
693    game.player_order
694        .iter()
695        .copied()
696        .filter(|&player| {
697            !sa.ignore_effect_players.contains(&player)
698                && crate::card::valid_filter::matches_valid(
699                    affected,
700                    None,
701                    Some(player),
702                    source,
703                    source.controller,
704                )
705        })
706        .collect()
707}
708
709fn resolve_rules_amount(game: &GameState, source_id: CardId, value: &str) -> i32 {
710    if let Ok(n) = value.trim().parse::<i32>() {
711        return n;
712    }
713    let source = game.card(source_id);
714    if let Some(svar_expr) = source.svars.get(value.trim()) {
715        if svar_expr.starts_with("Count$") {
716            return crate::ability::effects::resolve_count_svar(
717                svar_expr,
718                game,
719                source_id,
720                source.controller,
721            );
722        }
723        return crate::ability::effects::evaluate_svar(
724            svar_expr,
725            &crate::spellability::SpellAbility::new_empty(Some(source_id), source.controller),
726        );
727    }
728    0
729}
730
731/// Apply ETB-tapped effects to `entering_card` as it enters the battlefield.
732///
733/// Checks:
734/// 1. The card's own static abilities for `Mode$ ETBTapped` (intrinsic).
735/// 2. Any other battlefield permanent with `Mode$ ETBTapped` whose filter
736///    matches the entering card (extrinsic, e.g. Imposing Sovereign).
737///
738/// Call this immediately after [`GameState::move_card`] resolves a
739/// `Battlefield` destination and before triggers are fired.
740pub fn apply_etb_tapped(game: &mut GameState, entering_card: CardId) {
741    apply_etb_tapped_with_agents(game, entering_card, None);
742}
743
744fn applicable_etb_tapped_replacement_sources(
745    game: &GameState,
746    entering_card: CardId,
747) -> Vec<(CardId, String)> {
748    let mut repl_sources: Vec<(CardId, String, String)> = Vec::new();
749    for c in &game.cards {
750        if c.zone != ZoneType::Battlefield {
751            continue;
752        }
753        for re in &c.replacement_effects {
754            if re.event == ReplacementType::Moved
755                && re.replace_with() == Some("ETBTapped")
756                && re.ir.destination_zone == Some(ZoneType::Battlefield)
757                && re.active_in_zone(ZoneType::Battlefield)
758            {
759                let filter = re
760                    .ir
761                    .valid_card_text
762                    .as_deref()
763                    .unwrap_or("Card.Self")
764                    .to_string();
765                let desc = re.description(c, game);
766                repl_sources.push((c.id, filter, desc));
767            }
768        }
769    }
770
771    repl_sources
772        .into_iter()
773        .filter_map(|(source_id, filter_str, desc)| {
774            let tapped = if filter_str == "Card.Self" || filter_str.is_empty() {
775                source_id == entering_card
776            } else {
777                let source = &game.cards[source_id.index()];
778                let filter = CardFilter::parse(&filter_str);
779                filter.matches_with_game(&game.cards[entering_card.index()], source, game)
780            };
781            tapped.then_some((source_id, desc))
782        })
783        .collect()
784}
785
786pub fn prompt_etb_tapped_replacement_with_agents(
787    game: &mut GameState,
788    entering_card: CardId,
789    agents: &mut [Box<dyn PlayerAgent>],
790) {
791    let applicable = applicable_etb_tapped_replacement_sources(game, entering_card);
792    if applicable.is_empty() {
793        return;
794    }
795
796    let affected_player = game.cards[entering_card.index()].controller;
797    let descriptions: Vec<String> = applicable
798        .iter()
799        .map(|(source_id, desc)| format!("{}: {}", game.card(*source_id).card_name, desc))
800        .collect();
801    let _chosen = agents[affected_player.index()]
802        .choose_single_replacement_effect(affected_player, &descriptions)
803        .min(applicable.len().saturating_sub(1));
804}
805
806pub fn apply_etb_tapped_with_agents(
807    game: &mut GameState,
808    entering_card: CardId,
809    agents: Option<&mut [Box<dyn PlayerAgent>]>,
810) {
811    // Collect all ETBTapped sources: (source_id, filter_str).
812    // We need owned data to avoid aliasing the cards slice while mutating.
813    let etb_sources: Vec<(CardId, String)> = game
814        .cards
815        .iter()
816        .filter(|c| c.zone == ZoneType::Battlefield)
817        .flat_map(|c| {
818            c.static_abilities.iter().filter_map(move |sa| {
819                if sa.check_mode(&StaticMode::ETBTapped) {
820                    let filter_str = sa
821                        .ir
822                        .valid_cards_text
823                        .clone()
824                        .or_else(|| sa.ir.affected_text.clone())
825                        // Default: the card itself (intrinsic self-ETBTapped).
826                        .unwrap_or_else(|| "Card.Self".to_string());
827                    Some((c.id, filter_str))
828                } else {
829                    None
830                }
831            })
832        })
833        .collect();
834
835    for (source_id, filter_str) in etb_sources {
836        // "Card.Self" means only the card that owns the ability.
837        let tapped = if filter_str == "Card.Self" || filter_str.is_empty() {
838            source_id == entering_card
839        } else {
840            let source = &game.cards[source_id.index()];
841            let filter = CardFilter::parse(&filter_str);
842            filter.matches_with_game(&game.cards[entering_card.index()], source, game)
843        };
844
845        if tapped {
846            game.cards[entering_card.index()].tapped = true;
847            return; // once tapped, no need to check further sources
848        }
849    }
850
851    // ── Second pass: check replacement effects for ReplaceWith$ ETBTapped ──
852    // Many cards (e.g. Path of Ancestry, Temple of Mystery) use:
853    //   R:Event$ Moved | Destination$ Battlefield | ValidCard$ Card.Self | ReplaceWith$ ETBTapped
854    // Extrinsic sources (e.g. Kismet) may use broader ValidCard filters.
855    let applicable = applicable_etb_tapped_replacement_sources(game, entering_card);
856    if applicable.is_empty() {
857        return;
858    }
859
860    if let Some(agents) = agents {
861        prompt_etb_tapped_replacement_with_agents(game, entering_card, agents);
862    }
863
864    game.cards[entering_card.index()].tapped = true;
865}
866
867/// Check if a card has a shock-land-style "enters tapped unless you pay life" effect.
868///
869/// Looks for `R:Event$ Moved | Destination$ Battlefield | ReplaceWith$ <SVar>`
870/// where the SVar is `DB$ Tap | ETB$ True | UnlessCost$ PayLife<N>`.
871///
872/// Returns `Some(life_cost)` if found (e.g. `Some(2)` for shock lands), `None` otherwise.
873/// Called from `play_card` / `resolve_stack` where agents are available for prompting.
874pub fn get_etb_unless_life_cost(card: &crate::card::Card) -> Option<i32> {
875    for re in &card.replacement_effects {
876        if re.event != ReplacementType::Moved {
877            continue;
878        }
879        if re.ir.destination_zone != Some(ZoneType::Battlefield) {
880            continue;
881        }
882        if let Some(svar_name) = re.replace_with() {
883            if svar_name == "ETBTapped" {
884                continue;
885            }
886            if let Some(svar_val) = card.svars.get(svar_name) {
887                if svar_val.contains("DB$ Tap") && svar_val.contains("ETB$ True") {
888                    // Parse life cost from "UnlessCost$ PayLife<N>"
889                    if let Some(pos) = svar_val.find("PayLife<") {
890                        let after = &svar_val[pos + 8..]; // skip "PayLife<"
891                        if let Some(end) = after.find('>') {
892                            if let Ok(n) = after[..end].parse::<i32>() {
893                                return Some(n);
894                            }
895                        }
896                    }
897                }
898            }
899        }
900    }
901    None
902}
903
904/// Check if a card has a "enters tapped unless you reveal a <type> from hand" effect.
905///
906/// Looks for `R:Event$ Moved | Destination$ Battlefield | ReplaceWith$ <SVar>`
907/// where the SVar is `DB$ Tap | ETB$ True | UnlessCost$ Reveal<N/Filter>`.
908///
909/// Returns `Some((n, filter))` if found (e.g. `Some((1, "Merfolk"))` for Wanderwine Hub).
910pub fn get_etb_unless_reveal_cost(card: &crate::card::Card) -> Option<(i32, String)> {
911    for re in &card.replacement_effects {
912        if re.event != ReplacementType::Moved {
913            continue;
914        }
915        if re.ir.destination_zone != Some(ZoneType::Battlefield) {
916            continue;
917        }
918        if let Some(svar_name) = re.replace_with() {
919            if svar_name == "ETBTapped" {
920                continue;
921            }
922            if let Some(svar_val) = card.svars.get(svar_name) {
923                if svar_val.contains("DB$ Tap") && svar_val.contains("ETB$ True") {
924                    // Parse reveal cost from "UnlessCost$ Reveal<N/Filter>"
925                    if let Some(pos) = svar_val.find("Reveal<") {
926                        let after = &svar_val[pos + 7..]; // skip "Reveal<"
927                        if let Some(end) = after.find('>') {
928                            let inner = &after[..end]; // "1/Merfolk" or "1/Filter"
929                            let mut parts = inner.splitn(2, '/');
930                            let n = parts
931                                .next()
932                                .and_then(|s| s.trim().parse::<i32>().ok())
933                                .unwrap_or(1);
934                            let filter = parts.next().unwrap_or("").trim().to_string();
935                            return Some((n, filter));
936                        }
937                    }
938                }
939            }
940        }
941    }
942    None
943}
944
945/// Resolve an AddPower$/AddToughness$ parameter that may be a literal integer
946/// or an SVar reference (e.g. "X" → Count$Valid Enchantment.YouCtrl).
947fn resolve_add_pt_value(game: &GameState, source_id: CardId, val_str: Option<&str>) -> i32 {
948    let val_str = match val_str {
949        Some(val_str) => val_str,
950        None => return 0,
951    };
952
953    // Try direct integer parse first
954    if let Ok(n) = val_str.trim().parse::<i32>() {
955        return n;
956    }
957
958    // It's an SVar reference — look it up on the source card
959    let source = game.card(source_id);
960    if let Some(svar_expr) = source.svars.get(val_str.trim()) {
961        if svar_expr.starts_with("Count$") {
962            return crate::ability::effects::resolve_count_svar(
963                svar_expr,
964                game,
965                source_id,
966                source.controller,
967            );
968        }
969        return crate::ability::effects::evaluate_svar(
970            svar_expr,
971            &crate::spellability::SpellAbility::new_empty(Some(source_id), source.controller),
972        );
973    }
974
975    0
976}
977
978/// Resolve a SetPower$/SetToughness$ parameter that may be a literal integer or
979/// an SVar reference (e.g. "X" → SVar:X:Count$Valid Creature.ChosenType).
980/// Mirrors Java `AbilityUtils.calculateAmount(hostCard, param, stAb)`.
981fn resolve_set_pt_value(game: &GameState, source_id: CardId, val_str: Option<&str>) -> Option<i32> {
982    let val_str = val_str?;
983    // Try direct integer parse first
984    if let Ok(n) = val_str.trim().parse::<i32>() {
985        return Some(n);
986    }
987
988    // It's an SVar reference — look it up on the source card
989    let source = game.card(source_id);
990    if let Some(svar_expr) = source.svars.get(val_str.trim()) {
991        if svar_expr.starts_with("Count$") {
992            return Some(crate::ability::effects::resolve_count_svar(
993                svar_expr,
994                game,
995                source_id,
996                source.controller,
997            ));
998        }
999        // Simple SVar evaluation (e.g. Number$2)
1000        return Some(crate::ability::effects::evaluate_svar(
1001            svar_expr,
1002            &crate::spellability::SpellAbility::new_empty(Some(source_id), source.controller),
1003        ));
1004    }
1005
1006    None
1007}
1008
1009fn basic_land_mana_ability_text(subtype: &str) -> Option<&'static str> {
1010    match subtype {
1011        "Plains" => Some("AB$ Mana | Cost$ T | Produced$ W | SpellDescription$ Add {W}."),
1012        "Island" => Some("AB$ Mana | Cost$ T | Produced$ U | SpellDescription$ Add {U}."),
1013        "Swamp" => Some("AB$ Mana | Cost$ T | Produced$ B | SpellDescription$ Add {B}."),
1014        "Mountain" => Some("AB$ Mana | Cost$ T | Produced$ R | SpellDescription$ Add {R}."),
1015        "Forest" => Some("AB$ Mana | Cost$ T | Produced$ G | SpellDescription$ Add {G}."),
1016        _ => None,
1017    }
1018}
1019
1020fn resolve_added_basic_land_types(
1021    source: &crate::card::Card,
1022    add_type: Option<&str>,
1023) -> Vec<String> {
1024    resolve_added_types(source, add_type)
1025        .into_iter()
1026        .filter(|added| basic_land_mana_ability_text(added).is_some())
1027        .collect()
1028}
1029
1030fn resolve_added_types(source: &crate::card::Card, add_type: Option<&str>) -> Vec<String> {
1031    let Some(add_type) = add_type else {
1032        return Vec::new();
1033    };
1034    let mut resolved = Vec::new();
1035    for raw in add_type.split('&').map(str::trim).filter(|s| !s.is_empty()) {
1036        match raw {
1037            "ChosenType" => {
1038                if let Some(chosen) = source.chosen_type.as_ref() {
1039                    resolved.push(chosen.clone());
1040                }
1041            }
1042            "ChosenType2" => {
1043                if let Some(chosen) = source.chosen_type2.as_ref() {
1044                    resolved.push(chosen.clone());
1045                }
1046            }
1047            "AllBasicLandType" => {
1048                resolved.extend(
1049                    ["Plains", "Island", "Swamp", "Mountain", "Forest"]
1050                        .into_iter()
1051                        .map(str::to_string),
1052                );
1053            }
1054            other => resolved.push(other.to_string()),
1055        }
1056    }
1057    resolved
1058}
1059
1060// ── Tests ────────────────────────────────────────────────────────────────────
1061
1062#[cfg(test)]
1063mod tests {
1064    use super::*;
1065    use forge_foundation::{CardTypeLine, ColorSet, ManaCost, ZoneType};
1066
1067    use crate::card::Card;
1068    use crate::ids::{CardId, PlayerId};
1069
1070    // Build a minimal two-player game with empty zones.
1071    fn new_game() -> GameState {
1072        GameState::new(&["Alice", "Bob"], 20)
1073    }
1074
1075    fn add_creature(
1076        game: &mut GameState,
1077        owner: PlayerId,
1078        power: i32,
1079        toughness: i32,
1080        keywords: Vec<String>,
1081        abilities: Vec<String>,
1082    ) -> CardId {
1083        let card = Card::new(
1084            CardId(0), // reassigned by create_card
1085            "Creature".to_string(),
1086            owner,
1087            CardTypeLine::parse("Creature"),
1088            ManaCost::parse("1 G"),
1089            ColorSet::GREEN,
1090            Some(power),
1091            Some(toughness),
1092            keywords,
1093            abilities,
1094        );
1095        let id = game.create_card(card);
1096        game.move_card(id, ZoneType::Battlefield, owner);
1097        id
1098    }
1099
1100    fn add_enchantment(game: &mut GameState, owner: PlayerId, abilities: Vec<String>) -> CardId {
1101        let card = Card::new(
1102            CardId(0),
1103            "Enchantment".to_string(),
1104            owner,
1105            CardTypeLine::parse("Enchantment"),
1106            ManaCost::parse("2 W"),
1107            ColorSet::WHITE,
1108            None,
1109            None,
1110            vec![],
1111            abilities,
1112        );
1113        let id = game.create_card(card);
1114        game.move_card(id, ZoneType::Battlefield, owner);
1115        id
1116    }
1117
1118    fn add_land(
1119        game: &mut GameState,
1120        owner: PlayerId,
1121        name: &str,
1122        type_line: &str,
1123        abilities: Vec<String>,
1124    ) -> CardId {
1125        let card = Card::new(
1126            CardId(0),
1127            name.to_string(),
1128            owner,
1129            CardTypeLine::parse(type_line),
1130            ManaCost::no_cost(),
1131            ColorSet::COLORLESS,
1132            None,
1133            None,
1134            vec![],
1135            abilities,
1136        );
1137        let id = game.create_card(card);
1138        game.move_card(id, ZoneType::Battlefield, owner);
1139        id
1140    }
1141
1142    fn add_effect(game: &mut GameState, owner: PlayerId, abilities: Vec<String>) -> CardId {
1143        let card = Card::new(
1144            CardId(0),
1145            "Effect".to_string(),
1146            owner,
1147            CardTypeLine::parse("Effect"),
1148            ManaCost::parse("0"),
1149            ColorSet::COLORLESS,
1150            None,
1151            None,
1152            vec![],
1153            abilities,
1154        );
1155        let id = game.create_card(card);
1156        game.move_card(id, ZoneType::Command, owner);
1157        id
1158    }
1159
1160    // ── Anthem (+1/+1) ────────────────────────────────────────────────────
1161
1162    #[test]
1163    fn anthem_boosts_your_creatures() {
1164        let mut game = new_game();
1165        let alice = PlayerId(0);
1166        let bob = PlayerId(1);
1167
1168        // Add two creatures for Alice and one for Bob.
1169        let a1 = add_creature(&mut game, alice, 2, 2, vec![], vec![]);
1170        let a2 = add_creature(&mut game, alice, 1, 1, vec![], vec![]);
1171        let b1 = add_creature(&mut game, bob, 2, 2, vec![], vec![]);
1172
1173        // Add Glorious Anthem-style enchantment controlled by Alice.
1174        let _anthem = add_enchantment(
1175            &mut game,
1176            alice,
1177            vec!["S$ Mode$ Continuous | Affected$ Creature.YouControl | AddPower$ 1 | AddToughness$ 1 | Description$ Creatures you control get +1/+1.".to_string()],
1178        );
1179
1180        apply_continuous_effects(&mut game);
1181
1182        // Alice's creatures get +1/+1.
1183        assert_eq!(game.card(a1).power(), 3, "Alice's 2/2 should be 3/3");
1184        assert_eq!(game.card(a1).toughness(), 3);
1185        assert_eq!(game.card(a2).power(), 2, "Alice's 1/1 should be 2/2");
1186        assert_eq!(game.card(a2).toughness(), 2);
1187
1188        // Bob's creature is unaffected.
1189        assert_eq!(
1190            game.card(b1).power(),
1191            2,
1192            "Bob's creature should be unchanged"
1193        );
1194        assert_eq!(game.card(b1).toughness(), 2);
1195    }
1196
1197    #[test]
1198    fn command_effect_adjusts_land_plays_for_affected_player() {
1199        let mut game = new_game();
1200        let alice = PlayerId(0);
1201        let bob = PlayerId(1);
1202
1203        let effect = add_effect(
1204            &mut game,
1205            alice,
1206            vec![
1207                "S$ Mode$ Continuous | EffectZone$ Command | Affected$ You | AdjustLandPlays$ 1"
1208                    .to_string(),
1209            ],
1210        );
1211
1212        apply_continuous_effects(&mut game);
1213
1214        assert_eq!(game.player(alice).max_land_plays_per_turn, 2);
1215        assert_eq!(game.player(bob).max_land_plays_per_turn, 1);
1216
1217        game.move_card(effect, ZoneType::Exile, alice);
1218        apply_continuous_effects(&mut game);
1219
1220        assert_eq!(game.player(alice).max_land_plays_per_turn, 1);
1221    }
1222
1223    #[test]
1224    fn anthem_resets_when_removed() {
1225        let mut game = new_game();
1226        let alice = PlayerId(0);
1227
1228        let creature = add_creature(&mut game, alice, 2, 2, vec![], vec![]);
1229        let anthem = add_enchantment(
1230            &mut game,
1231            alice,
1232            vec!["S$ Mode$ Continuous | Affected$ Creature.YouControl | AddPower$ 1 | AddToughness$ 1".to_string()],
1233        );
1234
1235        apply_continuous_effects(&mut game);
1236        assert_eq!(game.card(creature).power(), 3);
1237
1238        // Remove the anthem from the battlefield.
1239        game.move_card(anthem, ZoneType::Graveyard, alice);
1240        apply_continuous_effects(&mut game);
1241
1242        assert_eq!(
1243            game.card(creature).power(),
1244            2,
1245            "Bonus should be gone after anthem leaves"
1246        );
1247    }
1248
1249    #[test]
1250    fn stacking_anthems() {
1251        let mut game = new_game();
1252        let alice = PlayerId(0);
1253
1254        let creature = add_creature(&mut game, alice, 1, 1, vec![], vec![]);
1255        // Two separate +1/+1 anthems.
1256        add_enchantment(
1257            &mut game,
1258            alice,
1259            vec!["S$ Mode$ Continuous | Affected$ Creature.YouControl | AddPower$ 1 | AddToughness$ 1".to_string()],
1260        );
1261        add_enchantment(
1262            &mut game,
1263            alice,
1264            vec!["S$ Mode$ Continuous | Affected$ Creature.YouControl | AddPower$ 1 | AddToughness$ 1".to_string()],
1265        );
1266
1267        apply_continuous_effects(&mut game);
1268        assert_eq!(game.card(creature).power(), 3, "Two anthems should give +2");
1269        assert_eq!(game.card(creature).toughness(), 3);
1270    }
1271
1272    // ── Keyword granting ──────────────────────────────────────────────────
1273
1274    #[test]
1275    fn grant_flying_to_your_creatures() {
1276        let mut game = new_game();
1277        let alice = PlayerId(0);
1278        let bob = PlayerId(1);
1279
1280        let a1 = add_creature(&mut game, alice, 2, 2, vec![], vec![]);
1281        let b1 = add_creature(&mut game, bob, 2, 2, vec![], vec![]);
1282
1283        add_enchantment(
1284            &mut game,
1285            alice,
1286            vec!["S$ Mode$ Continuous | Affected$ Creature.YouControl | AddKeyword$ Flying | Description$ Creatures you control have flying.".to_string()],
1287        );
1288
1289        apply_continuous_effects(&mut game);
1290
1291        assert!(
1292            game.card(a1).has_flying(),
1293            "Alice's creature should have flying"
1294        );
1295        assert!(
1296            !game.card(b1).has_flying(),
1297            "Bob's creature should not have flying"
1298        );
1299    }
1300
1301    #[test]
1302    fn grant_multiple_keywords() {
1303        let mut game = new_game();
1304        let alice = PlayerId(0);
1305
1306        let creature = add_creature(&mut game, alice, 2, 2, vec![], vec![]);
1307        add_enchantment(
1308            &mut game,
1309            alice,
1310            vec!["S$ Mode$ Continuous | Affected$ Creature.YouControl | AddKeyword$ Flying & First Strike".to_string()],
1311        );
1312
1313        apply_continuous_effects(&mut game);
1314
1315        assert!(game.card(creature).has_flying());
1316        assert!(game.card(creature).has_first_strike());
1317    }
1318
1319    // ── SetPT (Layer 7b) ──────────────────────────────────────────────────
1320
1321    #[test]
1322    fn set_pt_overrides_base() {
1323        let mut game = new_game();
1324        let alice = PlayerId(0);
1325
1326        let creature = add_creature(&mut game, alice, 5, 5, vec![], vec![]);
1327        // Effect: set all your creatures to 0/1 (e.g. Humility).
1328        add_enchantment(
1329            &mut game,
1330            alice,
1331            vec!["S$ Mode$ Continuous | Affected$ Creature.YouControl | SetPower$ 0 | SetToughness$ 1".to_string()],
1332        );
1333
1334        apply_continuous_effects(&mut game);
1335        assert_eq!(game.card(creature).power(), 0);
1336        assert_eq!(game.card(creature).toughness(), 1);
1337    }
1338
1339    #[test]
1340    fn modify_pt_adds_on_top_of_set_pt() {
1341        // CR 613.7c: ModifyPT applies after SetPT within the same turn.
1342        let mut game = new_game();
1343        let alice = PlayerId(0);
1344
1345        let creature = add_creature(&mut game, alice, 5, 5, vec![], vec![]);
1346        // Layer 7b: set to 0/1.
1347        add_enchantment(
1348            &mut game,
1349            alice,
1350            vec!["S$ Mode$ Continuous | Affected$ Creature.YouControl | SetPower$ 0 | SetToughness$ 1".to_string()],
1351        );
1352        // Layer 7c: +1/+1 anthem on top.
1353        add_enchantment(
1354            &mut game,
1355            alice,
1356            vec!["S$ Mode$ Continuous | Affected$ Creature.YouControl | AddPower$ 1 | AddToughness$ 1".to_string()],
1357        );
1358
1359        apply_continuous_effects(&mut game);
1360        // 0 + 1 = 1 power, 1 + 1 = 2 toughness.
1361        assert_eq!(game.card(creature).power(), 1);
1362        assert_eq!(game.card(creature).toughness(), 2);
1363    }
1364
1365    // ── CantAttack / CantBlock ────────────────────────────────────────────
1366
1367    #[test]
1368    fn cant_attack_flag_set() {
1369        let mut game = new_game();
1370        let alice = PlayerId(0);
1371
1372        let creature = add_creature(&mut game, alice, 2, 2, vec![], vec![]);
1373        // Pacifism-like effect.
1374        add_enchantment(
1375            &mut game,
1376            alice,
1377            vec!["S$ Mode$ CantAttack | Affected$ Creature.YouControl | Description$ Creatures you control can't attack.".to_string()],
1378        );
1379
1380        apply_continuous_effects(&mut game);
1381        assert!(game.card(creature).cant_attack_static);
1382    }
1383
1384    #[test]
1385    fn cant_block_flag_set() {
1386        let mut game = new_game();
1387        let alice = PlayerId(0);
1388
1389        let creature = add_creature(&mut game, alice, 2, 2, vec![], vec![]);
1390        add_enchantment(
1391            &mut game,
1392            alice,
1393            vec!["S$ Mode$ CantBlock | Affected$ Creature.YouControl".to_string()],
1394        );
1395
1396        apply_continuous_effects(&mut game);
1397        assert!(game.card(creature).cant_block_static);
1398    }
1399
1400    #[test]
1401    fn flags_reset_on_reapplication() {
1402        let mut game = new_game();
1403        let alice = PlayerId(0);
1404
1405        let creature = add_creature(&mut game, alice, 2, 2, vec![], vec![]);
1406        let restrictor = add_enchantment(
1407            &mut game,
1408            alice,
1409            vec!["S$ Mode$ CantAttack | Affected$ Creature.YouControl".to_string()],
1410        );
1411
1412        apply_continuous_effects(&mut game);
1413        assert!(game.card(creature).cant_attack_static);
1414
1415        game.move_card(restrictor, ZoneType::Graveyard, alice);
1416        apply_continuous_effects(&mut game);
1417        assert!(
1418            !game.card(creature).cant_attack_static,
1419            "Flag should clear after enchantment leaves"
1420        );
1421    }
1422
1423    #[test]
1424    fn lands_gain_swamp_mana_ability_from_urborg_style_effect() {
1425        let mut game = new_game();
1426        let alice = PlayerId(0);
1427
1428        let urborg = add_land(
1429            &mut game,
1430            alice,
1431            "Urborg, Tomb of Yawgmoth",
1432            "Legendary Land",
1433            vec!["S$ Mode$ Continuous | Affected$ Land | AddType$ Swamp | Description$ Each land is a Swamp in addition to its other land types.".to_string()],
1434        );
1435        let black_gate = add_land(
1436            &mut game,
1437            alice,
1438            "The Black Gate",
1439            "Legendary Land Gate",
1440            vec![],
1441        );
1442
1443        apply_continuous_effects(&mut game);
1444
1445        for land_id in [urborg, black_gate] {
1446            let land = game.card(land_id);
1447            assert!(
1448                land.type_line.has_subtype("Swamp"),
1449                "{} should gain the Swamp subtype",
1450                land.card_name
1451            );
1452            assert!(
1453                land.activated_abilities.iter().any(|ab| {
1454                    ab.is_mana_ability
1455                        && ab
1456                            .produced_ir
1457                            .as_ref()
1458                            .is_some_and(|ir| ir.as_script_text() == "B")
1459                }),
1460                "{} should gain an intrinsic black mana ability from Swamp",
1461                land.card_name
1462            );
1463        }
1464    }
1465
1466    // ── ETB Tapped ────────────────────────────────────────────────────────
1467
1468    #[test]
1469    fn self_etb_tapped() {
1470        let mut game = new_game();
1471        let alice = PlayerId(0);
1472
1473        // A permanent with ETBTapped on itself.
1474        let card = Card::new(
1475            CardId(0),
1476            "TappedLand".to_string(),
1477            alice,
1478            CardTypeLine::parse("Land"),
1479            ManaCost::parse(""),
1480            ColorSet::from_mask(0),
1481            None,
1482            None,
1483            vec![],
1484            vec!["S$ Mode$ ETBTapped | Description$ Enters tapped.".to_string()],
1485        );
1486        let id = game.create_card(card);
1487        game.move_card(id, ZoneType::Battlefield, alice);
1488        apply_etb_tapped(&mut game, id);
1489
1490        assert!(
1491            game.card(id).tapped,
1492            "Card with ETBTapped should enter tapped"
1493        );
1494    }
1495
1496    #[test]
1497    fn no_etb_tapped_without_ability() {
1498        let mut game = new_game();
1499        let alice = PlayerId(0);
1500
1501        let id = add_creature(&mut game, alice, 2, 2, vec![], vec![]);
1502        // Fresh ETB, no static — should not be tapped.
1503        assert!(
1504            !game.card(id).tapped,
1505            "Normal creature should not enter tapped"
1506        );
1507    }
1508
1509    #[test]
1510    fn etb_tapped_via_replacement_effect() {
1511        let mut game = new_game();
1512        let alice = PlayerId(0);
1513
1514        // A land with R:Event$ Moved replacement effect (like Path of Ancestry).
1515        let card = Card::new(
1516            CardId(0),
1517            "PathOfAncestry".to_string(),
1518            alice,
1519            CardTypeLine::parse("Land"),
1520            ManaCost::parse(""),
1521            ColorSet::from_mask(0),
1522            None,
1523            None,
1524            vec![],
1525            vec!["R:Event$ Moved | Destination$ Battlefield | ValidCard$ Card.Self | ReplaceWith$ ETBTapped | Description$ ~ enters tapped.".to_string()],
1526        );
1527        let id = game.create_card(card);
1528        game.move_card(id, ZoneType::Battlefield, alice);
1529        apply_etb_tapped(&mut game, id);
1530
1531        assert!(
1532            game.card(id).tapped,
1533            "Card with ReplaceWith$ ETBTapped replacement should enter tapped"
1534        );
1535    }
1536}