Skip to main content

manabrew_engine/ability/effects/
token_effect_base.rs

1//! TokenEffectBase — shared token creation machinery.
2//!
3//! Mirrors Java's `TokenEffectBase.java`.  Rust effects are generated as
4//! resolver functions rather than subclasses, so this module exposes a trait
5//! with Java-parity default methods plus a concrete stateless implementation
6//! used by effects that need the common token path.
7use std::collections::HashMap;
8
9use forge_foundation::{CardTypeLine, ColorSet, ManaCost, ZoneType};
10
11use super::{emit_zone_trigger, EffectContext};
12use crate::agent::types::GameEntity;
13use crate::card::card_zone_table::CardZoneTable;
14use crate::card::Card;
15use crate::event::RunParams;
16use crate::ids::{CardId, PlayerId};
17use crate::parsing::{keys, split_param_list_value};
18use crate::replacement::replacement_handler::{apply_replacements_with_agents, ReplacementEvent};
19use crate::replacement::replacement_result::ReplacementResult;
20use crate::spellability::SpellAbility;
21use crate::trigger::TriggerType;
22
23#[derive(Clone)]
24pub struct TokenTableCell {
25    pub owner: PlayerId,
26    pub prototype: Card,
27    pub amount: usize,
28}
29
30#[derive(Clone, Default)]
31pub struct TokenCreateTable {
32    cells: Vec<TokenTableCell>,
33}
34
35impl TokenCreateTable {
36    pub fn put(&mut self, owner: PlayerId, prototype: Card, amount: usize) {
37        self.cells.push(TokenTableCell {
38            owner,
39            prototype,
40            amount,
41        });
42    }
43
44    pub fn cells(&self) -> &[TokenTableCell] {
45        &self.cells
46    }
47
48    pub fn retain_players(&mut self, keep: impl Fn(PlayerId) -> bool) {
49        self.cells.retain(|cell| keep(cell.owner));
50    }
51
52    pub fn is_empty(&self) -> bool {
53        self.cells.is_empty()
54    }
55}
56
57#[derive(Debug, Clone, Default)]
58pub struct TokenCreateResult {
59    pub created: Vec<CardId>,
60    pub combat_changed: bool,
61}
62
63#[derive(Clone, Copy)]
64pub enum TokenAttachmentTarget {
65    Card(CardId),
66    Player(PlayerId),
67}
68
69pub trait TokenEffectBase {
70    fn token_scripts(&self, sa: &SpellAbility) -> Vec<String> {
71        split_param_list_value(sa.ir.token_script.as_deref(), ",")
72    }
73
74    fn get_token_template<'a>(
75        &self,
76        templates: &'a HashMap<String, Card>,
77        script: &str,
78    ) -> Option<&'a Card> {
79        templates.get(script).or_else(|| {
80            let lower = script.to_ascii_lowercase();
81            templates
82                .iter()
83                .find(|(key, _)| key.to_ascii_lowercase() == lower)
84                .map(|(_, value)| value)
85        })
86    }
87
88    fn require_token_template(&self, templates: &HashMap<String, Card>, script: &str) -> Card {
89        self.get_token_template(templates, script)
90            .cloned()
91            .unwrap_or_else(|| panic!("don't find Token for TokenScript: {script}"))
92    }
93
94    fn create_token_table(
95        &self,
96        ctx: &EffectContext,
97        players: &[PlayerId],
98        token_scripts: &[String],
99        final_amount: usize,
100        sa: &SpellAbility,
101    ) -> TokenCreateTable {
102        let mut token_table = TokenCreateTable::default();
103        for &owner in players {
104            if !ctx.game.player(owner).is_alive() {
105                continue;
106            }
107            for script in token_scripts {
108                let mut result = self.require_token_template(ctx.token_templates, script);
109                result.set_owner(owner);
110                result.set_controller(owner);
111                result.set_is_token(true);
112                result.set_s_var("TokenScript", script);
113                result.set_s_var("TokenSpawningAbility", sa.ability_text.clone());
114                token_table.put(owner, result, final_amount);
115            }
116        }
117        token_table
118    }
119
120    fn make_token_table_internal_from_script(
121        &self,
122        ctx: &EffectContext,
123        owner: PlayerId,
124        script: &str,
125        final_amount: usize,
126        sa: &SpellAbility,
127    ) -> TokenCreateTable {
128        let mut result = self.require_token_template(ctx.token_templates, script);
129        result.set_owner(owner);
130        result.set_controller(owner);
131        result.set_is_token(true);
132        result.set_s_var("TokenScript", script);
133        result.set_s_var("TokenSpawningAbility", sa.ability_text.clone());
134        self.make_token_table_internal(owner, result, final_amount)
135    }
136
137    fn make_token_table_internal(
138        &self,
139        owner: PlayerId,
140        result: Card,
141        final_amount: usize,
142    ) -> TokenCreateTable {
143        let mut token_table = TokenCreateTable::default();
144        token_table.put(owner, result, final_amount);
145        token_table
146    }
147
148    fn has_inline_token_params(&self, sa: &SpellAbility) -> bool {
149        sa.ir.token_power.is_some()
150            || sa.ir.token_toughness.is_some()
151            || sa.ir.token_types_text.is_some()
152            || sa.ir.token_name_text.is_some()
153    }
154
155    fn build_inline_token(&self, sa: &SpellAbility, owner: PlayerId) -> Card {
156        let name = sa
157            .ir
158            .token_name_text
159            .clone()
160            .unwrap_or_else(|| "Token".to_string());
161        let power = sa.ir.token_power;
162        let toughness = sa.ir.token_toughness;
163        let type_line = sa
164            .ir
165            .token_types_text
166            .as_deref()
167            .map(CardTypeLine::parse)
168            .unwrap_or_else(|| CardTypeLine::parse("Creature"));
169        let colors = sa
170            .ir
171            .token_colors_text
172            .as_deref()
173            .map(|s| {
174                if s.eq_ignore_ascii_case("Colorless") {
175                    ColorSet::COLORLESS
176                } else {
177                    ColorSet::from_names(s)
178                }
179            })
180            .unwrap_or(ColorSet::COLORLESS);
181        let keywords = split_param_list_value(sa.ir.token_keywords_text.as_deref(), "&");
182
183        Card::new(
184            CardId(0),
185            name,
186            owner,
187            type_line,
188            ManaCost::parse(""),
189            colors,
190            power,
191            toughness,
192            keywords,
193            vec![],
194        )
195    }
196
197    fn make_token_table_from_scripts(
198        &self,
199        ctx: &mut EffectContext,
200        players: &[PlayerId],
201        token_scripts: &[String],
202        final_amount: usize,
203        clone_origin: bool,
204        trigger_list: &mut CardZoneTable,
205        sa: &SpellAbility,
206    ) -> TokenCreateResult {
207        let token_table = self.create_token_table(ctx, players, token_scripts, final_amount, sa);
208        self.make_token_table(ctx, token_table, clone_origin, trigger_list, sa)
209    }
210
211    fn make_token_table(
212        &self,
213        ctx: &mut EffectContext,
214        mut token_table: TokenCreateTable,
215        clone_origin: bool,
216        trigger_list: &mut CardZoneTable,
217        sa: &SpellAbility,
218    ) -> TokenCreateResult {
219        if token_table.is_empty() {
220            return TokenCreateResult::default();
221        }
222
223        self.apply_create_token_replacements(ctx, &mut token_table);
224
225        let original_tokens: Vec<Card> = token_table
226            .cells()
227            .iter()
228            .map(|cell| cell.prototype.clone())
229            .collect();
230        let pump_keywords = self.pump_keywords(sa);
231        let mut result = TokenCreateResult::default();
232
233        for cell in token_table.cells().iter().cloned() {
234            let script = cell.prototype.get_s_var("TokenScript").map(str::to_owned);
235            if let Some(script) = script.as_deref() {
236                ctx.sync_token_art_rng(script, sa);
237            }
238
239            let controller = cell.prototype.controller;
240            for _ in 0..cell.amount {
241                let Some(token_id) = self.create_single_token(
242                    ctx,
243                    sa,
244                    &cell.prototype,
245                    cell.owner,
246                    controller,
247                    clone_origin,
248                    &pump_keywords,
249                    trigger_list,
250                    &original_tokens,
251                ) else {
252                    // Java records a None->None table entry with a null moved card here.
253                    // Rust CardZoneTable is CardId-only, so there is no valid value to store.
254                    continue;
255                };
256                result.created.push(token_id);
257                if self.add_token_to_combat(ctx, sa, token_id) {
258                    result.combat_changed = true;
259                }
260            }
261        }
262
263        if let Some(action) = sa.ir.at_eot.as_deref() {
264            crate::ability::spell_ability_effect::register_at_eot(
265                ctx.trigger_handler,
266                ctx.game,
267                sa,
268                action,
269                result.created.clone(),
270            );
271        }
272
273        result
274    }
275
276    fn create_single_token(
277        &self,
278        ctx: &mut EffectContext,
279        sa: &SpellAbility,
280        prototype: &Card,
281        creator: PlayerId,
282        controller: PlayerId,
283        clone_origin: bool,
284        pump_keywords: &[String],
285        trigger_list: &mut CardZoneTable,
286        original_tokens: &[Card],
287    ) -> Option<CardId> {
288        let attachment_before_move = if sa.ir.attach_after_text.is_none() {
289            self.attachment_target(ctx, sa)
290        } else {
291            None
292        };
293        if sa.ir.attached_to.is_some()
294            && attachment_before_move.is_none()
295            && prototype.type_line.has_subtype("Aura")
296        {
297            return None;
298        }
299
300        let mut token = prototype.clone();
301        token.set_owner(creator);
302        token.set_controller(controller);
303        token.set_is_token(true);
304
305        if let Some(counter_type) = sa.ir.with_counters_type.as_ref() {
306            let amount = super::resolve_numeric_svar(ctx.game, sa, keys::WITH_COUNTERS_AMOUNT, 1);
307            token.add_counter(counter_type, amount.max(0));
308        }
309
310        if let Some(add_triggers_from) = sa.ir.add_triggers_from_text.as_deref() {
311            if let Some(source_id) = sa.source {
312                let cards = crate::ability::ability_utils::get_defined_cards(
313                    ctx.game,
314                    Some(source_id),
315                    add_triggers_from,
316                    Some(sa.activating_player),
317                );
318                for card_id in cards {
319                    for trigger in ctx.game.card(card_id).copiable_triggers() {
320                        token.add_trigger(trigger);
321                    }
322                }
323            }
324        }
325
326        let token_id = ctx.game.create_card(token);
327        self.after_token_created(ctx, token_id);
328        if let Some(attachment) = attachment_before_move {
329            if !self.attach_token_to(ctx, token_id, attachment)
330                && ctx.game.card(token_id).type_line.has_subtype("Aura")
331            {
332                return None;
333            }
334        }
335
336        ctx.move_card(token_id, ZoneType::Battlefield, controller);
337
338        if sa.ir.token_tapped {
339            ctx.game.tap(token_id);
340        }
341
342        if clone_origin {
343            if let Some(source_id) = sa.source {
344                ctx.game.card_mut(token_id).clone_origin = Some(source_id);
345            }
346        }
347        if clone_origin || prototype.copied_permanent.is_some() {
348            ctx.game.card_mut(token_id).copied_permanent =
349                prototype.copied_permanent.or_else(|| {
350                    if prototype.id != CardId(0) {
351                        Some(prototype.id)
352                    } else {
353                        None
354                    }
355                });
356        }
357
358        if !pump_keywords.is_empty() {
359            for keyword in pump_keywords {
360                ctx.game.card_mut(token_id).add_pump_keyword(keyword);
361            }
362            self.add_pump_until(ctx, sa, token_id);
363        }
364
365        if let Some(location) = sa.ir.at_eot_trig_text.as_deref() {
366            crate::ability::spell_ability_effect::add_self_trigger_at_eot(
367                ctx.trigger_handler,
368                ctx.game,
369                location,
370                token_id,
371            );
372        }
373
374        if sa.ir.remember_tokens {
375            if let Some(source_id) = sa.source {
376                ctx.game.card_mut(source_id).add_remembered_card(token_id);
377            }
378        }
379        if sa.ir.remember_original_tokens
380            && original_tokens.iter().any(|original| {
381                original.card_name == prototype.card_name
382                    && original.type_line == prototype.type_line
383            })
384        {
385            if let Some(source_id) = sa.source {
386                ctx.game.card_mut(source_id).add_remembered_card(token_id);
387            }
388        }
389        if sa.ir.imprint_tokens {
390            if let Some(source_id) = sa.source {
391                ctx.game.card_mut(source_id).add_imprinted_card(token_id);
392            }
393        }
394        if sa.ir.remember_source {
395            if let Some(source_id) = sa.source {
396                ctx.game.card_mut(token_id).add_remembered_card(source_id);
397            }
398        }
399        if let Some(defined) = sa.ir.token_remembered.as_deref() {
400            if let Some(source_id) = sa.source {
401                let remembered_cards = crate::ability::ability_utils::get_defined_cards(
402                    ctx.game,
403                    Some(source_id),
404                    defined,
405                    Some(sa.activating_player),
406                );
407                ctx.game
408                    .card_mut(token_id)
409                    .add_remembered_cards(remembered_cards);
410            }
411        }
412        if sa.ir.cleanup_for_each {
413            let remembered = prototype.remembered_cards.clone();
414            for card_id in remembered {
415                ctx.game.card_mut(token_id).remove_remembered(card_id);
416            }
417        }
418
419        if sa.ir.attach_after_text.is_some() {
420            if let Some(attachment) = self.attachment_target(ctx, sa) {
421                let _ = self.attach_token_to(ctx, token_id, attachment);
422            }
423        }
424
425        ctx.trigger_handler
426            .register_active_trigger(ctx.game, token_id);
427        ctx.trigger_handler.run_trigger(
428            TriggerType::TokenCreated,
429            RunParams {
430                card: Some(token_id),
431                player: Some(creator),
432                ..Default::default()
433            },
434            false,
435        );
436        emit_zone_trigger(
437            ctx.trigger_handler,
438            token_id,
439            ZoneType::None,
440            ZoneType::Battlefield,
441        );
442
443        let token_lki = crate::card::card_copy_service::get_lki_copy(ctx.game.card(token_id));
444        trigger_list.put(Some(ZoneType::None), Some(ZoneType::Battlefield), token_id);
445        let first_time = ctx.game.player(creator).tokens_created_this_turn == 0;
446        // TODO(parity): Java CardZoneTable stores the token LKI copy here. Rust
447        // CardZoneTable currently stores CardId only, so consumers see the live token.
448        let _ = token_lki;
449        trigger_list.add_token(token_id, creator, first_time);
450        crate::player::add_tokens_created_this_turn(ctx.game, creator, 1);
451
452        Some(token_id)
453    }
454
455    fn after_token_created(&self, ctx: &mut EffectContext, token_id: CardId) {
456        let token = ctx.game.card_mut(token_id);
457        token.update_spell_abilities();
458
459        let bound_host = ctx.game.card(token_id).clone();
460        let token = ctx.game.card_mut(token_id);
461        for trigger in &mut token.triggers {
462            trigger.bind_host_card_id(bound_host.id);
463        }
464        for static_ability in &mut token.static_abilities {
465            static_ability.base.set_host_card_id(bound_host.id);
466        }
467        for replacement_effect in &mut token.replacement_effects {
468            replacement_effect.base.set_host_card_id(bound_host.id);
469        }
470    }
471
472    fn attachment_target(
473        &self,
474        ctx: &mut EffectContext,
475        sa: &SpellAbility,
476    ) -> Option<TokenAttachmentTarget> {
477        let attached_to = sa.ir.attached_to.as_deref()?;
478        let (players, cards) =
479            crate::ability::ability_utils::get_defined_entities(attached_to, sa, ctx.game);
480        let mut entities = Vec::with_capacity(players.len() + cards.len());
481        entities.extend(players.into_iter().map(GameEntity::Player));
482        entities.extend(
483            cards
484                .into_iter()
485                .filter(|&card_id| ctx.game.card(card_id).zone == ZoneType::Battlefield)
486                .map(GameEntity::Card),
487        );
488        if entities.is_empty() {
489            return None;
490        }
491
492        ctx.agents[sa.activating_player.index()].snapshot_state(ctx.game, ctx.mana_pools);
493        let chosen = ctx.agents[sa.activating_player.index()].choose_single_entity_for_effect(
494            sa.activating_player,
495            &entities,
496            false,
497        )?;
498        match chosen {
499            GameEntity::Card(card_id) => Some(TokenAttachmentTarget::Card(card_id)),
500            GameEntity::Player(player_id) => Some(TokenAttachmentTarget::Player(player_id)),
501        }
502    }
503
504    fn attach_token_to(
505        &self,
506        ctx: &mut EffectContext,
507        token_id: CardId,
508        target: TokenAttachmentTarget,
509    ) -> bool {
510        match target {
511            TokenAttachmentTarget::Card(target_id) => {
512                if ctx.game.card(target_id).zone != ZoneType::Battlefield {
513                    return false;
514                }
515                if crate::staticability::static_ability_cant_attach::cant_attach(
516                    &ctx.game.cards,
517                    ctx.game.card(token_id),
518                    ctx.game.card(target_id),
519                    false,
520                ) {
521                    return false;
522                }
523                ctx.game.attach_to(token_id, target_id);
524                true
525            }
526            TokenAttachmentTarget::Player(player_id) => {
527                if !ctx.game.player(player_id).is_alive() {
528                    return false;
529                }
530                ctx.game.attach_to_player(token_id, player_id);
531                true
532            }
533        }
534    }
535
536    fn add_token_to_combat(
537        &self,
538        ctx: &mut EffectContext,
539        sa: &SpellAbility,
540        token_id: CardId,
541    ) -> bool {
542        if super::add_to_combat(ctx, sa, token_id, keys::TOKEN_ATTACKING) {
543            return true;
544        }
545        self.add_token_blocking(ctx, sa, token_id)
546    }
547
548    fn add_token_blocking(
549        &self,
550        ctx: &mut EffectContext,
551        sa: &SpellAbility,
552        token_id: CardId,
553    ) -> bool {
554        if !ctx.game.turn.is_combat() || !ctx.game.card(token_id).is_creature() {
555            return false;
556        }
557        let Some(blocking) = sa.ir.token_blocking_text.as_deref() else {
558            return false;
559        };
560        let attackers = crate::ability::ability_utils::get_defined_cards(
561            ctx.game,
562            sa.source,
563            blocking,
564            Some(sa.activating_player),
565        );
566        let Some(combat) = ctx.combat.as_deref_mut() else {
567            return false;
568        };
569        let Some(attacker_id) = attackers
570            .into_iter()
571            .find(|&attacker_id| combat.is_attacking(attacker_id))
572        else {
573            return false;
574        };
575        if !crate::combat::combat_util::can_creature_block(ctx.game, token_id, attacker_id) {
576            return false;
577        }
578        if combat
579            .blockers
580            .iter()
581            .any(|&(blocker, attacker)| blocker == token_id && attacker == attacker_id)
582        {
583            return false;
584        }
585        combat.declare_blocker(
586            token_id,
587            attacker_id,
588            ctx.game.card(token_id).zone_timestamp,
589        );
590        true
591    }
592
593    fn pump_keywords(&self, sa: &SpellAbility) -> Vec<String> {
594        split_param_list_value(sa.ir.pump_keywords.as_deref(), " & ")
595    }
596
597    fn add_pump_until(&self, ctx: &mut EffectContext, sa: &SpellAbility, token_id: CardId) {
598        if let Some(duration) = sa.ir.pump_duration_text.as_deref() {
599            ctx.game
600                .card_mut(token_id)
601                .set_s_var("PumpDuration", duration);
602        }
603    }
604
605    fn apply_create_token_replacements(
606        &self,
607        ctx: &mut EffectContext,
608        token_table: &mut TokenCreateTable,
609    ) {
610        let mut retained = Vec::with_capacity(token_table.cells.len());
611        for mut cell in token_table.cells.drain(..) {
612            let mut event = ReplacementEvent::CreateToken {
613                player: cell.owner,
614                count: cell.amount as i32,
615                is_effect: true,
616            };
617            match apply_replacements_with_agents(&mut *ctx.game, ctx.agents, &mut event) {
618                ReplacementResult::NotReplaced => retained.push(cell),
619                ReplacementResult::Updated => {
620                    if let ReplacementEvent::CreateToken { count, .. } = event {
621                        cell.amount = count.max(0) as usize;
622                    }
623                    retained.push(cell);
624                }
625                ReplacementResult::Replaced
626                | ReplacementResult::Prevented
627                | ReplacementResult::Skipped => {}
628            }
629        }
630        token_table.cells = retained;
631    }
632}
633
634pub struct TokenEffectBaseImpl;
635
636impl TokenEffectBase for TokenEffectBaseImpl {}
637
638pub const TOKEN_EFFECT_BASE: TokenEffectBaseImpl = TokenEffectBaseImpl;