Skip to main content

manabrew_engine/ability/effects/
charm_effect.rs

1use super::{resolve_numeric_svar, EffectContext};
2use crate::agent::PlayerAgent;
3use crate::game::GameState;
4use crate::ids::PlayerId;
5use crate::parsing::keys;
6use crate::parsing::Params;
7use crate::spellability::{build_spell_ability, SpellAbility};
8
9/// Scope of a `ChoiceRestriction$` on `SP$ Charm`. Past selections within
10/// this scope are filtered out of the available mode list.
11/// Mirrors Java's `CharmEffect` string literals.
12#[derive(Debug, Clone, Copy, PartialEq, Eq, strum_macros::EnumString)]
13#[strum(ascii_case_insensitive)]
14pub enum ChoiceRestriction {
15    ThisGame,
16    ThisTurn,
17    YourLastCombat,
18}
19
20/// `SP$ Charm` — modal spell: player chooses N effects from a list.
21///
22/// Mirrors Java's `CharmEffect.java`.
23///
24/// # Card script format
25/// ```text
26/// A:SP$ Charm | Choices$ Mode1,Mode2,Mode3 | [CharmNum$ 2] | [MinCharmNum$ 1]
27/// SVar:Mode1:DB$ Draw | NumCards$ 1 | SpellDescription$ Draw a card.
28/// SVar:Mode2:DB$ Destroy | ValidTgts$ Creature | SpellDescription$ Destroy target creature.
29/// ```
30///
31/// Struct form of this effect so it can participate in the
32/// `SpellAbilityEffect` trait hierarchy — mirrors Java's
33/// `CharmEffect` class extending `SpellAbilityEffect`.
34#[manabrew_engine_macros::spell_effect(CharmEffect)]
35fn resolve(ctx: &mut EffectContext, sa: &crate::spellability::SpellAbility) {
36    // Java chains chosen charm modes onto the root SpellAbility during casting
37    // (via make_choices_precast), then the stack resolver walks the full
38    // sub-ability chain. If sub-abilities are already present, just return —
39    // the stack's resolve_ability loop will walk and resolve each sub-ability.
40    if sa.sub_ability.is_some() {
41        return;
42    }
43
44    let source_id = match sa.source {
45        Some(id) => id,
46        None => return,
47    };
48
49    let choices_str = sa.ir.choices.clone().unwrap_or_default();
50    if choices_str.is_empty() {
51        return;
52    }
53
54    let charm_num = resolve_numeric_svar(ctx.game, sa, keys::CHARM_NUM, 1).max(0) as usize;
55    let min_charm_num =
56        resolve_numeric_svar(ctx.game, sa, keys::MIN_CHARM_NUM, charm_num as i32).max(0) as usize;
57
58    let player = sa.activating_player;
59
60    // Collect SVar names for each mode
61    let mode_svars: Vec<&str> = choices_str.split(',').map(|s| s.trim()).collect();
62
63    // Get mode texts from the source card's SVars
64    let svars = ctx.game.card(source_id).svars.clone();
65    let mode_texts: Vec<String> = mode_svars
66        .iter()
67        .filter_map(|svar| svars.get(*svar).cloned())
68        .collect();
69
70    if mode_texts.is_empty() {
71        return;
72    }
73
74    // Extract SpellDescription$ for each mode (human-readable label)
75    let mode_descriptions: Vec<String> = mode_texts
76        .iter()
77        .map(|text| {
78            let params = Params::from_raw(text);
79            params
80                .get_cloned(keys::SPELL_DESCRIPTION)
81                .unwrap_or_else(|| text.clone())
82        })
83        .collect();
84
85    // `ChoiceRestriction$` — drop modes already chosen on this source within
86    // the restriction scope. Java tracks per-source history on
87    // `Card.chosenModesThisGame` etc.; Rust stores a {mode_svar → turn_number}
88    // map on the Card keyed by the scope enum below.
89    let restriction = sa
90        .ir
91        .choice_restriction_text
92        .as_deref()
93        .and_then(|s| s.parse::<ChoiceRestriction>().ok());
94    let current_turn = ctx.game.turn.turn_number as i32;
95    let last_combat_turn = ctx
96        .game
97        .last_combat_turn_of(sa.activating_player)
98        .unwrap_or(i32::MIN);
99    let is_restricted_index = |mode_svar: &str| -> bool {
100        let Some(scope) = restriction else {
101            return false;
102        };
103        let history = &ctx.game.card(source_id).chosen_charm_modes;
104        let Some(&turn) = history.get(mode_svar) else {
105            return false;
106        };
107        match scope {
108            ChoiceRestriction::ThisGame => true,
109            ChoiceRestriction::ThisTurn => turn == current_turn,
110            ChoiceRestriction::YourLastCombat => turn >= last_combat_turn,
111        }
112    };
113
114    // Filter modes to only those with valid targets (matching Java's CharmEffect
115    // which passes only `possible` modes to chooseModeForAbility).
116    let valid_mode_indices: Vec<usize> = mode_texts
117        .iter()
118        .enumerate()
119        .filter(|(i, text)| {
120            !is_restricted_index(mode_svars[*i])
121                && mode_has_valid_targets(ctx, text, player, source_id)
122        })
123        .map(|(i, _)| i)
124        .collect();
125
126    if valid_mode_indices.is_empty() {
127        return; // No modes have valid targets — spell fizzles
128    }
129
130    let valid_descriptions: Vec<String> = valid_mode_indices
131        .iter()
132        .map(|&i| mode_descriptions[i].clone())
133        .collect();
134
135    // Check if Entwine was paid (SA flag) — if so, auto-select all modes
136    let entwine_paid = sa.ir.entwine || sa.kicked; // Entwine is sometimes represented as kicked
137
138    // Check source card for Entwine/Escalate keywords
139    let has_entwine = ctx.game.card(source_id).get_entwine_cost().is_some();
140    let has_escalate = ctx.game.card(source_id).get_escalate_cost().is_some();
141
142    // If Escalate, allow choosing more modes (up to all)
143    let charm_num = if has_escalate {
144        mode_texts.len()
145    } else {
146        charm_num
147    };
148
149    // Ask the activating player to choose mode(s)
150    let use_preselected_modes = should_use_preselected_modes(ctx.game, source_id, &mode_texts);
151    // Check if modes were pre-selected (Spree — chosen during casting before payment)
152    let pre_selected = if use_preselected_modes {
153        ctx.game.card_mut(source_id).chosen_modes.take()
154    } else {
155        ctx.game.card_mut(source_id).chosen_modes = None;
156        None
157    };
158    let mut chosen_indices: Vec<usize> = if let Some(pre) = pre_selected {
159        // Spree: modes already chosen before payment
160        pre
161    } else if entwine_paid || (has_entwine && sa.kicked) {
162        // Entwine: all valid modes (mapped back to original indices)
163        valid_mode_indices.clone()
164    } else {
165        let agent_choices = ctx.agents[player.index()].choose_mode(
166            player,
167            &valid_descriptions,
168            min_charm_num,
169            charm_num.min(valid_mode_indices.len()),
170            Some(source_id),
171        );
172        // Map agent choices (indices into valid_descriptions) back to original mode indices
173        agent_choices
174            .into_iter()
175            .filter_map(|i| valid_mode_indices.get(i).copied())
176            .collect()
177    };
178
179    // Java `CharmEffect.chainAbilities` sorts chosen modes by their declared
180    // `CharmOrder` (1-based `Choices$` list index) before resolving, so Destroy
181    // always runs before GainLife even if the player picked GainLife first.
182    // Mirror that here — agent pick order must not leak into target prompts.
183    chosen_indices.sort();
184
185    // Record chosen modes into source card's history so a future cast can
186    // honor `ChoiceRestriction$`. Also honors `CanRepeatModes` (Rust default)
187    // vs dedup mode.
188    for &idx in &chosen_indices {
189        if let Some(svar_name) = mode_svars.get(idx).copied() {
190            ctx.game
191                .card_mut(source_id)
192                .chosen_charm_modes
193                .insert(svar_name.to_string(), current_turn);
194        }
195    }
196
197    // Resolve each chosen mode in declaration order
198    for idx in chosen_indices {
199        if idx >= mode_texts.len() {
200            continue;
201        }
202        let mode_text = &mode_texts[idx];
203
204        // Build the mode's SpellAbility (recursively includes SubAbility$ chain)
205        let mut mode_sa = build_spell_ability(ctx.game, source_id, mode_text, player);
206        mode_sa.source = Some(source_id);
207        // Propagate trigger context from parent SA to mode SA so that
208        // effects like Modular can read trigger_remembered_amount.
209        mode_sa.trigger_remembered_amount = sa.trigger_remembered_amount;
210
211        // Walk the sub-ability chain: set targets then resolve each node
212        let mut cur_opt: Option<SpellAbility> = Some(mode_sa);
213        while let Some(mut cur_sa) = cur_opt {
214            setup_mode_targets(ctx, &mut cur_sa, player);
215            super::resolve_effect(ctx, &cur_sa);
216            // Descend into sub-ability (unbox)
217            cur_opt = cur_sa.sub_ability.map(|b| *b);
218            if ctx.game.game_over {
219                break;
220            }
221        }
222
223        if ctx.game.game_over {
224            break;
225        }
226    }
227}
228
229pub fn make_choices_precast(
230    game: &mut GameState,
231    agents: &mut [Box<dyn PlayerAgent>],
232    sa: &mut SpellAbility,
233) -> bool {
234    make_choices_precast_with_count(game, agents, sa).is_some()
235}
236
237pub fn make_choices_precast_with_count(
238    game: &mut GameState,
239    agents: &mut [Box<dyn PlayerAgent>],
240    sa: &mut SpellAbility,
241) -> Option<usize> {
242    let source_id = match sa.source {
243        Some(id) => id,
244        None => return Some(0),
245    };
246
247    let choices_str = sa.ir.choices.clone().unwrap_or_default();
248    if choices_str.is_empty() {
249        return Some(0);
250    }
251
252    let player = sa.activating_player;
253    let mode_svars: Vec<&str> = choices_str.split(',').map(|s| s.trim()).collect();
254    let svars = game.card(source_id).svars.clone();
255    let mode_texts: Vec<String> = mode_svars
256        .iter()
257        .filter_map(|svar| svars.get(*svar).cloned())
258        .collect();
259    if mode_texts.is_empty() {
260        return None;
261    }
262
263    let mode_descriptions: Vec<String> = mode_texts
264        .iter()
265        .map(|text| {
266            let params = Params::from_raw(text);
267            params
268                .get_cloned(keys::SPELL_DESCRIPTION)
269                .unwrap_or_else(|| text.clone())
270        })
271        .collect();
272
273    // Drop modes already chosen on this source within the `ChoiceRestriction$`
274    // scope (mirror of the same filter in `resolve` — must run here too because
275    // triggered abilities make their mode choices precast and the resolver only
276    // consumes the pre-selected list).
277    let restriction = sa
278        .ir
279        .choice_restriction_text
280        .as_deref()
281        .and_then(|s| s.parse::<ChoiceRestriction>().ok());
282    let current_turn = game.turn.turn_number as i32;
283    let last_combat_turn = game.last_combat_turn_of(player).unwrap_or(i32::MIN);
284    let is_restricted_index = |mode_svar: &str| -> bool {
285        let Some(scope) = restriction else {
286            return false;
287        };
288        let history = &game.card(source_id).chosen_charm_modes;
289        let Some(&turn) = history.get(mode_svar) else {
290            return false;
291        };
292        match scope {
293            ChoiceRestriction::ThisGame => true,
294            ChoiceRestriction::ThisTurn => turn == current_turn,
295            ChoiceRestriction::YourLastCombat => turn >= last_combat_turn,
296        }
297    };
298
299    let valid_mode_indices: Vec<usize> = mode_texts
300        .iter()
301        .enumerate()
302        .filter(|(i, text)| {
303            !is_restricted_index(mode_svars[*i])
304                && mode_has_valid_targets_in_game(game, text, player, source_id)
305        })
306        .map(|(i, _)| i)
307        .collect();
308    if valid_mode_indices.is_empty() {
309        return None;
310    }
311
312    let valid_descriptions: Vec<String> = valid_mode_indices
313        .iter()
314        .map(|&i| mode_descriptions[i].clone())
315        .collect();
316
317    let has_entwine = game.card(source_id).get_entwine_cost().is_some();
318    let has_escalate = game.card(source_id).get_escalate_cost().is_some();
319    let can_repeat = sa.ir.can_repeat_modes;
320
321    let mut charm_num = resolve_numeric_svar(game, sa, keys::CHARM_NUM, 1).max(0) as usize;
322    let min_charm_num =
323        resolve_numeric_svar(game, sa, keys::MIN_CHARM_NUM, charm_num as i32).max(0) as usize;
324    if has_escalate {
325        charm_num = mode_texts.len();
326    }
327    if !can_repeat && min_charm_num > valid_mode_indices.len() {
328        return None;
329    }
330
331    let use_preselected_modes = should_use_preselected_modes(game, source_id, &mode_texts);
332    let pre_selected = if use_preselected_modes {
333        game.card_mut(source_id).chosen_modes.take()
334    } else {
335        game.card_mut(source_id).chosen_modes = None;
336        None
337    };
338    let mut chosen_indices: Vec<usize> = if let Some(pre) = pre_selected {
339        pre
340    } else if sa.ir.entwine || (has_entwine && sa.kicked) {
341        valid_mode_indices.clone()
342    } else {
343        let chosen = agents[player.index()].choose_mode(
344            player,
345            &valid_descriptions,
346            min_charm_num,
347            charm_num.min(valid_mode_indices.len()),
348            Some(source_id),
349        );
350        chosen
351            .into_iter()
352            .filter_map(|i| valid_mode_indices.get(i).copied())
353            .collect()
354    };
355
356    if chosen_indices.len() < min_charm_num {
357        return None;
358    }
359
360    // Mirror Java's `CharmEffect.chainAbilities`: resolve modes in the order
361    // they were declared (CharmOrder), not in the order the player picked
362    // them. Otherwise mode target prompts fire in agent-pick order and
363    // cascade RNG divergences vs Java.
364    chosen_indices.sort();
365    let selected_mode_count = chosen_indices.len();
366
367    // Record chosen modes into the source's history so the *next* trigger
368    // fire on this source (e.g. another Teval's Judgment trigger from the
369    // same turn's graveyard movements) honors `ChoiceRestriction$ ThisTurn`.
370    // The resolve-time path also records, but precast-driven flows like
371    // triggered Charms never reach the resolver's recorder for the freshly
372    // built SA, so we have to do it here too.
373    for &idx in &chosen_indices {
374        if let Some(svar_name) = mode_svars.get(idx).copied() {
375            game.card_mut(source_id)
376                .chosen_charm_modes
377                .insert(svar_name.to_string(), current_turn);
378        }
379    }
380
381    sa.sub_ability = None;
382    let parent_trigger_remembered = sa.trigger_remembered_amount;
383    for idx in chosen_indices {
384        if idx >= mode_texts.len() {
385            continue;
386        }
387        let mut mode_sa = build_spell_ability(game, source_id, &mode_texts[idx], player);
388        mode_sa.source = Some(source_id);
389        // Propagate trigger context from parent SA so effects like Modular
390        // can access trigger_remembered_amount at resolution time.
391        mode_sa.trigger_remembered_amount = parent_trigger_remembered;
392        append_subability(sa, mode_sa);
393    }
394
395    Some(selected_mode_count)
396}
397
398/// Pre-cast legality check for Charm mode selection.
399///
400/// Mirrors Java `CharmEffect.makeChoices` behavior enough to decide whether the
401/// cast should proceed at all: if not enough legal modes exist, casting fails.
402pub(crate) fn can_make_choices_precast(
403    game: &GameState,
404    player: PlayerId,
405    source_id: crate::ids::CardId,
406    charm_sa_text: &str,
407) -> bool {
408    let sa_params = Params::from_raw(charm_sa_text);
409    let Some(choices_str) = sa_params.get(keys::CHOICES) else {
410        return true;
411    };
412
413    let mode_svars: Vec<&str> = choices_str.split(',').map(|s| s.trim()).collect();
414    if mode_svars.is_empty() {
415        return false;
416    }
417
418    let svars = game.card(source_id).svars.clone();
419    let mode_texts: Vec<String> = mode_svars
420        .iter()
421        .filter_map(|svar| svars.get(*svar).cloned())
422        .collect();
423    if mode_texts.is_empty() {
424        return false;
425    }
426
427    let mut charm_num: usize = sa_params
428        .get(keys::CHARM_NUM)
429        .and_then(|s| {
430            s.parse().ok().or_else(|| {
431                // If not a plain integer, try resolving as an SVar from the source card.
432                game.card(source_id)
433                    .svars
434                    .get(s.trim())
435                    .and_then(|v| v.parse().ok())
436            })
437        })
438        .unwrap_or(1);
439    let min_charm_num: usize = sa_params
440        .get(keys::MIN_CHARM_NUM)
441        .and_then(|s| {
442            s.parse().ok().or_else(|| {
443                game.card(source_id)
444                    .svars
445                    .get(s.trim())
446                    .and_then(|v| v.parse().ok())
447            })
448        })
449        .unwrap_or(charm_num);
450    let can_repeat = sa_params.has(keys::CAN_REPEAT_MODES);
451
452    let valid_count = mode_texts
453        .iter()
454        .filter(|text| mode_has_valid_targets_in_game(game, text, player, source_id))
455        .count();
456
457    if valid_count == 0 {
458        return false;
459    }
460
461    if !can_repeat && min_charm_num > valid_count {
462        return false;
463    }
464
465    if !can_repeat {
466        charm_num = charm_num.min(valid_count);
467    }
468
469    charm_num >= min_charm_num
470}
471
472/// Collect valid mode indices for a charm/modal spell.
473/// Mirrors Java's `CharmEffect.makePossibleOptions(SpellAbility)`.
474///
475/// Returns the indices (into the mode list) of modes that have valid targets.
476pub fn make_possible_options(
477    game: &GameState,
478    source_id: crate::ids::CardId,
479    player: PlayerId,
480    choices_str: &str,
481) -> Vec<usize> {
482    let mode_svars: Vec<&str> = choices_str.split(',').map(|s| s.trim()).collect();
483    let svars = game.card(source_id).svars.clone();
484    let mode_texts: Vec<String> = mode_svars
485        .iter()
486        .filter_map(|svar| svars.get(*svar).cloned())
487        .collect();
488
489    mode_texts
490        .iter()
491        .enumerate()
492        .filter(|(_, text)| mode_has_valid_targets_in_game(game, text, player, source_id))
493        .map(|(i, _)| i)
494        .collect()
495}
496
497/// Build a formatted description string for charm mode choices.
498/// Mirrors Java's `CharmEffect.makeFormatedDescription(SpellAbility)`.
499///
500/// Returns a description listing all available modes with their descriptions.
501pub fn make_formated_description(
502    game: &GameState,
503    source_id: crate::ids::CardId,
504    choices_str: &str,
505) -> String {
506    let mode_svars: Vec<&str> = choices_str.split(',').map(|s| s.trim()).collect();
507    let svars = game.card(source_id).svars.clone();
508    let mode_texts: Vec<String> = mode_svars
509        .iter()
510        .filter_map(|svar| svars.get(*svar).cloned())
511        .collect();
512
513    let mut description = String::new();
514    description.push_str("Choose one —\n");
515    for (i, text) in mode_texts.iter().enumerate() {
516        let params = Params::from_raw(text);
517        let mode_desc = params
518            .get_cloned(keys::SPELL_DESCRIPTION)
519            .unwrap_or_else(|| text.clone());
520        description.push_str(&format!("• {mode_desc}\n"));
521        let _ = i; // index for potential numbering
522    }
523    description
524}
525
526/// Make charm mode choices during pre-cast.
527/// Mirrors Java's `CharmEffect.makeChoices(SpellAbility)`.
528///
529/// This is a wrapper around `make_choices_precast` for structural parity.
530pub fn make_choices(
531    game: &mut GameState,
532    agents: &mut [Box<dyn PlayerAgent>],
533    sa: &mut SpellAbility,
534) -> bool {
535    make_choices_precast(game, agents, sa)
536}
537
538/// Chain a list of sub-abilities (modes) onto a root spell ability.
539/// Mirrors Java's `CharmEffect.chainAbilities(SpellAbility, List<AbilitySub>)`.
540///
541/// Appends each mode ability as a sub-ability at the end of the SA chain.
542pub fn chain_abilities(
543    game: &GameState,
544    sa: &mut SpellAbility,
545    mode_texts: &[String],
546    player: PlayerId,
547    source_id: crate::ids::CardId,
548) {
549    for mode_text in mode_texts {
550        let mut mode_sa = build_spell_ability(game, source_id, mode_text, player);
551        mode_sa.source = Some(source_id);
552        mode_sa.trigger_remembered_amount = sa.trigger_remembered_amount;
553        append_subability(sa, mode_sa);
554    }
555}
556
557/// Check whether a charm mode has valid targets (or needs no targets).
558///
559/// Mirrors Java's pre-filtering of `possible` modes in CharmEffect before
560/// calling `chooseModeForAbility`. Modes without targeting requirements are
561/// always valid. Modes requiring specific targets are valid only if at least
562/// one legal candidate exists.
563fn mode_has_valid_targets(
564    ctx: &EffectContext,
565    mode_text: &str,
566    player: PlayerId,
567    source_id: crate::ids::CardId,
568) -> bool {
569    mode_has_valid_targets_in_game(ctx.game, mode_text, player, source_id)
570}
571
572fn append_subability(root: &mut SpellAbility, mode_sa: SpellAbility) {
573    let mut slot = &mut root.sub_ability;
574    loop {
575        match slot {
576            Some(node) => slot = &mut node.sub_ability,
577            None => {
578                *slot = Some(Box::new(mode_sa));
579                return;
580            }
581        }
582    }
583}
584
585fn should_use_preselected_modes(
586    game: &GameState,
587    source_id: crate::ids::CardId,
588    mode_texts: &[String],
589) -> bool {
590    let source = game.card(source_id);
591    if source.has_keyword("Spree") || source.has_keyword("Tiered") {
592        return true;
593    }
594
595    mode_texts.iter().any(|text| {
596        let params = Params::from_raw(text);
597        params.has(keys::MODE_COST)
598    })
599}
600
601pub(crate) fn mode_has_valid_targets_in_game(
602    game: &GameState,
603    mode_text: &str,
604    player: PlayerId,
605    source_id: crate::ids::CardId,
606) -> bool {
607    let sa = build_spell_ability(game, source_id, mode_text, player);
608    let tr = match &sa.target_restrictions {
609        Some(tr) => tr,
610        None => return true, // No targeting = always valid
611    };
612
613    // Match Java CharmEffect.makePossibleOptions(): only drop a targeted mode
614    // when it requires at least one target and the full targeting engine finds
615    // zero legal candidates.
616    if tr.get_min_targets(game, &sa) <= 0 {
617        return true;
618    }
619
620    tr.has_candidates(game, player, sa.source)
621}
622
623fn setup_mode_targets(ctx: &mut EffectContext, mode_sa: &mut SpellAbility, player: PlayerId) {
624    if !mode_sa.uses_targeting() {
625        return;
626    }
627    mode_sa.targeting_player = Some(player);
628    ctx.agents[player.index()].choose_targets_for(mode_sa, ctx.game, ctx.mana_pools);
629}
630
631#[cfg(test)]
632mod tests {
633    use crate::ability::spell_ability_effect::SpellAbilityEffect;
634    use forge_foundation::{CardTypeLine, ColorSet, ManaCost, ZoneType};
635    use std::collections::{BTreeMap, HashMap};
636
637    use crate::ability::effects::EffectContext;
638    use crate::agent::PassAgent;
639    use crate::card::Card;
640    use crate::game::GameState;
641    use crate::ids::{CardId, PlayerId};
642    use crate::mana::ManaPool;
643    use crate::spellability::SpellAbility;
644    use crate::trigger::handler::TriggerHandler;
645
646    #[test]
647    fn charm_choose_mode_zero_choices_noops() {
648        let mut game = GameState::new(&["Alice", "Bob"], 20);
649        let p0 = PlayerId(0);
650
651        // A charm with no Choices$ should be a no-op
652        let sa = SpellAbility::new_simple(None, p0, "A:SP$ Charm");
653        let mut th = TriggerHandler::new();
654        let mut agents: Vec<Box<dyn crate::agent::PlayerAgent>> =
655            vec![Box::new(PassAgent), Box::new(PassAgent)];
656        let mut mp = vec![ManaPool::default(), ManaPool::default()];
657        let templates = HashMap::new();
658        let templates_variants = HashMap::new();
659        let token_fallback = HashMap::new();
660        let edition_dates: HashMap<String, String> = HashMap::new();
661        let mut rng_adapter = crate::game_rng::ThreadRngAdapter;
662        let mut ctx = EffectContext {
663            game: &mut game,
664            combat: None,
665            agents: &mut agents,
666            trigger_handler: &mut th,
667            token_templates: &templates,
668            token_art_variants: &templates_variants,
669            token_fallback: &token_fallback,
670            edition_dates: &edition_dates,
671            mana_pools: &mut mp,
672            parent_target_card: None,
673            rng: &mut rng_adapter,
674        };
675        // Should not panic
676        super::CharmEffect::resolve(&mut ctx, &sa);
677    }
678
679    /// Integration test: charm with two draw modes, PassAgent picks first mode.
680    /// Uses a live card with SVars so `build_spell_ability` can look them up.
681    #[test]
682    fn charm_resolves_chosen_non_targeted_mode() {
683        let mut game = GameState::new(&["Alice", "Bob"], 20);
684        let p0 = PlayerId(0);
685        let p1 = PlayerId(1);
686
687        // Build a fake "charm" card with two modes stored as SVars
688        let mut svars = BTreeMap::new();
689        // Mode A: draw a card (uses Defined$ You)
690        svars.insert(
691            "ModeA".to_string(),
692            "DB$ Draw | NumCards$ 1 | Defined$ You | SpellDescription$ Draw a card.".to_string(),
693        );
694        // Mode B: draw a card for opponent (Defined$ Opponent)
695        svars.insert(
696            "ModeB".to_string(),
697            "DB$ Draw | NumCards$ 1 | Defined$ Opponent | SpellDescription$ Opponent draws."
698                .to_string(),
699        );
700
701        let charm_card = Card::new(
702            CardId(0),
703            "Test Charm".into(),
704            p0,
705            CardTypeLine::parse("Instant"),
706            ManaCost::parse("U B"),
707            ColorSet::from_names("u"),
708            None,
709            None,
710            vec!["A:SP$ Charm | Choices$ ModeA,ModeB".to_string()],
711            vec![],
712        );
713        // We can't set svars in Card::new directly, so we use create_card + mutate
714        let cid = game.create_card(charm_card);
715        game.card_mut(cid).set_svars_map(svars);
716
717        // Put a card in each player's library so draw succeeds
718        let dummy_a = game.create_card(Card::new(
719            CardId(0),
720            "Dummy A".into(),
721            p0,
722            CardTypeLine::parse("Creature"),
723            ManaCost::parse(""),
724            ColorSet::COLORLESS,
725            Some(1),
726            Some(1),
727            vec![],
728            vec![],
729        ));
730        game.move_card(dummy_a, ZoneType::Library, p0);
731
732        let sa = SpellAbility::new_simple(Some(cid), p0, "A:SP$ Charm | Choices$ ModeA,ModeB");
733
734        let mut th = TriggerHandler::new();
735        let mut agents: Vec<Box<dyn crate::agent::PlayerAgent>> =
736            vec![Box::new(PassAgent), Box::new(PassAgent)];
737        let mut mp = vec![ManaPool::default(), ManaPool::default()];
738        let templates = HashMap::new();
739        let templates_variants = HashMap::new();
740        let token_fallback = HashMap::new();
741        let edition_dates: HashMap<String, String> = HashMap::new();
742        let mut rng_adapter = crate::game_rng::ThreadRngAdapter;
743        let mut ctx = EffectContext {
744            game: &mut game,
745            combat: None,
746            agents: &mut agents,
747            trigger_handler: &mut th,
748            token_templates: &templates,
749            token_art_variants: &templates_variants,
750            token_fallback: &token_fallback,
751            edition_dates: &edition_dates,
752            mana_pools: &mut mp,
753            parent_target_card: None,
754            rng: &mut rng_adapter,
755        };
756
757        // PassAgent.choose_mode picks first min modes → ModeA (draw for p0)
758        let p0_hand_before = ctx.game.cards_in_zone(ZoneType::Hand, p0).len();
759        super::CharmEffect::resolve(&mut ctx, &sa);
760        let p0_hand_after = ctx.game.cards_in_zone(ZoneType::Hand, p0).len();
761        // p0 should have drawn 1 card
762        assert_eq!(p0_hand_after, p0_hand_before + 1);
763        // p1 should not have drawn
764        assert_eq!(ctx.game.cards_in_zone(ZoneType::Hand, p1).len(), 0);
765    }
766
767    #[test]
768    fn charm_precast_does_not_reuse_stale_chosen_modes() {
769        let mut game = GameState::new(&["Alice", "Bob"], 20);
770        let p0 = PlayerId(0);
771        let p1 = PlayerId(1);
772
773        let mut svars = BTreeMap::new();
774        svars.insert(
775            "BraveTheStench".to_string(),
776            "DB$ Pump | ValidTgts$ Creature.OppCtrl | TgtPrompt$ Select target creature an opponent controls. | NumAtt$ -1 | NumDef$ -1 | IsCurse$ True | SpellDescription$ Brave the Stench".to_string(),
777        );
778        svars.insert(
779            "SearchTheBody".to_string(),
780            "DB$ Token | TokenScript$ c_a_treasure_sac | TokenOwner$ You | SpellDescription$ Search the Body".to_string(),
781        );
782
783        let ghast = Card::new(
784            CardId(0),
785            "Shambling Ghast".into(),
786            p1,
787            CardTypeLine::parse("Creature Zombie"),
788            ManaCost::parse("B"),
789            ColorSet::from_names("b"),
790            Some(1),
791            Some(1),
792            vec!["A:SP$ Charm | Choices$ BraveTheStench,SearchTheBody".to_string()],
793            vec![],
794        );
795        let ghast_id = game.create_card(ghast);
796        game.card_mut(ghast_id).set_svars_map(svars);
797
798        let patient_zero = game.create_card(Card::new(
799            CardId(0),
800            "Patient Zero".into(),
801            p0,
802            CardTypeLine::parse("Creature Zombie"),
803            ManaCost::parse("1 B"),
804            ColorSet::from_names("b"),
805            Some(2),
806            Some(2),
807            vec![],
808            vec![],
809        ));
810        game.move_card(patient_zero, ZoneType::Battlefield, p0);
811
812        let mut sa = SpellAbility::new_simple(
813            Some(ghast_id),
814            p1,
815            "A:SP$ Charm | Choices$ BraveTheStench,SearchTheBody",
816        );
817        // Simulate a prior life of the same card instance choosing "Search the Body".
818        game.card_mut(ghast_id).set_chosen_modes(vec![1]);
819        let mut agents: Vec<Box<dyn crate::agent::PlayerAgent>> =
820            vec![Box::new(PassAgent), Box::new(PassAgent)];
821
822        assert!(super::make_choices_precast(&mut game, &mut agents, &mut sa));
823        assert!(game.card(ghast_id).chosen_modes.is_none());
824        assert_eq!(
825            sa.sub_ability.as_ref().and_then(|sub| sub.api),
826            Some(crate::ability::api_type::ApiType::Pump)
827        );
828    }
829}