use super::{resolve_numeric_svar, EffectContext};
use crate::agent::PlayerAgent;
use crate::game::GameState;
use crate::ids::PlayerId;
use crate::parsing::keys;
use crate::parsing::Params;
use crate::spellability::{build_spell_ability, SpellAbility};
#[derive(Debug, Clone, Copy, PartialEq, Eq, strum_macros::EnumString)]
#[strum(ascii_case_insensitive)]
pub enum ChoiceRestriction {
ThisGame,
ThisTurn,
YourLastCombat,
}
#[manabrew_engine_macros::spell_effect(CharmEffect)]
fn resolve(ctx: &mut EffectContext, sa: &crate::spellability::SpellAbility) {
if sa.sub_ability.is_some() {
return;
}
let source_id = match sa.source {
Some(id) => id,
None => return,
};
let choices_str = sa.ir.choices.clone().unwrap_or_default();
if choices_str.is_empty() {
return;
}
let charm_num = resolve_numeric_svar(ctx.game, sa, keys::CHARM_NUM, 1).max(0) as usize;
let min_charm_num =
resolve_numeric_svar(ctx.game, sa, keys::MIN_CHARM_NUM, charm_num as i32).max(0) as usize;
let player = sa.activating_player;
let mode_svars: Vec<&str> = choices_str.split(',').map(|s| s.trim()).collect();
let svars = ctx.game.card(source_id).svars.clone();
let mode_texts: Vec<String> = mode_svars
.iter()
.filter_map(|svar| svars.get(*svar).cloned())
.collect();
if mode_texts.is_empty() {
return;
}
let mode_descriptions: Vec<String> = mode_texts
.iter()
.map(|text| {
let params = Params::from_raw(text);
params
.get_cloned(keys::SPELL_DESCRIPTION)
.unwrap_or_else(|| text.clone())
})
.collect();
let restriction = sa
.ir
.choice_restriction_text
.as_deref()
.and_then(|s| s.parse::<ChoiceRestriction>().ok());
let current_turn = ctx.game.turn.turn_number as i32;
let last_combat_turn = ctx
.game
.last_combat_turn_of(sa.activating_player)
.unwrap_or(i32::MIN);
let is_restricted_index = |mode_svar: &str| -> bool {
let Some(scope) = restriction else {
return false;
};
let history = &ctx.game.card(source_id).chosen_charm_modes;
let Some(&turn) = history.get(mode_svar) else {
return false;
};
match scope {
ChoiceRestriction::ThisGame => true,
ChoiceRestriction::ThisTurn => turn == current_turn,
ChoiceRestriction::YourLastCombat => turn >= last_combat_turn,
}
};
let valid_mode_indices: Vec<usize> = mode_texts
.iter()
.enumerate()
.filter(|(i, text)| {
!is_restricted_index(mode_svars[*i])
&& mode_has_valid_targets(ctx, text, player, source_id)
})
.map(|(i, _)| i)
.collect();
if valid_mode_indices.is_empty() {
return; }
let valid_descriptions: Vec<String> = valid_mode_indices
.iter()
.map(|&i| mode_descriptions[i].clone())
.collect();
let entwine_paid = sa.ir.entwine || sa.kicked;
let has_entwine = ctx.game.card(source_id).get_entwine_cost().is_some();
let has_escalate = ctx.game.card(source_id).get_escalate_cost().is_some();
let charm_num = if has_escalate {
mode_texts.len()
} else {
charm_num
};
let use_preselected_modes = should_use_preselected_modes(ctx.game, source_id, &mode_texts);
let pre_selected = if use_preselected_modes {
ctx.game.card_mut(source_id).chosen_modes.take()
} else {
ctx.game.card_mut(source_id).chosen_modes = None;
None
};
let mut chosen_indices: Vec<usize> = if let Some(pre) = pre_selected {
pre
} else if entwine_paid || (has_entwine && sa.kicked) {
valid_mode_indices.clone()
} else {
let agent_choices = ctx.agents[player.index()].choose_mode(
player,
&valid_descriptions,
min_charm_num,
charm_num.min(valid_mode_indices.len()),
Some(source_id),
);
agent_choices
.into_iter()
.filter_map(|i| valid_mode_indices.get(i).copied())
.collect()
};
chosen_indices.sort();
for &idx in &chosen_indices {
if let Some(svar_name) = mode_svars.get(idx).copied() {
ctx.game
.card_mut(source_id)
.chosen_charm_modes
.insert(svar_name.to_string(), current_turn);
}
}
for idx in chosen_indices {
if idx >= mode_texts.len() {
continue;
}
let mode_text = &mode_texts[idx];
let mut mode_sa = build_spell_ability(ctx.game, source_id, mode_text, player);
mode_sa.source = Some(source_id);
mode_sa.trigger_remembered_amount = sa.trigger_remembered_amount;
let mut cur_opt: Option<SpellAbility> = Some(mode_sa);
while let Some(mut cur_sa) = cur_opt {
setup_mode_targets(ctx, &mut cur_sa, player);
super::resolve_effect(ctx, &cur_sa);
cur_opt = cur_sa.sub_ability.map(|b| *b);
if ctx.game.game_over {
break;
}
}
if ctx.game.game_over {
break;
}
}
}
pub fn make_choices_precast(
game: &mut GameState,
agents: &mut [Box<dyn PlayerAgent>],
sa: &mut SpellAbility,
) -> bool {
make_choices_precast_with_count(game, agents, sa).is_some()
}
pub fn make_choices_precast_with_count(
game: &mut GameState,
agents: &mut [Box<dyn PlayerAgent>],
sa: &mut SpellAbility,
) -> Option<usize> {
let source_id = match sa.source {
Some(id) => id,
None => return Some(0),
};
let choices_str = sa.ir.choices.clone().unwrap_or_default();
if choices_str.is_empty() {
return Some(0);
}
let player = sa.activating_player;
let mode_svars: Vec<&str> = choices_str.split(',').map(|s| s.trim()).collect();
let svars = game.card(source_id).svars.clone();
let mode_texts: Vec<String> = mode_svars
.iter()
.filter_map(|svar| svars.get(*svar).cloned())
.collect();
if mode_texts.is_empty() {
return None;
}
let mode_descriptions: Vec<String> = mode_texts
.iter()
.map(|text| {
let params = Params::from_raw(text);
params
.get_cloned(keys::SPELL_DESCRIPTION)
.unwrap_or_else(|| text.clone())
})
.collect();
let restriction = sa
.ir
.choice_restriction_text
.as_deref()
.and_then(|s| s.parse::<ChoiceRestriction>().ok());
let current_turn = game.turn.turn_number as i32;
let last_combat_turn = game.last_combat_turn_of(player).unwrap_or(i32::MIN);
let is_restricted_index = |mode_svar: &str| -> bool {
let Some(scope) = restriction else {
return false;
};
let history = &game.card(source_id).chosen_charm_modes;
let Some(&turn) = history.get(mode_svar) else {
return false;
};
match scope {
ChoiceRestriction::ThisGame => true,
ChoiceRestriction::ThisTurn => turn == current_turn,
ChoiceRestriction::YourLastCombat => turn >= last_combat_turn,
}
};
let valid_mode_indices: Vec<usize> = mode_texts
.iter()
.enumerate()
.filter(|(i, text)| {
!is_restricted_index(mode_svars[*i])
&& mode_has_valid_targets_in_game(game, text, player, source_id)
})
.map(|(i, _)| i)
.collect();
if valid_mode_indices.is_empty() {
return None;
}
let valid_descriptions: Vec<String> = valid_mode_indices
.iter()
.map(|&i| mode_descriptions[i].clone())
.collect();
let has_entwine = game.card(source_id).get_entwine_cost().is_some();
let has_escalate = game.card(source_id).get_escalate_cost().is_some();
let can_repeat = sa.ir.can_repeat_modes;
let mut charm_num = resolve_numeric_svar(game, sa, keys::CHARM_NUM, 1).max(0) as usize;
let min_charm_num =
resolve_numeric_svar(game, sa, keys::MIN_CHARM_NUM, charm_num as i32).max(0) as usize;
if has_escalate {
charm_num = mode_texts.len();
}
if !can_repeat && min_charm_num > valid_mode_indices.len() {
return None;
}
let use_preselected_modes = should_use_preselected_modes(game, source_id, &mode_texts);
let pre_selected = if use_preselected_modes {
game.card_mut(source_id).chosen_modes.take()
} else {
game.card_mut(source_id).chosen_modes = None;
None
};
let mut chosen_indices: Vec<usize> = if let Some(pre) = pre_selected {
pre
} else if sa.ir.entwine || (has_entwine && sa.kicked) {
valid_mode_indices.clone()
} else {
let chosen = agents[player.index()].choose_mode(
player,
&valid_descriptions,
min_charm_num,
charm_num.min(valid_mode_indices.len()),
Some(source_id),
);
chosen
.into_iter()
.filter_map(|i| valid_mode_indices.get(i).copied())
.collect()
};
if chosen_indices.len() < min_charm_num {
return None;
}
chosen_indices.sort();
let selected_mode_count = chosen_indices.len();
for &idx in &chosen_indices {
if let Some(svar_name) = mode_svars.get(idx).copied() {
game.card_mut(source_id)
.chosen_charm_modes
.insert(svar_name.to_string(), current_turn);
}
}
sa.sub_ability = None;
let parent_trigger_remembered = sa.trigger_remembered_amount;
for idx in chosen_indices {
if idx >= mode_texts.len() {
continue;
}
let mut mode_sa = build_spell_ability(game, source_id, &mode_texts[idx], player);
mode_sa.source = Some(source_id);
mode_sa.trigger_remembered_amount = parent_trigger_remembered;
append_subability(sa, mode_sa);
}
Some(selected_mode_count)
}
pub(crate) fn can_make_choices_precast(
game: &GameState,
player: PlayerId,
source_id: crate::ids::CardId,
charm_sa_text: &str,
) -> bool {
let sa_params = Params::from_raw(charm_sa_text);
let Some(choices_str) = sa_params.get(keys::CHOICES) else {
return true;
};
let mode_svars: Vec<&str> = choices_str.split(',').map(|s| s.trim()).collect();
if mode_svars.is_empty() {
return false;
}
let svars = game.card(source_id).svars.clone();
let mode_texts: Vec<String> = mode_svars
.iter()
.filter_map(|svar| svars.get(*svar).cloned())
.collect();
if mode_texts.is_empty() {
return false;
}
let mut charm_num: usize = sa_params
.get(keys::CHARM_NUM)
.and_then(|s| {
s.parse().ok().or_else(|| {
game.card(source_id)
.svars
.get(s.trim())
.and_then(|v| v.parse().ok())
})
})
.unwrap_or(1);
let min_charm_num: usize = sa_params
.get(keys::MIN_CHARM_NUM)
.and_then(|s| {
s.parse().ok().or_else(|| {
game.card(source_id)
.svars
.get(s.trim())
.and_then(|v| v.parse().ok())
})
})
.unwrap_or(charm_num);
let can_repeat = sa_params.has(keys::CAN_REPEAT_MODES);
let valid_count = mode_texts
.iter()
.filter(|text| mode_has_valid_targets_in_game(game, text, player, source_id))
.count();
if valid_count == 0 {
return false;
}
if !can_repeat && min_charm_num > valid_count {
return false;
}
if !can_repeat {
charm_num = charm_num.min(valid_count);
}
charm_num >= min_charm_num
}
pub fn make_possible_options(
game: &GameState,
source_id: crate::ids::CardId,
player: PlayerId,
choices_str: &str,
) -> Vec<usize> {
let mode_svars: Vec<&str> = choices_str.split(',').map(|s| s.trim()).collect();
let svars = game.card(source_id).svars.clone();
let mode_texts: Vec<String> = mode_svars
.iter()
.filter_map(|svar| svars.get(*svar).cloned())
.collect();
mode_texts
.iter()
.enumerate()
.filter(|(_, text)| mode_has_valid_targets_in_game(game, text, player, source_id))
.map(|(i, _)| i)
.collect()
}
pub fn make_formated_description(
game: &GameState,
source_id: crate::ids::CardId,
choices_str: &str,
) -> String {
let mode_svars: Vec<&str> = choices_str.split(',').map(|s| s.trim()).collect();
let svars = game.card(source_id).svars.clone();
let mode_texts: Vec<String> = mode_svars
.iter()
.filter_map(|svar| svars.get(*svar).cloned())
.collect();
let mut description = String::new();
description.push_str("Choose one —\n");
for (i, text) in mode_texts.iter().enumerate() {
let params = Params::from_raw(text);
let mode_desc = params
.get_cloned(keys::SPELL_DESCRIPTION)
.unwrap_or_else(|| text.clone());
description.push_str(&format!("• {mode_desc}\n"));
let _ = i; }
description
}
pub fn make_choices(
game: &mut GameState,
agents: &mut [Box<dyn PlayerAgent>],
sa: &mut SpellAbility,
) -> bool {
make_choices_precast(game, agents, sa)
}
pub fn chain_abilities(
game: &GameState,
sa: &mut SpellAbility,
mode_texts: &[String],
player: PlayerId,
source_id: crate::ids::CardId,
) {
for mode_text in mode_texts {
let mut mode_sa = build_spell_ability(game, source_id, mode_text, player);
mode_sa.source = Some(source_id);
mode_sa.trigger_remembered_amount = sa.trigger_remembered_amount;
append_subability(sa, mode_sa);
}
}
fn mode_has_valid_targets(
ctx: &EffectContext,
mode_text: &str,
player: PlayerId,
source_id: crate::ids::CardId,
) -> bool {
mode_has_valid_targets_in_game(ctx.game, mode_text, player, source_id)
}
fn append_subability(root: &mut SpellAbility, mode_sa: SpellAbility) {
let mut slot = &mut root.sub_ability;
loop {
match slot {
Some(node) => slot = &mut node.sub_ability,
None => {
*slot = Some(Box::new(mode_sa));
return;
}
}
}
}
fn should_use_preselected_modes(
game: &GameState,
source_id: crate::ids::CardId,
mode_texts: &[String],
) -> bool {
let source = game.card(source_id);
if source.has_keyword("Spree") || source.has_keyword("Tiered") {
return true;
}
mode_texts.iter().any(|text| {
let params = Params::from_raw(text);
params.has(keys::MODE_COST)
})
}
pub(crate) fn mode_has_valid_targets_in_game(
game: &GameState,
mode_text: &str,
player: PlayerId,
source_id: crate::ids::CardId,
) -> bool {
let sa = build_spell_ability(game, source_id, mode_text, player);
let tr = match &sa.target_restrictions {
Some(tr) => tr,
None => return true, };
if tr.get_min_targets(game, &sa) <= 0 {
return true;
}
tr.has_candidates(game, player, sa.source)
}
fn setup_mode_targets(ctx: &mut EffectContext, mode_sa: &mut SpellAbility, player: PlayerId) {
if !mode_sa.uses_targeting() {
return;
}
mode_sa.targeting_player = Some(player);
ctx.agents[player.index()].choose_targets_for(mode_sa, ctx.game, ctx.mana_pools);
}
#[cfg(test)]
mod tests {
use crate::ability::spell_ability_effect::SpellAbilityEffect;
use forge_foundation::{CardTypeLine, ColorSet, ManaCost, ZoneType};
use std::collections::{BTreeMap, HashMap};
use crate::ability::effects::EffectContext;
use crate::agent::PassAgent;
use crate::card::Card;
use crate::game::GameState;
use crate::ids::{CardId, PlayerId};
use crate::mana::ManaPool;
use crate::spellability::SpellAbility;
use crate::trigger::handler::TriggerHandler;
#[test]
fn charm_choose_mode_zero_choices_noops() {
let mut game = GameState::new(&["Alice", "Bob"], 20);
let p0 = PlayerId(0);
let sa = SpellAbility::new_simple(None, p0, "A:SP$ Charm");
let mut th = TriggerHandler::new();
let mut agents: Vec<Box<dyn crate::agent::PlayerAgent>> =
vec![Box::new(PassAgent), Box::new(PassAgent)];
let mut mp = vec![ManaPool::default(), ManaPool::default()];
let templates = HashMap::new();
let templates_variants = HashMap::new();
let token_fallback = HashMap::new();
let edition_dates: HashMap<String, String> = HashMap::new();
let mut rng_adapter = crate::game_rng::ThreadRngAdapter;
let mut ctx = EffectContext {
game: &mut game,
combat: None,
agents: &mut agents,
trigger_handler: &mut th,
token_templates: &templates,
token_art_variants: &templates_variants,
token_fallback: &token_fallback,
edition_dates: &edition_dates,
mana_pools: &mut mp,
parent_target_card: None,
rng: &mut rng_adapter,
};
super::CharmEffect::resolve(&mut ctx, &sa);
}
#[test]
fn charm_resolves_chosen_non_targeted_mode() {
let mut game = GameState::new(&["Alice", "Bob"], 20);
let p0 = PlayerId(0);
let p1 = PlayerId(1);
let mut svars = BTreeMap::new();
svars.insert(
"ModeA".to_string(),
"DB$ Draw | NumCards$ 1 | Defined$ You | SpellDescription$ Draw a card.".to_string(),
);
svars.insert(
"ModeB".to_string(),
"DB$ Draw | NumCards$ 1 | Defined$ Opponent | SpellDescription$ Opponent draws."
.to_string(),
);
let charm_card = Card::new(
CardId(0),
"Test Charm".into(),
p0,
CardTypeLine::parse("Instant"),
ManaCost::parse("U B"),
ColorSet::from_names("u"),
None,
None,
vec!["A:SP$ Charm | Choices$ ModeA,ModeB".to_string()],
vec![],
);
let cid = game.create_card(charm_card);
game.card_mut(cid).set_svars_map(svars);
let dummy_a = game.create_card(Card::new(
CardId(0),
"Dummy A".into(),
p0,
CardTypeLine::parse("Creature"),
ManaCost::parse(""),
ColorSet::COLORLESS,
Some(1),
Some(1),
vec![],
vec![],
));
game.move_card(dummy_a, ZoneType::Library, p0);
let sa = SpellAbility::new_simple(Some(cid), p0, "A:SP$ Charm | Choices$ ModeA,ModeB");
let mut th = TriggerHandler::new();
let mut agents: Vec<Box<dyn crate::agent::PlayerAgent>> =
vec![Box::new(PassAgent), Box::new(PassAgent)];
let mut mp = vec![ManaPool::default(), ManaPool::default()];
let templates = HashMap::new();
let templates_variants = HashMap::new();
let token_fallback = HashMap::new();
let edition_dates: HashMap<String, String> = HashMap::new();
let mut rng_adapter = crate::game_rng::ThreadRngAdapter;
let mut ctx = EffectContext {
game: &mut game,
combat: None,
agents: &mut agents,
trigger_handler: &mut th,
token_templates: &templates,
token_art_variants: &templates_variants,
token_fallback: &token_fallback,
edition_dates: &edition_dates,
mana_pools: &mut mp,
parent_target_card: None,
rng: &mut rng_adapter,
};
let p0_hand_before = ctx.game.cards_in_zone(ZoneType::Hand, p0).len();
super::CharmEffect::resolve(&mut ctx, &sa);
let p0_hand_after = ctx.game.cards_in_zone(ZoneType::Hand, p0).len();
assert_eq!(p0_hand_after, p0_hand_before + 1);
assert_eq!(ctx.game.cards_in_zone(ZoneType::Hand, p1).len(), 0);
}
#[test]
fn charm_precast_does_not_reuse_stale_chosen_modes() {
let mut game = GameState::new(&["Alice", "Bob"], 20);
let p0 = PlayerId(0);
let p1 = PlayerId(1);
let mut svars = BTreeMap::new();
svars.insert(
"BraveTheStench".to_string(),
"DB$ Pump | ValidTgts$ Creature.OppCtrl | TgtPrompt$ Select target creature an opponent controls. | NumAtt$ -1 | NumDef$ -1 | IsCurse$ True | SpellDescription$ Brave the Stench".to_string(),
);
svars.insert(
"SearchTheBody".to_string(),
"DB$ Token | TokenScript$ c_a_treasure_sac | TokenOwner$ You | SpellDescription$ Search the Body".to_string(),
);
let ghast = Card::new(
CardId(0),
"Shambling Ghast".into(),
p1,
CardTypeLine::parse("Creature Zombie"),
ManaCost::parse("B"),
ColorSet::from_names("b"),
Some(1),
Some(1),
vec!["A:SP$ Charm | Choices$ BraveTheStench,SearchTheBody".to_string()],
vec![],
);
let ghast_id = game.create_card(ghast);
game.card_mut(ghast_id).set_svars_map(svars);
let patient_zero = game.create_card(Card::new(
CardId(0),
"Patient Zero".into(),
p0,
CardTypeLine::parse("Creature Zombie"),
ManaCost::parse("1 B"),
ColorSet::from_names("b"),
Some(2),
Some(2),
vec![],
vec![],
));
game.move_card(patient_zero, ZoneType::Battlefield, p0);
let mut sa = SpellAbility::new_simple(
Some(ghast_id),
p1,
"A:SP$ Charm | Choices$ BraveTheStench,SearchTheBody",
);
game.card_mut(ghast_id).set_chosen_modes(vec![1]);
let mut agents: Vec<Box<dyn crate::agent::PlayerAgent>> =
vec![Box::new(PassAgent), Box::new(PassAgent)];
assert!(super::make_choices_precast(&mut game, &mut agents, &mut sa));
assert!(game.card(ghast_id).chosen_modes.is_none());
assert_eq!(
sa.sub_ability.as_ref().and_then(|sub| sub.api),
Some(crate::ability::api_type::ApiType::Pump)
);
}
}