Skip to main content

manabrew_engine/ability/effects/
change_text_effect.rs

1//! ChangeText effect — modify text on a card (color words, land types, etc.)
2//!
3//! Ported from Java's `ChangeTextEffect.java`.
4
5use super::EffectContext;
6use crate::game::TypeRegistry;
7use crate::ids::{CardId, PlayerId};
8use crate::spellability::{AbilityDuration, SpellAbility};
9
10const COLORS: [&str; 5] = ["White", "Blue", "Black", "Red", "Green"];
11const BASIC_LAND_TYPES: [&str; 5] = ["Plains", "Island", "Swamp", "Mountain", "Forest"];
12const NO_PRIOR_TEXT_CHANGE: &str = "__forge_no_prior_text_change__";
13
14/// End-of-turn revert for temporary text changes.
15///
16/// Java stores text changes with a timestamp and removes the timestamped entries
17/// at end of turn. Rust stores the active substitutions as card SVars with
18/// `TextColor:` / `TextType:` prefixes, plus `TextColorEOT:` / `TextTypeEOT:`
19/// markers containing the previous value to restore.
20pub fn run(game: &mut crate::game::GameState, card_id: CardId) {
21    restore_text_changes(game, card_id, "TextColor:", "TextColorEOT:");
22    restore_text_changes(game, card_id, "TextType:", "TextTypeEOT:");
23}
24
25#[manabrew_engine_macros::spell_effect(ChangeTextEffect)]
26fn resolve(ctx: &mut EffectContext, sa: &SpellAbility) {
27    let color_change = sa
28        .ir
29        .change_color_word_text
30        .as_deref()
31        .and_then(|raw| resolve_color_word_change(ctx, sa, raw));
32    let type_change = sa
33        .ir
34        .change_type_word_text
35        .as_deref()
36        .and_then(|raw| resolve_type_word_change(ctx, sa, raw));
37
38    if color_change.is_none() && type_change.is_none() {
39        return;
40    }
41
42    let permanent = matches!(sa.ir.duration, Some(AbilityDuration::Permanent));
43    let targets = resolve_target_cards(ctx, sa);
44    for target in targets {
45        if let Some((from, to)) = color_change.as_ref() {
46            if !permanent {
47                mark_eot_text_change(ctx.game, target, "TextColor:", "TextColorEOT:", from);
48            }
49            ctx.game
50                .card_mut(target)
51                .add_changed_text_color_word(from, to);
52        }
53        if let Some((from, to)) = type_change.as_ref() {
54            if !permanent {
55                mark_eot_text_change(ctx.game, target, "TextType:", "TextTypeEOT:", from);
56            }
57            ctx.game
58                .card_mut(target)
59                .add_changed_text_type_word(from, to);
60        }
61    }
62}
63
64fn resolve_color_word_change(
65    ctx: &mut EffectContext,
66    sa: &SpellAbility,
67    raw: &str,
68) -> Option<(String, String)> {
69    let (from_raw, to_raw) = split_word_change(raw)?;
70    let controller = sa.activating_player;
71
72    let from = if from_raw == "Choose" {
73        choose_color(ctx, controller, COLORS.iter().copied())
74    } else {
75        Some(from_raw.to_string())
76    }?;
77
78    let to = if to_raw == "Choose" {
79        let choices = COLORS
80            .iter()
81            .copied()
82            .filter(|color| !color.eq_ignore_ascii_case(&from));
83        choose_color(ctx, controller, choices)
84    } else {
85        Some(to_raw.to_string())
86    }?;
87
88    Some((from, to))
89}
90
91fn resolve_type_word_change(
92    ctx: &mut EffectContext,
93    sa: &SpellAbility,
94    raw: &str,
95) -> Option<(String, String)> {
96    let (from_raw, to_raw) = split_word_change(raw)?;
97    let controller = sa.activating_player;
98
99    let from = match from_raw {
100        "ChooseBasicLandType" => choose_type(ctx, controller, "basic land", basic_land_types()),
101        "ChooseCreatureType" => choose_type(ctx, controller, "Creature", creature_types()),
102        _ => Some(from_raw.to_string()),
103    }?;
104
105    let to = if to_raw.starts_with("Choose") {
106        let mut valid_types = match to_raw {
107            "ChooseBasicLandType" => basic_land_types(),
108            "ChooseCreatureType" => creature_types(),
109            _ => Vec::new(),
110        };
111        let mut forbidden = forbidden_new_types(sa);
112        forbidden.push(from.clone());
113        valid_types.retain(|ty| {
114            !forbidden
115                .iter()
116                .any(|forbid| forbid.eq_ignore_ascii_case(ty))
117        });
118        choose_type(
119            ctx,
120            controller,
121            if to_raw == "ChooseBasicLandType" {
122                "basic land"
123            } else {
124                "Creature"
125            },
126            valid_types,
127        )
128    } else {
129        Some(to_raw.to_string())
130    }?;
131
132    Some((from, to))
133}
134
135fn split_word_change(raw: &str) -> Option<(&str, &str)> {
136    let mut parts = raw.split_whitespace();
137    let from = parts.next()?;
138    let to = parts.next()?;
139    Some((from, to))
140}
141
142fn choose_color<'a>(
143    ctx: &mut EffectContext,
144    player: PlayerId,
145    colors: impl IntoIterator<Item = &'a str>,
146) -> Option<String> {
147    let choices = colors.into_iter().map(str::to_string).collect::<Vec<_>>();
148    if choices.is_empty() {
149        return None;
150    }
151    ctx.agents[player.index()].choose_color(player, &choices)
152}
153
154fn choose_type(
155    ctx: &mut EffectContext,
156    player: PlayerId,
157    type_category: &str,
158    valid_types: Vec<String>,
159) -> Option<String> {
160    if valid_types.is_empty() {
161        return None;
162    }
163    ctx.agents[player.index()].choose_type(player, type_category, &valid_types)
164}
165
166fn basic_land_types() -> Vec<String> {
167    BASIC_LAND_TYPES.iter().map(|ty| ty.to_string()).collect()
168}
169
170fn creature_types() -> Vec<String> {
171    TypeRegistry::creature_types().to_vec()
172}
173
174fn forbidden_new_types(sa: &SpellAbility) -> Vec<String> {
175    sa.ir
176        .forbidden_new_types_text
177        .as_deref()
178        .unwrap_or("")
179        .split(',')
180        .map(str::trim)
181        .filter(|ty| !ty.is_empty())
182        .map(str::to_string)
183        .collect()
184}
185
186fn resolve_target_cards(ctx: &EffectContext, sa: &SpellAbility) -> Vec<CardId> {
187    let mut targets =
188        crate::ability::spell_ability_effect::get_defined_cards_or_targeted(ctx.game, sa);
189
190    if let Some(stack_id) = sa.target_chosen.target_stack_entry {
191        if let Some(source) = ctx
192            .game
193            .stack
194            .find_by_id(stack_id)
195            .and_then(|entry| entry.spell_ability.source)
196        {
197            targets.push(source);
198        }
199    }
200
201    targets.sort_unstable_by_key(|id| id.0);
202    targets.dedup();
203    targets
204}
205
206fn mark_eot_text_change(
207    game: &mut crate::game::GameState,
208    card_id: CardId,
209    active_prefix: &str,
210    marker_prefix: &str,
211    from: &str,
212) {
213    let active_key = format!("{active_prefix}{from}");
214    let marker_key = format!("{marker_prefix}{from}");
215    if game.card(card_id).svars.contains_key(&marker_key) {
216        return;
217    }
218
219    let previous = game
220        .card(card_id)
221        .svars
222        .get(&active_key)
223        .cloned()
224        .unwrap_or_else(|| NO_PRIOR_TEXT_CHANGE.to_string());
225    game.card_mut(card_id).set_s_var(marker_key, previous);
226}
227
228fn restore_text_changes(
229    game: &mut crate::game::GameState,
230    card_id: CardId,
231    active_prefix: &str,
232    marker_prefix: &str,
233) {
234    let markers = game
235        .card(card_id)
236        .svars
237        .iter()
238        .filter_map(|(key, value)| {
239            key.strip_prefix(marker_prefix)
240                .map(|from| (from.to_string(), value.clone()))
241        })
242        .collect::<Vec<_>>();
243
244    for (from, previous) in markers {
245        let active_key = format!("{active_prefix}{from}");
246        let marker_key = format!("{marker_prefix}{from}");
247        game.card_mut(card_id).remove_s_var(&marker_key);
248        if previous == NO_PRIOR_TEXT_CHANGE {
249            game.card_mut(card_id).remove_s_var(&active_key);
250        } else {
251            game.card_mut(card_id).set_s_var(active_key, previous);
252        }
253    }
254}