Skip to main content

manabrew_engine/zone/
magic_stack.rs

1//! MagicStack — the game stack for spells and abilities.
2//!
3//! Mirrors Java's `MagicStack.java` from `forge.game.zone`.
4//! Spells and abilities are pushed onto the stack and resolve LIFO.
5
6use forge_foundation::ZoneType;
7use serde::{Deserialize, Serialize};
8
9use crate::game::GameState;
10use crate::ids::{CardId, PlayerId};
11use crate::spellability::SpellAbility;
12
13// ── StackEntry (mirrors Java's SpellAbilityStackInstance) ────────────
14
15/// An entry on the game stack (spell or ability waiting to resolve).
16/// Mirrors Java's `SpellAbilityStackInstance` which wraps a `SpellAbility`.
17#[derive(Debug, Clone, Serialize, Deserialize)]
18pub struct StackEntry {
19    pub id: u32,
20    /// The spell ability with its full sub-ability chain and targets.
21    pub spell_ability: SpellAbility,
22    /// True while a spell has been announced and moved to the stack, but its
23    /// modes/targets/costs/payment have not finished yet. Pending entries are
24    /// visible for casting prompts but must never resolve or receive priority.
25    #[serde(default)]
26    pub is_pending_cast: bool,
27    /// Whether this is a creature spell (goes to battlefield on resolve).
28    pub is_creature_spell: bool,
29    /// Whether this is a non-creature permanent spell.
30    pub is_permanent_spell: bool,
31    /// The zone the spell was cast from (for Flashback exile-on-resolve).
32    pub cast_from_zone: Option<ZoneType>,
33    /// If this is an optional trigger, the player who decides whether to
34    /// accept or decline.  Mirrors Java's WrappedAbility `decider` field.
35    #[serde(default)]
36    pub optional_trigger_decider: Option<PlayerId>,
37    /// Description text shown to the deciding player for optional triggers.
38    #[serde(default)]
39    pub optional_trigger_description: Option<String>,
40    /// Source card name for optional trigger prompts.
41    #[serde(default)]
42    pub optional_trigger_source_name: Option<String>,
43}
44
45impl StackEntry {
46    /// Get the next unique ID for a stack entry.
47    /// Mirrors Java's `SpellAbilityStackInstance.nextId()`.
48    pub fn next_id() -> u64 {
49        use std::sync::atomic::{AtomicU64, Ordering};
50        static COUNTER: AtomicU64 = AtomicU64::new(1);
51        COUNTER.fetch_add(1, Ordering::Relaxed)
52    }
53
54    /// Update a target card in this stack entry's spell ability.
55    /// Mirrors Java's `SpellAbilityStackInstance.updateTarget(Card, Card)`.
56    pub fn update_target(&mut self, old: CardId, new: CardId) {
57        self.spell_ability.update_target(old, new);
58    }
59
60    /// Set a triggering object on this stack entry's spell ability.
61    /// Mirrors Java's `SpellAbilityStackInstance.setTriggeringObject(String, Object)`.
62    pub fn set_triggering_object<K: crate::spellability::TriggerKeyInput>(
63        &mut self,
64        key: K,
65        value: &str,
66    ) {
67        self.spell_ability.set_triggering_object(key, value);
68    }
69
70    /// Update a triggering object in this stack entry's spell ability.
71    /// Mirrors Java's `SpellAbilityStackInstance.updateTriggeringObject(String, Object)`.
72    pub fn update_triggering_object<K: crate::spellability::TriggerKeyInput>(
73        &mut self,
74        key: K,
75        value: &str,
76    ) {
77        self.spell_ability.update_triggering_object(key, value);
78    }
79}
80
81/// The game stack. Spells and abilities are added to the top and resolve LIFO.
82/// Mirrors Java's `MagicStack` class.
83#[derive(Debug, Clone, Serialize, Deserialize)]
84pub struct MagicStack {
85    entries: Vec<StackEntry>,
86    next_id: u32,
87
88    /// Whether the stack is frozen (during declare attackers/blockers).
89    /// While frozen, new non-mana abilities are queued in `frozen_stack`.
90    #[serde(default)]
91    frozen: bool,
92
93    /// Entries queued while the stack is frozen (combat declarations).
94    #[serde(default)]
95    frozen_stack: Vec<StackEntry>,
96
97    /// Whether the stack is currently resolving an entry.
98    #[serde(default)]
99    resolving: bool,
100
101    /// The card currently being resolved (if any).
102    #[serde(default)]
103    cur_resolving_card: Option<CardId>,
104
105    #[serde(default, skip)]
106    resolving_entry: Option<StackEntry>,
107
108    /// Short-lived LKI cache of entries that were removed from the stack
109    /// during the current resolution cycle (e.g. by Counter effects). Used
110    /// by `find_by_id` so sub-abilities like An Offer You Can't Refuse's
111    /// "its controller creates two Treasure tokens" can still look up the
112    /// countered spell's controller after it's left the stack.
113    #[serde(default, skip)]
114    recently_removed: Vec<StackEntry>,
115
116    /// Cards (by ID) of spells cast this turn — for storm count, etc.
117    #[serde(default)]
118    this_turn_cast: Vec<CardId>,
119
120    /// Cards cast last turn (rotated from this_turn_cast on turn change).
121    #[serde(default)]
122    last_turn_cast: Vec<CardId>,
123
124    /// Abilities activated this turn.
125    #[serde(default)]
126    this_turn_activated: Vec<CardId>,
127
128    /// Maximum distinct sources that have been on the stack simultaneously.
129    #[serde(default)]
130    max_distinct_sources: usize,
131
132    /// Undo stack — tracks undoable spell abilities and their owner.
133    #[serde(default)]
134    undo_stack: Vec<UndoEntry>,
135
136    /// Player who owns the current undo stack.
137    #[serde(default)]
138    undo_stack_owner: Option<PlayerId>,
139
140    /// Simultaneous stack entries waiting to be added (triggers that fire
141    /// at the same time and need ordering by the active player).
142    #[serde(default)]
143    simultaneous_entries: Vec<StackEntry>,
144
145    /// Cast commands keyed by card name — callbacks to run when a spell resolves.
146    #[serde(default)]
147    cast_commands: std::collections::HashMap<String, Vec<String>>,
148}
149
150/// An undo entry tracking a spell that can be undone.
151#[derive(Debug, Clone, Serialize, Deserialize)]
152pub struct UndoEntry {
153    pub source_card: Option<CardId>,
154    pub activating_player: PlayerId,
155}
156
157impl MagicStack {
158    pub fn new() -> Self {
159        MagicStack {
160            entries: Vec::new(),
161            next_id: 0,
162            frozen: false,
163            frozen_stack: Vec::new(),
164            resolving: false,
165            cur_resolving_card: None,
166            resolving_entry: None,
167            this_turn_cast: Vec::new(),
168            last_turn_cast: Vec::new(),
169            this_turn_activated: Vec::new(),
170            max_distinct_sources: 0,
171            undo_stack: Vec::new(),
172            undo_stack_owner: None,
173            simultaneous_entries: Vec::new(),
174            cast_commands: std::collections::HashMap::new(),
175            recently_removed: Vec::new(),
176        }
177    }
178
179    pub fn push(&mut self, mut entry: StackEntry) -> u32 {
180        let id = self.next_id;
181        self.next_id += 1;
182        entry.is_pending_cast = false;
183        entry.id = id;
184        self.entries.push(entry);
185        self.update_max_distinct_sources();
186        id
187    }
188
189    pub fn begin_pending_cast(&mut self, mut entry: StackEntry) -> u32 {
190        let id = self.next_id;
191        self.next_id += 1;
192        entry.id = id;
193        entry.is_pending_cast = true;
194        self.entries.push(entry);
195        self.update_max_distinct_sources();
196        id
197    }
198
199    pub fn complete_pending_cast(&mut self, id: u32, mut entry: StackEntry) -> Option<&StackEntry> {
200        let pending = self
201            .entries
202            .iter_mut()
203            .find(|existing| existing.id == id && existing.is_pending_cast)?;
204        entry.id = id;
205        entry.is_pending_cast = false;
206        *pending = entry;
207        self.update_max_distinct_sources();
208        self.entries.iter().find(|existing| existing.id == id)
209    }
210
211    pub fn remove_pending_cast(&mut self, id: u32) -> Option<StackEntry> {
212        let index = self
213            .entries
214            .iter()
215            .position(|entry| entry.id == id && entry.is_pending_cast)?;
216        let entry = self.entries.remove(index);
217        self.update_max_distinct_sources();
218        Some(entry)
219    }
220
221    fn update_max_distinct_sources(&mut self) {
222        let distinct: std::collections::HashSet<_> = self
223            .entries
224            .iter()
225            .filter_map(|e| e.spell_ability.source)
226            .collect();
227        if distinct.len() > self.max_distinct_sources {
228            self.max_distinct_sources = distinct.len();
229        }
230    }
231
232    pub fn pop(&mut self) -> Option<StackEntry> {
233        self.entries.pop()
234    }
235
236    pub fn peek(&self) -> Option<&StackEntry> {
237        self.entries.last()
238    }
239
240    pub fn is_empty(&self) -> bool {
241        self.entries.is_empty()
242    }
243
244    pub fn len(&self) -> usize {
245        self.entries.len()
246    }
247
248    pub fn iter(&self) -> impl Iterator<Item = &StackEntry> {
249        self.entries.iter()
250    }
251
252    pub fn iter_mut(&mut self) -> impl Iterator<Item = &mut StackEntry> {
253        self.entries.iter_mut()
254    }
255
256    /// Find a stack entry by ID without removing it. Also searches the
257    /// short-lived LKI cache populated by Counter effects so that follow-up
258    /// sub-abilities can still resolve `Defined$ TargetedController` after
259    /// the target spell has been pulled off the stack.
260    pub fn find_by_id(&self, id: u32) -> Option<&StackEntry> {
261        self.entries
262            .iter()
263            .find(|e| e.id == id)
264            .or_else(|| self.recently_removed.iter().find(|e| e.id == id))
265    }
266
267    /// Remove and return the stack entry with the given ID (for Counter effects).
268    /// The removed entry is kept in a short-lived LKI cache so follow-up
269    /// sub-abilities can still observe its metadata (see `find_by_id`).
270    pub fn remove_by_id(&mut self, id: u32) -> Option<StackEntry> {
271        if let Some(pos) = self.entries.iter().position(|e| e.id == id) {
272            let entry = self.entries.remove(pos);
273            self.recently_removed.push(entry.clone());
274            Some(entry)
275        } else {
276            None
277        }
278    }
279
280    /// Clear the LKI cache of recently-removed entries. Called at the end of
281    /// each stack-resolution cycle so the cache doesn't leak state.
282    pub fn clear_recently_removed(&mut self) {
283        self.recently_removed.clear();
284    }
285
286    /// Find a stack entry by its source card ID (for Ward — finding the targeting spell).
287    pub fn find_by_source_card(&self, card_id: CardId) -> Option<&StackEntry> {
288        self.entries
289            .iter()
290            .find(|e| e.spell_ability.source == Some(card_id))
291    }
292
293    /// Number of items on the stack. Mirrors Java's `MagicStack.size()`.
294    pub fn size(&self) -> usize {
295        self.entries.len()
296    }
297
298    /// Add a stack entry. Mirrors Java's `MagicStack.add()`.
299    pub fn add(&mut self, entry: StackEntry) -> u32 {
300        self.push(entry)
301    }
302
303    /// Remove a specific entry by ID. Mirrors Java's `MagicStack.remove()`.
304    pub fn remove(&mut self, id: u32) -> Option<StackEntry> {
305        self.remove_by_id(id)
306    }
307
308    /// Clear all entries from the stack. Mirrors Java's underlying `clear()`.
309    pub fn clear(&mut self) {
310        self.entries.clear();
311    }
312
313    /// Reset the stack completely. Mirrors Java's `MagicStack.reset()`.
314    pub fn reset(&mut self) {
315        self.entries.clear();
316        self.next_id = 0;
317        self.frozen = false;
318        self.frozen_stack.clear();
319        self.resolving = false;
320        self.cur_resolving_card = None;
321        self.resolving_entry = None;
322        self.last_turn_cast.clear();
323        self.this_turn_cast.clear();
324        self.simultaneous_entries.clear();
325        self.undo_stack.clear();
326        self.undo_stack_owner = None;
327        self.cast_commands.clear();
328    }
329
330    /// Peek at the top ability. Mirrors Java's `MagicStack.peekAbility()`.
331    pub fn peek_ability(&self) -> Option<&SpellAbility> {
332        self.entries.last().map(|e| &e.spell_ability)
333    }
334
335    /// Check if any entry has the given source card.
336    /// Mirrors Java's `MagicStack.hasSourceOnStack()`.
337    pub fn has_source_on_stack(&self, card_id: CardId) -> bool {
338        let matches = |entry: &StackEntry| entry.spell_ability.source == Some(card_id);
339        self.entries.iter().any(&matches)
340            || self.frozen_stack.iter().any(&matches)
341            || self.simultaneous_entries.iter().any(&matches)
342            || self.resolving_entry.as_ref().is_some_and(matches)
343    }
344
345    pub fn has_source_chapter_on_stack(&self, game: &GameState, card_id: CardId) -> bool {
346        let matches = |entry: &StackEntry| {
347            entry.spell_ability.is_trigger
348                && entry.spell_ability.source == Some(card_id)
349                && entry
350                    .spell_ability
351                    .source_trigger_id
352                    .is_some_and(|trigger_id| {
353                        game.card(card_id)
354                            .triggers
355                            .iter()
356                            .any(|trigger| trigger.id == trigger_id && trigger.is_chapter())
357                    })
358        };
359
360        self.entries.iter().any(&matches)
361            || self.frozen_stack.iter().any(&matches)
362            || self.simultaneous_entries.iter().any(&matches)
363            || self.resolving_entry.as_ref().is_some_and(matches)
364    }
365
366    /// Check if the top entry has legal targeting (at least one target chosen).
367    /// Mirrors Java's `MagicStack.hasLegalTargeting()`.
368    pub fn has_legal_targeting(&self) -> bool {
369        match self.entries.last() {
370            Some(entry) => {
371                let tc = &entry.spell_ability.target_chosen;
372                // No targeting required = always legal
373                if entry.spell_ability.target_restrictions.is_none() {
374                    return true;
375                }
376                // Has at least one target chosen
377                tc.target_card.is_some()
378                    || tc.target_player.is_some()
379                    || tc.target_stack_entry.is_some()
380            }
381            None => false,
382        }
383    }
384
385    /// Remove all entries controlled by the given player.
386    /// Mirrors Java's `MagicStack.removeInstancesControlledBy()`.
387    pub fn remove_instances_controlled_by(&mut self, player: PlayerId) {
388        self.entries
389            .retain(|e| e.spell_ability.activating_player != player);
390    }
391
392    /// Forward iterator. Mirrors Java's `MagicStack.iterator()`.
393    pub fn iterator(&self) -> impl Iterator<Item = &StackEntry> {
394        self.entries.iter()
395    }
396
397    /// Reverse iterator (top to bottom). Mirrors Java's `MagicStack.reverseIterator()`.
398    pub fn reverse_iterator(&self) -> impl Iterator<Item = &StackEntry> {
399        self.entries.iter().rev()
400    }
401
402    // ── Frozen stack (for declare attackers/blockers) ────────────────
403
404    /// Whether the stack is currently frozen.
405    pub fn is_frozen(&self) -> bool {
406        self.frozen
407    }
408
409    /// Freeze the stack. While frozen, new entries go to the frozen queue.
410    /// Mirrors Java's `MagicStack.freezeStack()`.
411    pub fn freeze_stack(&mut self) {
412        self.frozen = true;
413    }
414
415    /// Add an entry and unfreeze, flushing any queued frozen entries.
416    /// Mirrors Java's `MagicStack.addAndUnfreeze()`.
417    pub fn add_and_unfreeze(&mut self, entry: StackEntry) -> u32 {
418        let id = self.push(entry);
419        self.unfreeze_stack();
420        id
421    }
422
423    /// Unfreeze the stack and flush all frozen entries onto the real stack.
424    /// Mirrors Java's `MagicStack.unfreezeStack()`.
425    pub fn unfreeze_stack(&mut self) {
426        self.frozen = false;
427        // Move frozen entries onto the real stack
428        let frozen = std::mem::take(&mut self.frozen_stack);
429        for entry in frozen.into_iter().rev() {
430            self.push(entry);
431        }
432    }
433
434    /// Clear all frozen entries without processing them.
435    /// Mirrors Java's `MagicStack.clearFrozen()`.
436    pub fn clear_frozen(&mut self) {
437        self.frozen = false;
438        self.frozen_stack.clear();
439    }
440
441    // ── Resolution state ─────────────────────────────────────────────
442
443    /// Whether the stack is currently resolving.
444    pub fn is_resolving(&self) -> bool {
445        self.resolving
446    }
447
448    pub fn set_resolving(&mut self, resolving: bool) {
449        self.resolving = resolving;
450    }
451
452    pub fn set_cur_resolving_card(&mut self, card: Option<CardId>) {
453        self.cur_resolving_card = card;
454    }
455
456    pub fn cur_resolving_card(&self) -> Option<CardId> {
457        self.cur_resolving_card
458    }
459
460    pub fn set_resolving_entry(&mut self, entry: Option<StackEntry>) {
461        self.resolving_entry = entry;
462    }
463
464    // ── Undo stack ───────────────────────────────────────────────────
465
466    /// Check if undo is available for the given player.
467    /// Mirrors Java's `MagicStack.canUndo()`.
468    pub fn can_undo(&self, player: PlayerId) -> bool {
469        self.undo_stack_owner == Some(player) && !self.undo_stack.is_empty()
470    }
471
472    /// Undo the last undoable action. Returns true if successful.
473    /// Mirrors Java's `MagicStack.undo()`.
474    pub fn undo(&mut self) -> bool {
475        if self.undo_stack.is_empty() {
476            return false;
477        }
478        self.undo_stack.pop();
479        if self.undo_stack.is_empty() {
480            self.undo_stack_owner = None;
481        }
482        true
483    }
484
485    /// Clear the undo stack entirely.
486    /// Mirrors Java's `MagicStack.clearUndoStack()`.
487    pub fn clear_undo_stack(&mut self) {
488        self.undo_stack.clear();
489        self.undo_stack_owner = None;
490    }
491
492    /// Remove undo entries whose source matches the given card.
493    /// Mirrors Java's `MagicStack.filterUndoStackByHost()`.
494    pub fn filter_undo_stack_by_host(&mut self, card_id: CardId) {
495        self.undo_stack.retain(|e| e.source_card != Some(card_id));
496        if self.undo_stack.is_empty() {
497            self.undo_stack_owner = None;
498        }
499    }
500
501    /// Record an undoable action on the undo stack.
502    pub fn record_undoable(&mut self, source: Option<CardId>, player: PlayerId) {
503        self.undo_stack_owner = Some(player);
504        self.undo_stack.push(UndoEntry {
505            source_card: source,
506            activating_player: player,
507        });
508    }
509
510    // ── Simultaneous stack entries (triggers) ────────────────────────
511
512    /// Check if there are simultaneous stack entries waiting to be added.
513    /// Mirrors Java's `MagicStack.hasSimultaneousStackEntries()`.
514    pub fn has_simultaneous_stack_entries(&self) -> bool {
515        !self.simultaneous_entries.is_empty()
516    }
517
518    /// Clear all simultaneous stack entries.
519    /// Mirrors Java's `MagicStack.clearSimultaneousStack()`.
520    pub fn clear_simultaneous_stack(&mut self) {
521        self.simultaneous_entries.clear();
522    }
523
524    /// Queue a simultaneous stack entry (trigger) for later addition.
525    /// Mirrors Java's `MagicStack.addSimultaneousStackEntry()`.
526    pub fn add_simultaneous_stack_entry(&mut self, entry: StackEntry) {
527        self.simultaneous_entries.push(entry);
528    }
529
530    /// Move all queued simultaneous entries onto the real stack.
531    /// Returns true if any were added.
532    /// Mirrors Java's `MagicStack.addAllTriggeredAbilitiesToStack()`.
533    pub fn add_all_triggered_abilities_to_stack(&mut self) -> bool {
534        if self.simultaneous_entries.is_empty() {
535            return false;
536        }
537        let entries = std::mem::take(&mut self.simultaneous_entries);
538        for entry in entries {
539            self.push(entry);
540        }
541        true
542    }
543
544    /// Check if there's a state trigger waiting in the simultaneous queue.
545    /// Mirrors Java's `MagicStack.hasStateTrigger()`.
546    pub fn has_state_trigger(&self) -> bool {
547        self.simultaneous_entries
548            .iter()
549            .any(|e| e.spell_ability.is_trigger)
550    }
551
552    /// Check if a specific trigger id already exists in pending/active stack entries.
553    /// Mirrors Java's `MagicStack.hasStateTrigger(triggerId)` behavior.
554    pub fn has_state_trigger_id(&self, trigger_id: u32) -> bool {
555        let matches = |e: &StackEntry| {
556            e.spell_ability.is_trigger && e.spell_ability.source_trigger_id == Some(trigger_id)
557        };
558        self.entries.iter().any(matches)
559            || self.frozen_stack.iter().any(matches)
560            || self.simultaneous_entries.iter().any(matches)
561    }
562
563    // ── Cast commands ────────────────────────────────────────────────
564
565    /// Register a command to run when a spell with the given key resolves.
566    /// Mirrors Java's `MagicStack.addCastCommand()`.
567    pub fn add_cast_command(&mut self, key: &str, command: String) {
568        self.cast_commands
569            .entry(key.to_string())
570            .or_default()
571            .push(command);
572    }
573
574    /// Take and return any cast commands registered for the given key.
575    pub fn take_cast_commands(&mut self, key: &str) -> Vec<String> {
576        self.cast_commands.remove(key).unwrap_or_default()
577    }
578
579    // ── Stack resolution ─────────────────────────────────────────────
580
581    /// Pop the top entry and begin resolution.
582    /// Sets `resolving` state and tracks the resolving card.
583    /// The actual effect resolution is driven by `GameLoop::resolve_stack()`
584    /// which calls this to get the entry, then resolves it with full game context.
585    /// Mirrors Java's `MagicStack.resolveStack()`.
586    pub fn resolve_stack(&mut self) -> Option<StackEntry> {
587        if self
588            .entries
589            .last()
590            .map(|entry| entry.is_pending_cast)
591            .unwrap_or(false)
592        {
593            return None;
594        }
595        let entry = self.entries.pop()?;
596        self.resolving = true;
597        self.cur_resolving_card = entry.spell_ability.source;
598        self.resolving_entry = Some(entry.clone());
599        Some(entry)
600    }
601
602    /// Mark resolution as complete.
603    pub fn finish_resolving(&mut self) {
604        self.resolving = false;
605        self.cur_resolving_card = None;
606        self.resolving_entry = None;
607    }
608
609    // ── Turn tracking ────────────────────────────────────────────────
610
611    /// Called when a new turn begins. Rotates cast/activated tracking.
612    /// Mirrors Java's `MagicStack.onNextTurn()`.
613    pub fn on_next_turn(&mut self) {
614        self.last_turn_cast = std::mem::take(&mut self.this_turn_cast);
615        self.this_turn_activated.clear();
616    }
617
618    /// Record that a spell was cast this turn (for storm count, etc.).
619    pub fn record_spell_cast(&mut self, card_id: CardId) {
620        self.this_turn_cast.push(card_id);
621    }
622
623    /// Get the number of spells cast this turn (storm count).
624    pub fn spells_cast_this_turn(&self) -> usize {
625        self.this_turn_cast.len()
626    }
627
628    /// Get the list of spells cast this turn.
629    pub fn get_spells_cast_this_turn(&self) -> &[CardId] {
630        &self.this_turn_cast
631    }
632
633    /// Get the list of spells cast last turn.
634    pub fn get_spells_cast_last_turn(&self) -> &[CardId] {
635        &self.last_turn_cast
636    }
637
638    /// Track an ability activation this turn.
639    /// Mirrors Java's `MagicStack.addAbilityActivatedThisTurn()`.
640    pub fn add_ability_activated_this_turn(&mut self, sa: &SpellAbility) {
641        if let Some(source) = sa.source {
642            self.this_turn_activated.push(source);
643        }
644    }
645
646    /// Reset max distinct sources counter.
647    /// Mirrors Java's `MagicStack.resetMaxDistinctSources()`.
648    pub fn reset_max_distinct_sources(&mut self) {
649        self.max_distinct_sources = 0;
650    }
651
652    /// Get the max distinct sources seen on the stack this turn.
653    pub fn get_max_distinct_sources(&self) -> usize {
654        self.max_distinct_sources
655    }
656
657    /// Find a stack entry whose spell ability ID matches the given stack entry ID.
658    /// Mirrors Java's `MagicStack.getInstanceMatchingSpellAbilityID()`.
659    pub fn get_instance_matching_spell_ability_id(&self, id: u32) -> Option<&StackEntry> {
660        self.entries.iter().find(|si| si.id == id)
661    }
662
663    /// Find a spell on the stack whose host card matches the given card.
664    /// Returns the spell ability if found.
665    /// Mirrors Java's `MagicStack.getSpellMatchingHost()`.
666    pub fn get_spell_matching_host(&self, host: CardId) -> Option<&SpellAbility> {
667        for si in &self.entries {
668            if si.spell_ability.is_spell && si.spell_ability.source == Some(host) {
669                return Some(&si.spell_ability);
670            }
671        }
672        None
673    }
674}
675
676impl Default for MagicStack {
677    fn default() -> Self {
678        Self::new()
679    }
680}