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