Skip to main content

manabrew_engine/ability/
spell_ability_effect.rs

1//! SpellAbilityEffect — base trait and utility free functions for effects.
2//!
3//! Mirrors Java's `SpellAbilityEffect.java`.
4//! In Java this is an abstract class with many protected static helpers;
5//! in Rust we keep the trait for interface parity and provide the utility
6//! methods as free functions that take `(&GameState, &SpellAbility)`.
7
8use crate::ability::ability_ir::{DefinedExpr, DefinedRef};
9use crate::ability::api_type::ApiType;
10use crate::ability::AbilityKey;
11use crate::agent::PlayerAgent;
12use crate::game::GameState;
13use crate::ids::{CardId, PlayerId};
14use crate::parsing::keys;
15use crate::player::player_factory_util::add_replacement_effect;
16use crate::spellability::SpellAbility;
17use crate::spellability::{AbilityDuration, ReplaceDyingCondition};
18
19use super::ability_factory::AbilityRecordType;
20use super::ability_utils;
21use super::effects::EffectContext;
22
23/// Base trait for all spell ability effect implementations.
24///
25/// Mirrors Java's abstract `SpellAbilityEffect` class.
26/// Each effect type provides a `resolve` implementation that performs
27/// the actual game-state mutation.
28/// Every effect is a stateless unit struct, so all methods are associated
29/// functions (no `self`). The trait is therefore not object-safe — runtime
30/// dispatch through `dyn SpellAbilityEffect` is intentionally unsupported;
31/// dispatch happens via the `effect_dispatch!` macro at compile time.
32pub trait SpellAbilityEffect {
33    /// Resolve this effect for the given spell ability.
34    fn resolve(ctx: &mut EffectContext, sa: &SpellAbility);
35
36    /// Return the stack description for this effect.
37    /// Defaults to the spell ability's own description.
38    fn get_stack_description(sa: &SpellAbility) -> String {
39        sa.ability_text.clone()
40    }
41
42    /// Build/configure the spell ability after construction.
43    /// Default is a no-op; some effects override this to add parameters.
44    fn build_spell_ability(_sa: &mut SpellAbility) {}
45
46    /// Tokenize a description string, replacing CARDNAME with the card's name.
47    /// Mirrors Java's `SpellAbilityEffect.tokenizeString(SpellAbility, String)`.
48    fn tokenize_string(game: &GameState, sa: &SpellAbility, desc: &str) -> String {
49        tokenize_string(game, sa, desc)
50    }
51
52    /// Add a "forget on moved" trigger for remembered cards.
53    /// Mirrors Java's `SpellAbilityEffect.addForgetOnMovedTrigger(SpellAbility, Card, String)`.
54    fn add_forget_on_moved_trigger(
55        game: &mut GameState,
56        host_id: CardId,
57        remembered_card_id: CardId,
58    ) {
59        add_forget_on_moved_trigger(game, host_id, remembered_card_id)
60    }
61
62    /// Create a temporary effect card in the command zone.
63    /// Mirrors Java's `SpellAbilityEffect.createEffect(SpellAbility, Player, String, String)`.
64    fn create_effect(game: &mut GameState, sa: &SpellAbility, name: &str, image: &str) -> CardId {
65        create_effect(game, sa, name, image)
66    }
67
68    /// Run the effect (resolve entry point).
69    /// Mirrors Java's `SpellAbilityEffect.run(SpellAbility)`.
70    fn run(ctx: &mut super::effects::EffectContext, sa: &SpellAbility) {
71        run(ctx, sa)
72    }
73
74    /// Track which card exiled another card, for "exile until" effects.
75    /// Mirrors Java's `SpellAbilityEffect.handleExiledWith(SpellAbility, Card)`.
76    fn handle_exiled_with(game: &mut GameState, sa: &SpellAbility, exiled_card_id: CardId) {
77        handle_exiled_with(game, sa, exiled_card_id)
78    }
79
80    /// Execute the exile-with command.
81    /// Mirrors Java's `SpellAbilityEffect.exileEffectCommand(Game, SpellAbility, Card)`.
82    fn exile_effect_command(
83        game: &mut GameState,
84        trigger_handler: &mut crate::trigger::handler::TriggerHandler,
85        sa: &SpellAbility,
86        card_id: CardId,
87    ) {
88        exile_effect_command(game, trigger_handler, sa, card_id)
89    }
90
91    /// Full stack-description with sub-ability concatenation.
92    /// Mirrors Java `SpellAbilityEffect.getStackDescriptionWithSubs(Map, SpellAbility)`.
93    fn get_stack_description_with_subs(game: &GameState, sa: &SpellAbility) -> String {
94        let fallback = Self::get_stack_description(sa);
95        get_stack_description_with_subs(game, sa, &fallback)
96    }
97
98    /// Resolve a replacement chooser when the original lost the game (CR 800.4g).
99    /// Mirrors Java `SpellAbilityEffect.getNewChooser(SpellAbility, Player)`.
100    fn get_new_chooser(
101        game: &GameState,
102        agents: &mut [Box<dyn PlayerAgent>],
103        sa: &SpellAbility,
104        loser: PlayerId,
105    ) -> Option<PlayerId> {
106        get_new_chooser(game, agents, sa, loser)
107    }
108}
109
110// ── Utility free functions mirroring Java's SpellAbilityEffect helpers ──
111
112/// Get target cards for a spell ability.
113/// If the SA uses targeting, returns the chosen target card(s).
114/// Otherwise, resolves the `Defined$` parameter (defaulting to "Self").
115///
116/// Mirrors Java's `SpellAbilityEffect.getTargetCards(sa)`.
117pub fn get_target_cards(game: &GameState, sa: &SpellAbility) -> Vec<CardId> {
118    get_cards(game, sa, false, "Defined")
119}
120
121/// Get defined cards, falling back to targeted cards if no `Defined$` param.
122///
123/// Mirrors Java's `SpellAbilityEffect.getDefinedCardsOrTargeted(sa)`.
124pub fn get_defined_cards_or_targeted(game: &GameState, sa: &SpellAbility) -> Vec<CardId> {
125    get_cards(game, sa, true, "Defined")
126}
127
128/// Get defined cards with a custom param name, falling back to targeted.
129///
130/// Mirrors Java's `SpellAbilityEffect.getDefinedCardsOrTargeted(sa, definedParam)`.
131pub fn get_defined_cards_or_targeted_param(
132    game: &GameState,
133    sa: &SpellAbility,
134    defined_param: &str,
135) -> Vec<CardId> {
136    get_cards(game, sa, true, defined_param)
137}
138
139/// Core card resolution logic — shared by getTargetCards and getDefinedCardsOrTargeted.
140/// Mirrors Java's private `SpellAbilityEffect.getCards(definedFirst, definedParam, sa)`.
141fn get_cards(
142    game: &GameState,
143    sa: &SpellAbility,
144    defined_first: bool,
145    defined_param: &str,
146) -> Vec<CardId> {
147    let ir_defined = ir_defined_param(sa, defined_param);
148    let has_defined = ir_defined.is_some_and(|defined| defined.is_some());
149    let use_targets = sa.uses_targeting() && (!defined_first || !has_defined);
150
151    if use_targets {
152        // Return targeted card(s)
153        sa.target_chosen.target_card.into_iter().collect()
154    } else {
155        // Resolve Defined$ (or default to "Self")
156        let defined = ir_defined;
157        let mut result = Vec::new();
158        if let Some(Some(defined)) = defined {
159            for d in &defined.refs {
160                let cards = resolve_defined_cards_for_sa_ref(game, sa, d);
161                result.extend(cards);
162            }
163        } else {
164            result.extend(resolve_defined_cards_for_sa(game, sa, "Self"));
165        }
166        result
167    }
168}
169
170/// Get target players for a spell ability.
171/// If the SA uses targeting, returns the chosen target player(s).
172/// Otherwise, resolves the `Defined$` parameter (defaulting to "You").
173///
174/// Mirrors Java's `SpellAbilityEffect.getTargetPlayers(sa)`.
175pub fn get_target_players(game: &GameState, sa: &SpellAbility) -> Vec<PlayerId> {
176    get_players(game, sa, false, "Defined")
177}
178
179/// Get defined players, falling back to targeted players if no `Defined$` param.
180///
181/// Mirrors Java's `SpellAbilityEffect.getDefinedPlayersOrTargeted(sa)`.
182pub fn get_defined_players_or_targeted(game: &GameState, sa: &SpellAbility) -> Vec<PlayerId> {
183    get_players(game, sa, true, "Defined")
184}
185
186/// Core player resolution logic.
187/// Mirrors Java's private `SpellAbilityEffect.getPlayers(definedFirst, definedParam, sa)`.
188fn get_players(
189    game: &GameState,
190    sa: &SpellAbility,
191    defined_first: bool,
192    defined_param: &str,
193) -> Vec<PlayerId> {
194    fn unique_push(players: &mut Vec<PlayerId>, player: PlayerId) {
195        if !players.contains(&player) {
196            players.push(player);
197        }
198    }
199
200    fn ordered_players(game: &GameState, starter: PlayerId) -> Vec<PlayerId> {
201        let Some(offset) = game.player_order.iter().position(|&pid| pid == starter) else {
202            return game.player_order.clone();
203        };
204
205        (0..game.player_order.len())
206            .map(|idx| game.player_order[(offset + idx) % game.player_order.len()])
207            .collect()
208    }
209
210    fn sort_in_turn_order(game: &GameState, sa: &SpellAbility, players: &mut [PlayerId]) {
211        let starter = sa
212            .ir
213            .starting_with
214            .as_deref()
215            .and_then(|defined| {
216                ability_utils::resolve_defined_players_with_sa(
217                    defined,
218                    sa,
219                    sa.activating_player,
220                    game,
221                )
222                .into_iter()
223                .next()
224            })
225            .unwrap_or(game.turn.active_player);
226        let ordered = ordered_players(game, starter);
227
228        players.sort_by_key(|pid| {
229            ordered
230                .iter()
231                .position(|ordered_pid| ordered_pid == pid)
232                .unwrap_or(usize::MAX)
233        });
234    }
235
236    let ir_defined = ir_defined_param(sa, defined_param);
237    let has_defined = ir_defined.is_some_and(|defined| defined.is_some());
238    let use_targets = sa.uses_targeting() && (!defined_first || !has_defined);
239
240    if use_targets {
241        let mut result = sa.target_chosen.all_target_players();
242        sort_in_turn_order(game, sa, &mut result);
243        result
244    } else {
245        let mut result = Vec::new();
246        if let Some(Some(defined)) = ir_defined {
247            for d in &defined.refs {
248                let players = ability_utils::resolve_defined_players_with_sa(
249                    d.as_legacy_str(),
250                    sa,
251                    sa.activating_player,
252                    game,
253                );
254                for player in players {
255                    unique_push(&mut result, player);
256                }
257            }
258        } else {
259            for player in ability_utils::resolve_defined_players_with_sa(
260                "You",
261                sa,
262                sa.activating_player,
263                game,
264            ) {
265                unique_push(&mut result, player);
266            }
267        }
268        sort_in_turn_order(game, sa, &mut result);
269        result
270    }
271}
272
273fn ir_defined_param<'a>(
274    sa: &'a SpellAbility,
275    defined_param: &str,
276) -> Option<Option<&'a DefinedExpr>> {
277    if defined_param == keys::DEFINED {
278        Some(sa.ir.defined.as_ref())
279    } else if defined_param == keys::DEFINED_PLAYER {
280        Some(sa.ir.defined_player.as_ref())
281    } else {
282        None
283    }
284}
285
286fn resolve_defined_cards_for_sa_ref(
287    game: &GameState,
288    sa: &SpellAbility,
289    defined: &DefinedRef,
290) -> Vec<CardId> {
291    resolve_defined_cards_for_sa_ref_inner(game, sa, defined)
292}
293
294/// Resolve a `Defined$` string to card IDs in the context of a spell ability.
295/// Handles SA-specific defined values like "Targeted", "ParentTarget",
296/// "TriggeredCard", etc., in addition to the base AbilityUtils definitions.
297fn resolve_defined_cards_for_sa(game: &GameState, sa: &SpellAbility, defined: &str) -> Vec<CardId> {
298    let defined_ref = DefinedRef::parse(defined);
299    resolve_defined_cards_for_sa_ref_inner(game, sa, &defined_ref)
300}
301
302fn resolve_defined_cards_for_sa_ref_inner(
303    game: &GameState,
304    sa: &SpellAbility,
305    defined: &DefinedRef,
306) -> Vec<CardId> {
307    match defined {
308        DefinedRef::SelfCard => {
309            if sa.is_trigger {
310                if let (Some(source), Some(created_at)) =
311                    (sa.trigger_source, sa.trigger_source_zone_timestamp)
312                {
313                    let current = game.card(source);
314                    if current.zone_timestamp != created_at {
315                        return Vec::new();
316                    }
317                }
318            }
319            sa.source.into_iter().collect()
320        }
321        DefinedRef::Targeted | DefinedRef::TargetedCard | DefinedRef::ThisTargetedCard => {
322            sa.target_chosen.target_card.into_iter().collect()
323        }
324        DefinedRef::TriggeredTargetLkiCopy => triggered_target_lki_cards(sa),
325        DefinedRef::TriggeredCard | DefinedRef::TriggeredCardLkiCopy => {
326            let cards = sa.get_triggering_cards(AbilityKey::Card);
327            if cards.is_empty() {
328                sa.trigger_source.into_iter().collect()
329            } else {
330                cards
331            }
332        }
333        DefinedRef::ReplacedCard | DefinedRef::ReplacedCardLki => {
334            let cards = sa.get_triggering_cards(AbilityKey::ReplacedCard);
335            if cards.is_empty() {
336                sa.get_triggering_cards(AbilityKey::Card)
337            } else {
338                cards
339            }
340        }
341        DefinedRef::TriggeredNewCard | DefinedRef::TriggeredNewCardLkiCopy => {
342            let cards = sa.get_triggering_cards(AbilityKey::NewCard);
343            if cards.is_empty() {
344                sa.trigger_source.into_iter().collect()
345            } else {
346                cards
347            }
348        }
349        DefinedRef::TriggeredAttackers => sa.get_triggering_cards(AbilityKey::Attackers),
350        DefinedRef::TriggeredAttacker => sa.get_triggering_cards(AbilityKey::Attacker),
351        DefinedRef::TriggeredBlocker => sa.get_triggering_cards(AbilityKey::Blocker),
352        DefinedRef::Explorer => sa.get_triggering_cards(AbilityKey::Explorer),
353        DefinedRef::Explored => sa.get_triggering_cards(AbilityKey::Explored),
354        // Cards paid during cost — Java reads `SA.paidHash`; Rust stores the
355        // discarded slot on `discarded_cost_cards`, sacrificed slot on
356        // `GameState.last_sacrificed_card`.
357        DefinedRef::Discarded => sa.discarded_cost_cards.clone(),
358        DefinedRef::Sacrificed => game.last_sacrificed_card.into_iter().collect(),
359        _ => ability_utils::get_defined_cards(
360            game,
361            sa.source,
362            defined.as_legacy_str(),
363            Some(sa.activating_player),
364        ),
365    }
366}
367
368fn triggering_cards_from_key(sa: &SpellAbility, key: AbilityKey) -> Vec<CardId> {
369    let cards = sa.get_triggering_cards(key);
370    if !cards.is_empty() {
371        return cards;
372    }
373    sa.get_triggering_object(key)
374        .and_then(|raw| raw.parse::<u32>().ok())
375        .map(CardId)
376        .into_iter()
377        .collect()
378}
379
380fn triggered_target_lki_cards(sa: &SpellAbility) -> Vec<CardId> {
381    let target_cards = triggering_cards_from_key(sa, AbilityKey::TargetCard);
382    if !target_cards.is_empty() {
383        return target_cards;
384    }
385    if sa.get_triggering_player(AbilityKey::TargetPlayer).is_some() {
386        return Vec::new();
387    }
388    triggering_cards_from_key(sa, AbilityKey::Target)
389}
390
391// ── SpellAbilityEffect utility functions ────────────────────────────
392
393/// Tokenize a description string, replacing CARDNAME with the actual card name
394/// and NICKNAME with a short version.
395/// Mirrors Java's `SpellAbilityEffect.tokenizeString(SpellAbility, String)`.
396pub fn tokenize_string(game: &GameState, sa: &SpellAbility, desc: &str) -> String {
397    let card_name = sa
398        .source
399        .map(|cid| game.card(cid).card_name.clone())
400        .unwrap_or_else(|| "CARDNAME".to_string());
401
402    let mut result = desc.to_string();
403    result = result.replace("CARDNAME", &card_name);
404    result = result.replace("NICKNAME", &card_name);
405
406    // Replace DAMAGE with the NumDmg parameter if present
407    if let Some(num_dmg) = sa.ir.num_dmg_text.as_deref() {
408        result = result.replace("DAMAGE", num_dmg);
409    }
410
411    // Replace AMOUNT with relevant numeric parameter
412    if let Some(amount) = sa.ir.amount.as_deref().or(sa.ir.num_cards_text.as_deref()) {
413        result = result.replace("AMOUNT", amount);
414    }
415
416    result
417}
418
419/// Add a "forget on moved" trigger to a card — when the card changes zones,
420/// it is removed from its host's remembered list.
421/// Mirrors Java's `SpellAbilityEffect.addForgetOnMovedTrigger(SpellAbility, Card, String)`.
422pub fn add_forget_on_moved_trigger(
423    game: &mut GameState,
424    host_id: CardId,
425    remembered_card_id: CardId,
426) {
427    // In the Rust engine, zone-change cleanup of remembered cards is handled
428    // centrally in the zone-move logic. This function marks the card with a
429    // flag so the zone-move system knows to clean up.
430    // Store a SVar on the card so the zone-move system knows which host to clean up
431    game.card_mut(remembered_card_id)
432        .set_s_var("ForgetOnZoneChangeHost", host_id.0.to_string());
433}
434
435/// Create a temporary "effect" card in the command zone.
436/// Mirrors Java's `SpellAbilityEffect.createEffect(SpellAbility, Player, String, String)`.
437///
438/// Effect cards are invisible game objects that hold continuous effects,
439/// delayed triggers, and other state that persists beyond a single resolution.
440/// They are placed in the Command zone and cleaned up when their effect ends.
441pub fn create_effect(game: &mut GameState, sa: &SpellAbility, name: &str, _image: &str) -> CardId {
442    use forge_foundation::{CardTypeLine, ColorSet, ManaCost};
443
444    let owner = sa.activating_player;
445    let source_name = sa
446        .source
447        .map(|cid| game.card(cid).card_name.clone())
448        .unwrap_or_default();
449
450    let effect_name = if name.is_empty() {
451        format!("{source_name} Effect")
452    } else {
453        name.to_string()
454    };
455
456    let effect_card = crate::card::Card::new(
457        CardId(0), // will be assigned by create_card
458        effect_name,
459        owner,
460        CardTypeLine::parse("Effect"),
461        ManaCost::parse(""),
462        ColorSet::COLORLESS,
463        None,
464        None,
465        vec![],
466        vec![],
467    );
468
469    let effect_id = game.create_card(effect_card);
470    game.move_card(effect_id, forge_foundation::ZoneType::Command, owner);
471
472    // Java passes `image` for UI; Rust tracks provenance via `effect_source` instead.
473    if let Some(source_id) = sa.source {
474        game.card_mut(effect_id).effect_source = Some(source_id);
475        let source_svars = game.card(source_id).svars.clone();
476        game.card_mut(effect_id).set_svars_map(source_svars);
477    }
478
479    // Mark as an effect card (not a "real" card) via SVar
480    game.card_mut(effect_id).set_s_var("IsEffectCard", "True");
481
482    effect_id
483}
484
485/// Run/resolve a spell ability effect (the main entry point for effect dispatch).
486/// Mirrors Java's `SpellAbilityEffect.run(SpellAbility)` which calls resolve().
487///
488/// In the Rust engine this delegates to the effect dispatch system.
489pub fn run(ctx: &mut super::effects::EffectContext, sa: &SpellAbility) {
490    super::effects::resolve_effect(ctx, sa);
491}
492
493/// Track which card exiled another card, for "exile until" effects.
494/// Mirrors Java's `SpellAbilityEffect.handleExiledWith(SpellAbility, Card)`.
495///
496/// Sets the `exiled_with` field on the exiled card and adds it to the
497/// source card's imprinted list.
498pub fn handle_exiled_with(game: &mut GameState, sa: &SpellAbility, exiled_card_id: CardId) {
499    let source_id = match sa.source {
500        Some(id) => id,
501        None => return,
502    };
503
504    game.card_mut(exiled_card_id).set_exiled_by(Some(source_id));
505    game.card_mut(source_id).add_imprinted_card(exiled_card_id);
506}
507
508/// Execute the "exile with" command — exile a card and track the exile source.
509/// Mirrors Java's `SpellAbilityEffect.exileEffectCommand(Game, SpellAbility, Card)`.
510///
511/// Moves the card to exile, sets up the exiled_with tracking, and optionally
512/// adds it to the source's remembered list.
513pub fn exile_effect_command(
514    game: &mut GameState,
515    trigger_handler: &mut crate::trigger::handler::TriggerHandler,
516    sa: &SpellAbility,
517    card_id: CardId,
518) {
519    let owner = game.card(card_id).owner;
520    let old_zone = game.card(card_id).zone;
521
522    // Move the card to exile
523    game.move_card(card_id, forge_foundation::ZoneType::Exile, owner);
524
525    // Register zone triggers
526    trigger_handler.register_active_trigger(game, card_id);
527    super::effects::zone_triggers::emit_zone_trigger(
528        trigger_handler,
529        card_id,
530        old_zone,
531        forge_foundation::ZoneType::Exile,
532    );
533
534    // Set up the exiled_with relationship
535    handle_exiled_with(game, sa, card_id);
536
537    // Remember the exiled card if requested
538    if sa.ir.remember_exiled {
539        if let Some(source_id) = sa.source {
540            game.card_mut(source_id).add_remembered_card(card_id);
541        }
542    }
543}
544
545/// Render the stack description for a SpellAbility, including sub-ability text.
546/// Mirrors Java `SpellAbilityEffect.getStackDescriptionWithSubs(Map, SpellAbility)`.
547///
548/// `stack_desc_fallback` supplies the `getStackDescription(sa)` hook (per-effect
549/// overrides) when the SA has no `StackDescription$` param. Pass the SA's own
550/// `description` field for the default behavior.
551pub fn get_stack_description_with_subs(
552    game: &GameState,
553    sa: &SpellAbility,
554    stack_desc_fallback: &str,
555) -> String {
556    let mut sb = String::new();
557
558    let is_permanent_api = matches!(
559        sa.api,
560        Some(ApiType::PermanentCreature) | Some(ApiType::PermanentNoncreature)
561    );
562    let is_sub = matches!(sa.record_type, AbilityRecordType::SubAbility);
563
564    if !is_permanent_api {
565        if !is_sub {
566            if let Some(src_id) = sa.source {
567                sb.push_str(&game.card(src_id).card_name);
568                sb.push_str(" -");
569            }
570        }
571        sb.push(' ');
572    }
573
574    // Own description
575    if let Some(raw_stack_desc) = sa.ir.stack_description_text.as_deref() {
576        let (stack_desc, reps): (&str, Option<Vec<(String, String)>>) =
577            if let Some(rest) = raw_stack_desc.strip_prefix("REP") {
578                let pairs = rest
579                    .trim_start()
580                    .split(" & ")
581                    .filter_map(|s| {
582                        s.split_once('_')
583                            .map(|(a, b)| (a.to_string(), b.to_string()))
584                    })
585                    .collect();
586                ("SpellDescription", Some(pairs))
587            } else {
588                (raw_stack_desc, None)
589            };
590
591        if stack_desc.eq_ignore_ascii_case("SpellDescription") {
592            if let Some(raw_sdesc) = sa.ir.spell_description_text.as_deref() {
593                let mut spell_desc = raw_sdesc.replace(",,,,,,", " ").replace(",,,", " ");
594                // Strip reminder text `(...)`.
595                if let (Some(l), Some(r)) = (spell_desc.find(" ("), spell_desc.find(')')) {
596                    if r > l {
597                        let reminder = spell_desc[l..=r].to_string();
598                        spell_desc = spell_desc.replacen(&reminder, "", 1);
599                    }
600                }
601                if let Some(replacements) = &reps {
602                    for (from, to) in replacements {
603                        if let Some(idx) = spell_desc.find(from) {
604                            spell_desc.replace_range(idx..idx + from.len(), to);
605                        }
606                    }
607                    sb.push_str(&tokenize_string(game, sa, &spell_desc));
608                } else {
609                    sb.push_str(&spell_desc);
610                }
611            }
612            if reps.is_none() && has_any_target(sa) {
613                sb.push_str(" (Targeting: ");
614                sb.push_str(&join_targets(game, sa));
615                sb.push(')');
616            }
617        } else if !stack_desc.eq_ignore_ascii_case("None") {
618            sb.push_str(&tokenize_string(game, sa, stack_desc));
619        }
620    } else {
621        let cond_desc = sa.ir.condition_description_text.as_deref();
622        let after_desc = sa.ir.after_description_text.as_deref();
623        let base_desc = stack_desc_fallback.to_string();
624        if let Some(cd) = cond_desc {
625            sb.push_str(cd);
626            sb.push(' ');
627            if cd.ends_with(',') {
628                // Uncapitalize first letter of base_desc (mirrors Java StringUtils.uncapitalize).
629                let mut chars = base_desc.chars();
630                if let Some(c) = chars.next() {
631                    sb.extend(c.to_lowercase());
632                    sb.push_str(chars.as_str());
633                }
634            } else {
635                sb.push_str(&base_desc);
636            }
637        } else {
638            sb.push_str(&base_desc);
639        }
640        if let Some(ad) = after_desc {
641            sb.push(' ');
642            sb.push_str(ad);
643        }
644    }
645
646    // Sub-ability chain (Java: `sa.getSubAbility().getStackDescription()`).
647    // Permanent spells intentionally skip sub-description.
648    if !is_permanent_api {
649        if let Some(sub) = sa.sub_ability.as_deref() {
650            let sub_fallback = sub.description.clone();
651            sb.push_str(&get_stack_description_with_subs(game, sub, &sub_fallback));
652        }
653    }
654
655    // Announce/X value suffix.
656    if let Some(svar) = sa.ir.announce_text.as_deref() {
657        let amount = calculate_amount_for_sa(game, sa, svar);
658        sb.push_str(&format!(" ({svar}={amount})"));
659    } else if sa.cost_has_mana_x() {
660        sb.push_str(&format!(" (X={})", sa.x_mana_cost_paid));
661    }
662
663    // CARDNAME / NICKNAME substitution (already handled by tokenize_string for
664    // the REP-path, but cover the non-tokenized paths too).
665    if let Some(src_id) = sa.source {
666        let name = game.card(src_id).card_name.clone();
667        sb = sb.replace("CARDNAME", &name);
668        sb = sb.replace("NICKNAME", &name);
669    }
670
671    sb
672}
673
674fn has_any_target(sa: &SpellAbility) -> bool {
675    !sa.target_chosen.all_target_cards().is_empty()
676        || !sa.target_chosen.all_target_players().is_empty()
677}
678
679fn join_targets(game: &GameState, sa: &SpellAbility) -> String {
680    let mut names = Vec::new();
681    for cid in sa.target_chosen.all_target_cards() {
682        names.push(game.card(cid).card_name.clone());
683    }
684    for pid in sa.target_chosen.all_target_players() {
685        names.push(format!("P{}", pid.index()));
686    }
687    names.join(", ")
688}
689
690fn calculate_amount_for_sa(game: &GameState, sa: &SpellAbility, svar: &str) -> i32 {
691    if let Ok(n) = svar.parse::<i32>() {
692        return n;
693    }
694    let Some(src) = sa.source else {
695        return 0;
696    };
697    if sa.ir.semantic_numeric_params.contains_key(svar) {
698        return crate::svar::resolve_numeric_svar(game, sa, svar, 0);
699    }
700    let Some(expr) = game.card(src).get_s_var(svar) else {
701        return 0;
702    };
703    crate::svar::resolve_count_svar_for_sa(expr, game, src, sa.activating_player, sa)
704}
705
706/// Ask the activator's controller to pick a replacement chooser when the
707/// original chooser has lost the game mid-effect (CR 800.4g).
708///
709/// Mirrors Java `SpellAbilityEffect.getNewChooser(SpellAbility, Player)`.
710pub fn get_new_chooser(
711    game: &GameState,
712    agents: &mut [Box<dyn PlayerAgent>],
713    sa: &SpellAbility,
714    loser: PlayerId,
715) -> Option<PlayerId> {
716    let activator = sa.activating_player;
717    let loser_is_opponent = loser != activator;
718    let options: Vec<PlayerId> = game
719        .alive_players()
720        .into_iter()
721        .filter(|&pid| pid != activator)
722        .filter(|&pid| {
723            if loser_is_opponent {
724                // opponents of activator
725                pid != activator
726            } else {
727                // all other players
728                true
729            }
730        })
731        .collect();
732
733    if options.is_empty() {
734        return None;
735    }
736    agents[activator.index()].choose_target_player(activator, &options, Some(sa))
737}
738
739/// `AtEOT$ <action>` — delayed-trigger action token.
740/// Mirrors Java's `SpellAbilityEffect.registerDelayedTrigger` `location` arg.
741#[derive(Debug, Clone, Copy, PartialEq, Eq, strum_macros::EnumString, Default)]
742#[strum(ascii_case_insensitive)]
743pub enum AtEotAction {
744    /// Owner sacrifices the remembered card (Controller$ You).
745    #[default]
746    Sacrifice,
747    /// Controller sacrifices (no Controller$ override).
748    SacrificeCtrl,
749    Destroy,
750    Exile,
751    Hand,
752    Library,
753}
754
755impl AtEotAction {
756    /// SVar payload registered on the delayed trigger.
757    pub fn execute_svar(self) -> &'static str {
758        match self {
759            AtEotAction::Sacrifice => {
760                "DB$ SacrificeAll | Defined$ DelayTriggerRememberedLKI | Controller$ You"
761            }
762            AtEotAction::SacrificeCtrl => "DB$ SacrificeAll | Defined$ DelayTriggerRememberedLKI",
763            AtEotAction::Exile => {
764                "DB$ ChangeZone | Defined$ DelayTriggerRememberedLKI | Origin$ Battlefield | \
765                 Destination$ Exile"
766            }
767            AtEotAction::Hand => {
768                "DB$ ChangeZone | Defined$ DelayTriggerRememberedLKI | Origin$ Battlefield | \
769                 Destination$ Hand"
770            }
771            AtEotAction::Library => {
772                "DB$ ChangeZone | Defined$ DelayTriggerRememberedLKI | Origin$ Battlefield | \
773                 Destination$ Library | Shuffle$ True"
774            }
775            AtEotAction::Destroy => "DB$ Destroy | Defined$ DelayTriggerRememberedLKI",
776        }
777    }
778}
779
780/// Register a delayed trigger that fires at end of turn and performs `action`
781/// on `remembered` cards. Mirrors Java
782/// `SpellAbilityEffect.registerDelayedTrigger(sa, location, iterable)`.
783///
784/// `action` parses via `AtEotAction::from_str` — unknown tokens default to
785/// `Sacrifice` (matches Java when the call site passes an unrecognized tag).
786pub fn register_at_eot(
787    trigger_handler: &mut crate::trigger::handler::TriggerHandler,
788    game: &crate::game::GameState,
789    sa: &SpellAbility,
790    action: &str,
791    remembered: Vec<CardId>,
792) {
793    if remembered.is_empty() {
794        return;
795    }
796    let action = action.parse::<AtEotAction>().unwrap_or_default();
797    let execute_svar = action.execute_svar().to_string();
798    trigger_handler.register_delayed_trigger(crate::trigger::handler::DelayedTrigger {
799        mode: crate::trigger::TriggerType::Phase,
800        trigger_mode: Box::new(crate::trigger::trigger_phase::TriggerPhase {
801            phases: vec![forge_foundation::PhaseType::EndOfTurn],
802            valid_player: None,
803        }) as Box<dyn crate::trigger::TriggerBehavior>,
804        params: crate::parsing::Params::default(),
805        execute_svar,
806        controller: sa.activating_player,
807        source_card: sa.source.unwrap_or(remembered[0]),
808        created_turn: game.turn.turn_number,
809        created_phase: game.turn.phase,
810        target_card: None,
811        remembered_amount: 0,
812        remembered_cards: remembered.clone(),
813        remembered_players: Vec::new(),
814        remembered_lki_cards: remembered,
815        sort_after_active: false,
816        trigger_order: None,
817    });
818}
819
820pub fn add_self_trigger_at_eot(
821    trigger_handler: &mut crate::trigger::handler::TriggerHandler,
822    game: &mut crate::game::GameState,
823    location: &str,
824    card_id: CardId,
825) {
826    let mut player = "";
827    let mut action = location;
828    let mut whose = " the ";
829    if let Some((prefix, suffix)) = location.split_once('_') {
830        player = prefix;
831        action = suffix;
832        if player.eq_ignore_ascii_case("You") {
833            whose = " your next ";
834        }
835    }
836
837    let mut trigger_raw = format!(
838        "Mode$ Phase | Phase$ End of Turn | TriggerZones$ Battlefield | TriggerDescription$ At the beginning of{}end step, {} CARDNAME.",
839        whose,
840        action.to_ascii_lowercase()
841    );
842    if !player.is_empty() {
843        trigger_raw.push_str(" | Player$ ");
844        trigger_raw.push_str(player);
845    }
846
847    let Some(mut trigger) = trigger_handler.parse_trigger(&trigger_raw) else {
848        return;
849    };
850
851    let effect = match action {
852        "Sacrifice" => "DB$ Sacrifice | SacValid$ Self",
853        "Exile" => "DB$ ChangeZone | Origin$ Battlefield | Destination$ Exile | Defined$ Self",
854        _ => "",
855    };
856    if !effect.is_empty() {
857        trigger.execute = "EndOfTurnLeavePlay".to_string();
858        game.card_mut(card_id)
859            .set_s_var("EndOfTurnLeavePlay", effect);
860    }
861    game.card_mut(card_id).add_trigger(trigger);
862}
863
864/// Validate a `Duration$` param against the host card's current state.
865/// Mirrors Java's `SpellAbilityEffect.checkValidDuration(String, SpellAbility)`.
866///
867/// Returns `true` when the duration is either absent or its prerequisites
868/// (host in play, tapped, controlled by activator, ...) are still satisfied
869/// at resolution time.
870pub fn check_valid_duration(
871    game: &GameState,
872    sa: &SpellAbility,
873    duration: Option<&AbilityDuration>,
874) -> bool {
875    let Some(duration) = duration else {
876        return true;
877    };
878    let Some(host_id) = sa.source else {
879        return true;
880    };
881    let host = game.card(host_id);
882    let in_play_or_stack = matches!(
883        host.zone,
884        forge_foundation::ZoneType::Battlefield | forge_foundation::ZoneType::Stack
885    );
886
887    if duration.needs_host_in_play_or_stack() && !in_play_or_stack {
888        return false;
889    }
890    if duration.needs_host_not_phased_out() && host.phased_out {
891        return false;
892    }
893    if duration.needs_host_control() && host.controller != sa.activating_player {
894        return false;
895    }
896    if duration.needs_host_tapped() && !host.tapped {
897        return false;
898    }
899    if duration.needs_targeted_card_tapped() {
900        if let Some(tgt_id) = sa.target_chosen.target_card {
901            let tgt = game.card(tgt_id);
902            if !tgt.tapped || tgt.phased_out {
903                return false;
904            }
905        }
906    }
907    true
908}
909
910/// Set up the "replace dying" replacement effect for cards that should
911/// be exiled instead of dying this turn.
912///
913/// Mirrors Java's `SpellAbilityEffect.replaceDying(sa)`.
914pub fn replace_dying(game: &mut GameState, sa: &SpellAbility) -> Vec<CardId> {
915    if sa.ir.replace_dying_defined.is_none() && sa.ir.replace_dying_valid.is_none() {
916        return Vec::new();
917    }
918
919    // Check condition (currently only Kicked)
920    if let Some(cond) = sa.ir.replace_dying_condition.as_ref() {
921        if matches!(cond, ReplaceDyingCondition::Kicked) && !sa.kicked {
922            return Vec::new();
923        }
924    }
925
926    let cards = if let Some(defined) = sa.ir.replace_dying_defined_text.as_deref() {
927        let cards = resolve_defined_cards_for_sa(game, sa, defined);
928        if cards.is_empty() {
929            return Vec::new();
930        }
931        cards
932    } else {
933        Vec::new()
934    };
935
936    let effect_name = sa
937        .source
938        .map(|source_id| format!("{}'s Effect", game.card(source_id).card_name))
939        .unwrap_or_else(|| "Effect".to_string());
940    let effect_id = create_effect(game, sa, &effect_name, "");
941    {
942        let effect = game.card_mut(effect_id);
943        effect.add_remembered_cards(cards.iter().copied());
944        effect.set_forget_on_moved_origin(Some(forge_foundation::ZoneType::Battlefield));
945        effect.set_exile_when_no_remembered(true);
946        effect.set_temp_effect_until_eot(true);
947    }
948
949    let valid = sa
950        .ir
951        .replace_dying_valid
952        .as_deref()
953        .unwrap_or("Card.IsRemembered");
954    let zone = sa.ir.replace_dying_zone_text.as_deref().unwrap_or("Exile");
955    let mut replacement_raw = format!(
956        "R$ Event$ Moved | ValidLKI$ {valid} | Origin$ Battlefield | Destination$ Graveyard | NewDestination$ {zone} | Description$ If that permanent would die this turn, exile it instead."
957    );
958    if sa.ir.replace_dying_exiled_with {
959        replacement_raw.push_str(" | ExiledWithEffectSource$ True");
960    }
961
962    let effect = game.card_mut(effect_id);
963    add_replacement_effect(effect, &replacement_raw);
964
965    cards
966}