Skip to main content

manabrew_engine/
game_loop.rs

1use std::collections::{HashMap, VecDeque};
2use std::hash::{DefaultHasher, Hasher};
3use std::sync::atomic::{AtomicBool, Ordering};
4use std::sync::Arc;
5
6#[path = "game_loop/mana_action_undo.rs"]
7mod mana_action_undo;
8#[path = "game_loop/trigger_replacement_base.rs"]
9pub mod trigger_replacement_base;
10
11use forge_foundation::{PhaseType, ZoneType};
12
13// Comment test 2
14use crate::ability::effects::{self, EffectContext};
15use crate::agent::{CombatCostAction, MainPhaseAction, ManaCostAction, PlayerAgent};
16use crate::card::{Card, CounterType};
17use crate::combat::{self, CombatState};
18use crate::cost::{self, parse_cost, CostPart};
19use crate::event::RunParams;
20use crate::game::GameState;
21use crate::game_log::GameLog;
22use crate::game_log_entry_type::GameLogEntryType;
23use crate::game_rng::{GameRng, ThreadRngAdapter};
24use crate::game_snapshot::GameSnapshot;
25use crate::ids::{CardId, PlayerId};
26use crate::mana::{self, basic_land_mana_atom, ManaPool};
27use crate::parsing::{keys, Params};
28use crate::spellability::target_restrictions;
29use crate::spellability::{SpellAbility, StackEntry};
30use crate::staticability::layer::apply_continuous_effects;
31use crate::trigger::handler::TriggerHandler;
32use crate::trigger::TriggerType;
33use mana_action_undo::ManaUndoRecord;
34
35// ── GameLoop ────────────────────────────────────────────────────────
36
37/// Drives a complete game from setup through game over.
38pub struct GameLoop {
39    pub mana_pools: Vec<ManaPool>,
40    pub combat: CombatState,
41    pub trigger_handler: TriggerHandler,
42    pub game_log: GameLog,
43    /// Token templates keyed by their script filename stem (e.g. "r_1_1_goblin").
44    /// Populated at game start by the Tauri layer; used by the Token effect handler.
45    pub token_templates: HashMap<String, Card>,
46    /// Token art variant counts: (token_script, edition_code) → count.
47    /// Used for game-RNG parity with Java. When Java creates a token, it calls
48    /// `Aggregates.random(collection)` on a Set of art variants, which consumes
49    /// `nextInt()` once per element. Rust needs to consume the same number of
50    /// RNG calls to keep the game RNG in sync.
51    pub token_art_variants: HashMap<(String, String), usize>,
52    /// Token fallback codes: edition_code → fallback_edition_code.
53    pub token_fallback: HashMap<String, String>,
54    /// Edition release dates: edition_code → "YYYY-MM-DD".
55    pub edition_dates: HashMap<String, String>,
56    /// Pluggable RNG for game effects (shuffles, coin flips, dice rolls).
57    /// Default: ThreadRngAdapter (non-deterministic). For parity testing,
58    /// replace with a JavaRandom-backed implementation.
59    pub game_rng: Box<dyn GameRng>,
60    /// Enables Java-parity snapshot rollback support (`stash_game_state` / `restore_game_state`).
61    pub experimental_restore_snapshot: bool,
62    /// Last stashed snapshot used by rollback flows.
63    previous_game_state: Option<GameSnapshot>,
64    /// Rolling checkpoint history for UI rewind/debug.
65    checkpoints: VecDeque<(u64, String, GameSnapshot)>,
66    next_checkpoint_id: u64,
67    reserved_sacrifice_stack: Vec<Vec<CardId>>,
68    reserved_source_reuse_stack: Vec<bool>,
69    /// Per-player stack of reversible mana actions. The UI consumes this
70    /// through `untappableLandIds`; legality is owned by the engine.
71    mana_undo_stacks: Vec<Vec<ManaUndoRecord>>,
72    mana_undo_disqualified: bool,
73    /// Cooperative shutdown signal. When the host (e.g. Tauri's
74    /// `GameManager::end_game`) flips this flag we short-circuit the
75    /// outer `run()` loop and bail out. Prevents the engine from
76    /// continuing to tick after the user has conceded or returned to
77    /// the main menu — the previous behavior kept the game running
78    /// silently and drove a visible log/prompt loop on the frontend.
79    pub abort_signal: Option<Arc<AtomicBool>>,
80    /// Whether the engine precomputes priority action space before calling
81    /// `PlayerAgent::choose_action`. UI agents use the default `true`; parity
82    /// disables it and requests action space explicitly only when needed.
83    pub provide_priority_action_space: bool,
84}
85
86#[derive(Debug, Clone)]
87pub(crate) struct PreparedSpellAbility {
88    pub spell_ability: SpellAbility,
89    pub activated_ability_index: Option<usize>,
90    pub static_alternative_cost_prepared: bool,
91}
92
93#[derive(Debug, Clone, Copy, PartialEq, Eq)]
94pub(crate) enum SpellAbilityLogEventKind {
95    Stack,
96    Action,
97}
98
99#[derive(Debug, Clone)]
100pub(crate) struct StackPushContext {
101    pub source_card: CardId,
102    pub entry: StackEntry,
103    pub pending_stack_id: Option<u32>,
104    pub stack_log_name: String,
105    pub stack_message: String,
106    pub target_card: Option<CardId>,
107    pub event_kind: SpellAbilityLogEventKind,
108    pub move_source_to_stack: bool,
109    pub register_source_trigger: bool,
110}
111
112#[derive(Debug, Clone)]
113pub(crate) struct PostStackTriggerContext {
114    pub source_card: CardId,
115    pub cast_trigger: TriggerType,
116    pub emit_ability_activated: bool,
117    pub emit_waterbend: bool,
118    pub waterbend_cards: Vec<CardId>,
119}
120
121#[derive(Debug, Clone, PartialEq, Eq)]
122pub(crate) enum PlaySpellAbilityResult {
123    CardPlayed { card_id: CardId, card_name: String },
124    AbilityActivated,
125}
126
127#[derive(Debug, Clone, Copy, PartialEq, Eq)]
128enum TurnMachineState {
129    Untap,
130    Upkeep,
131    Draw,
132    Main1,
133    Combat,
134    Main2,
135    EndOfTurn,
136    Cleanup,
137    Done,
138}
139
140#[derive(Debug, Clone, Copy, PartialEq, Eq)]
141enum TurnEvent {
142    EnterPhase {
143        phase: PhaseType,
144        emit_phase_trigger: bool,
145    },
146    PriorityWindow {
147        is_main_phase: bool,
148    },
149    UntapStep,
150    DrawStep,
151    CombatStep,
152    CleanupStep,
153    AdvanceTurn,
154}
155
156impl GameLoop {
157    pub fn new(num_players: usize) -> Self {
158        GameLoop {
159            mana_pools: (0..num_players).map(|_| ManaPool::new()).collect(),
160            combat: CombatState::new(),
161            trigger_handler: TriggerHandler::new(),
162            game_log: GameLog::new(),
163            token_templates: HashMap::new(),
164            token_art_variants: HashMap::new(),
165            token_fallback: HashMap::new(),
166            edition_dates: HashMap::new(),
167            game_rng: Box::new(ThreadRngAdapter),
168            experimental_restore_snapshot: false,
169            previous_game_state: None,
170            checkpoints: VecDeque::new(),
171            next_checkpoint_id: 1,
172            reserved_sacrifice_stack: Vec::new(),
173            reserved_source_reuse_stack: Vec::new(),
174            mana_undo_stacks: (0..num_players).map(|_| Vec::new()).collect(),
175            mana_undo_disqualified: false,
176            abort_signal: None,
177            provide_priority_action_space: true,
178        }
179    }
180
181    pub fn set_provide_priority_action_space(&mut self, provide: bool) {
182        self.provide_priority_action_space = provide;
183    }
184
185    /// Install a cooperative abort signal. When the host flips the flag
186    /// the outer `run()` loop exits before the next turn so the game
187    /// thread can wind down cleanly instead of continuing to drive
188    /// prompts at a frontend that has already unmounted.
189    pub fn set_abort_signal(&mut self, signal: Arc<AtomicBool>) {
190        self.abort_signal = Some(signal);
191    }
192
193    fn is_aborted(&self) -> bool {
194        self.abort_signal
195            .as_ref()
196            .map(|s| s.load(Ordering::Relaxed))
197            .unwrap_or(false)
198    }
199
200    /// Register a token template by its script filename stem (e.g. "r_1_1_goblin").
201    /// Called at game start by the Tauri layer for every token script in the token DB.
202    pub fn register_token(&mut self, script_name: impl Into<String>, template: Card) {
203        self.token_templates.insert(script_name.into(), template);
204    }
205
206    /// Get the number of art variants for a token in a given edition.
207    /// Follows TokenFallbackCode chains. Returns 1 if not found.
208    pub fn token_art_variant_count(&self, token_script: &str, edition_code: &str) -> usize {
209        let key = (token_script.to_lowercase(), edition_code.to_uppercase());
210        if let Some(&count) = self.token_art_variants.get(&key) {
211            return count;
212        }
213        if let Some(fallback) = self.token_fallback.get(&edition_code.to_uppercase()) {
214            return self.token_art_variant_count(token_script, fallback);
215        }
216        1
217    }
218
219    pub fn pool(&self, pid: PlayerId) -> &ManaPool {
220        &self.mana_pools[pid.index()]
221    }
222
223    pub fn pool_mut(&mut self, pid: PlayerId) -> &mut ManaPool {
224        &mut self.mana_pools[pid.index()]
225    }
226
227    pub(crate) fn move_card_with_runtime(
228        &mut self,
229        game: &mut GameState,
230        card_id: CardId,
231        dest_zone: ZoneType,
232        dest_owner: PlayerId,
233        agents: &mut [Box<dyn PlayerAgent>],
234    ) {
235        let mut runtime = crate::replacement::replacement_handler::ReplacementRuntime {
236            trigger_handler: &mut self.trigger_handler,
237            token_templates: &self.token_templates,
238            token_art_variants: &self.token_art_variants,
239            token_fallback: &self.token_fallback,
240            edition_dates: &self.edition_dates,
241            mana_pools: &mut self.mana_pools,
242            rng: &mut *self.game_rng,
243        };
244        game.move_card_with_agents_and_replacement_runtime(
245            card_id,
246            dest_zone,
247            dest_owner,
248            agents,
249            &mut runtime,
250        );
251    }
252
253    pub(crate) fn add_saga_lore_counters(
254        &mut self,
255        game: &mut GameState,
256        agents: &mut [Box<dyn PlayerAgent>],
257        cards: &[CardId],
258    ) {
259        let mut table = crate::game_entity_counter_table::GameEntityCounterTable::default();
260        for &card_id in cards {
261            let card = game.card(card_id);
262            if card.zone != ZoneType::Battlefield
263                || !card.has_subtype("Saga")
264                || !card.has_chapter()
265            {
266                continue;
267            }
268            table.put(
269                Some(card.controller),
270                crate::agent::GameEntity::Card(card_id),
271                CounterType::Lore,
272                1,
273            );
274        }
275        table.replace_counter_effect(
276            game,
277            Some(&mut self.trigger_handler),
278            Some(agents),
279            None,
280            false,
281            RunParams::default(),
282        );
283    }
284
285    /// Create a game snapshot. Set `include_stack` false for copy-without-stack flows.
286    pub fn make_snapshot(&self, game: &GameState, include_stack: bool) -> GameSnapshot {
287        GameSnapshot::capture(
288            game,
289            &self.mana_pools,
290            &self.combat,
291            &self.trigger_handler,
292            include_stack,
293        )
294    }
295
296    /// Restore a previously captured snapshot.
297    pub fn restore_snapshot(&mut self, game: &mut GameState, snapshot: &GameSnapshot) {
298        snapshot.restore_game_state(
299            game,
300            &mut self.mana_pools,
301            &mut self.combat,
302            &mut self.trigger_handler,
303        );
304    }
305
306    /// Stash the current state if snapshot rollback is enabled.
307    pub fn stash_game_state(&mut self, game: &GameState) {
308        if self.experimental_restore_snapshot {
309            self.previous_game_state = Some(self.make_snapshot(game, true));
310        }
311    }
312
313    /// Restore from the previously stashed state if available and enabled.
314    pub fn restore_game_state(&mut self, game: &mut GameState) -> bool {
315        if !self.experimental_restore_snapshot {
316            return false;
317        }
318        let Some(snapshot) = self.previous_game_state.as_ref() else {
319            return false;
320        };
321        crate::perf::increment(crate::perf::Metric::SnapshotClones, 1);
322        let snapshot = snapshot.clone();
323        self.restore_snapshot(game, &snapshot);
324        true
325    }
326
327    pub fn restore_checkpoint(&mut self, game: &mut GameState, checkpoint_id: u64) -> bool {
328        let Some((_, _, snapshot)) = self
329            .checkpoints
330            .iter()
331            .find(|(id, _, _)| *id == checkpoint_id)
332        else {
333            return false;
334        };
335        crate::perf::increment(crate::perf::Metric::SnapshotClones, 1);
336        let snapshot = snapshot.clone();
337        self.restore_snapshot(game, &snapshot);
338        true
339    }
340
341    fn record_checkpoint(&mut self, game: &GameState, include_stack: bool) -> (u64, String) {
342        let checkpoint_id = self.next_checkpoint_id;
343        self.next_checkpoint_id += 1;
344        let label = format!(
345            "Turn {} {}",
346            game.turn.turn_number,
347            game.turn.phase.script_name()
348        );
349        let snap = self.make_snapshot(game, include_stack);
350        self.checkpoints
351            .push_back((checkpoint_id, label.clone(), snap));
352        while self.checkpoints.len() > 256 {
353            self.checkpoints.pop_front();
354        }
355        (checkpoint_id, label)
356    }
357
358    pub(crate) fn apply_pending_snapshot_restore(
359        &mut self,
360        game: &mut GameState,
361        agents: &mut [Box<dyn PlayerAgent>],
362    ) -> bool {
363        let mut requested = None;
364        for agent in agents.iter_mut() {
365            if let Some(id) = agent.take_restore_request() {
366                requested = Some(id);
367            }
368        }
369        let Some(checkpoint_id) = requested else {
370            return false;
371        };
372        let restored = self.restore_checkpoint(game, checkpoint_id);
373        if restored {
374            for agent in agents.iter_mut() {
375                agent.snapshot_state(game, &self.mana_pools);
376                agent.notify(crate::agent::notification::GameNotification::StateChanged);
377            }
378        }
379        restored
380    }
381
382    /// Get untapped lands on the battlefield for a player.
383    pub fn get_tappable_lands(&self, game: &GameState, player: PlayerId) -> Vec<CardId> {
384        game.cards_in_zone(ZoneType::Battlefield, player)
385            .to_vec()
386            .into_iter()
387            .filter(|&cid| {
388                let c = game.card(cid);
389                c.is_land() && !c.tapped
390            })
391            .collect()
392    }
393
394    /// Get the top reversible mana source for a player, if any.
395    pub fn get_untappable_lands(
396        &self,
397        _game: &GameState,
398        player: PlayerId,
399        _pool_snapshot: &ManaPool,
400    ) -> Vec<CardId> {
401        self.undoable_mana_sources(player)
402    }
403
404    /// Set up the game: roll for first player, shuffle libraries, draw
405    /// opening hands, run mulligans.
406    pub fn setup(
407        &mut self,
408        game: &mut GameState,
409        agents: &mut [Box<dyn PlayerAgent>],
410        rng: &mut impl rand::Rng,
411    ) {
412        self.roll_for_first_player(game, agents, rng);
413
414        for &pid in &game.player_order.clone() {
415            game.shuffle_library(pid, rng);
416            game.draw_cards(pid, 7);
417        }
418
419        let first_player = game.active_player();
420        crate::mulligan::run_london_mulligans(
421            game,
422            agents,
423            rng,
424            first_player,
425            &self.mana_pools,
426            &self.game_log,
427        );
428    }
429
430    /// Each player rolls a d20; the highest roller goes first. Ties are
431    /// broken by rerolling among the tied players (resolved internally —
432    /// only the final round is surfaced to the UI).
433    ///
434    /// Emits a single `FirstPlayerRoll` notification so the frontend can
435    /// animate every player's die side-by-side. Mutates
436    /// `game.turn.active_player` and `priority_player` to the winner.
437    pub fn roll_for_first_player(
438        &mut self,
439        game: &mut GameState,
440        agents: &mut [Box<dyn PlayerAgent>],
441        rng: &mut impl rand::Rng,
442    ) {
443        const SIDES: i32 = 20;
444        let players: Vec<PlayerId> = game.player_order.clone();
445        if players.len() < 2 {
446            return;
447        }
448
449        // Run the tiebreak loop silently; only the final, decisive round
450        // is broadcast to the UI.
451        let mut contenders: Vec<PlayerId> = players.clone();
452        let (final_rolls, winner) = loop {
453            let rolls: Vec<(PlayerId, i32)> = contenders
454                .iter()
455                .map(|&pid| (pid, rng.gen_range(1..=SIDES)))
456                .collect();
457
458            for (pid, value) in &rolls {
459                let player_name = game.player(*pid).name.clone();
460                self.game_log.log(
461                    GameLogEntryType::Info,
462                    0,
463                    format!("{player_name} rolls a {value} (d{SIDES})"),
464                );
465            }
466
467            let highest = rolls.iter().map(|(_, v)| *v).max().unwrap_or(0);
468            let top: Vec<PlayerId> = rolls
469                .iter()
470                .filter(|(_, v)| *v == highest)
471                .map(|(p, _)| *p)
472                .collect();
473            if top.len() == 1 {
474                break (rolls, top[0]);
475            }
476            self.game_log.log(
477                GameLogEntryType::Info,
478                0,
479                "Tie — rerolling among tied players".to_string(),
480            );
481            contenders = top;
482        };
483
484        let winner_name = game.player(winner).name.clone();
485        self.game_log.log(
486            GameLogEntryType::Info,
487            0,
488            format!("{winner_name} goes first"),
489        );
490
491        for agent in agents.iter_mut() {
492            agent.snapshot_state(game, &self.mana_pools);
493        }
494        crate::agent::game_log::broadcast_notification(
495            agents,
496            crate::agent::notification::GameNotification::FirstPlayerRoll {
497                sides: SIDES,
498                rolls: final_rolls,
499                winner,
500            },
501        );
502        // Wait for every human-driven transport to finish its animation
503        // (in parallel — the prompt was already dispatched to all of
504        // them). AI transports skip this no-op.
505        for agent in agents.iter_mut() {
506            agent.await_display_ack();
507        }
508
509        game.turn.active_player = winner;
510        game.turn.priority_player = winner;
511    }
512
513    /// Run generic "opening hand" actions before the game begins.
514    ///
515    /// Mirrors Java's `GameAction.runOpeningHandActions()`: gather every
516    /// `MayEffectFromOpeningHand` keyword in hand, ask the controller whether
517    /// to use it, and resolve the referenced SVar immediately.
518    pub fn run_opening_hand_actions(
519        &mut self,
520        game: &mut GameState,
521        agents: &mut [Box<dyn PlayerAgent>],
522    ) {
523        let first_player = game.active_player();
524        let mut takes_action = first_player;
525        let mut new_first = first_player;
526
527        loop {
528            let usable = self.collect_opening_hand_actions(game, takes_action, first_player);
529            for mut sa in usable {
530                let Some(source_id) = sa.source else {
531                    continue;
532                };
533                if game.card(source_id).zone != ZoneType::Hand {
534                    continue;
535                }
536
537                agents[takes_action.index()].snapshot_state(game, &self.mana_pools);
538                let _card_name = game.card(source_id).card_name.clone();
539                let prompt = sa
540                    .ir
541                    .spell_description_text
542                    .as_deref()
543                    .unwrap_or("Use opening hand effect?");
544                let accepted = agents[takes_action.index()].confirm_action(
545                    takes_action,
546                    Some("FromOpeningHand"),
547                    prompt,
548                    &[],
549                    Some(source_id),
550                    sa.api,
551                );
552                if !accepted {
553                    continue;
554                }
555
556                if sa.uses_targeting() && !sa.setup_targets(game, agents, &self.mana_pools) {
557                    continue;
558                }
559
560                let becomes_starting_player = sa.ir.become_starting_player;
561                let entry = StackEntry {
562                    id: 0,
563                    spell_ability: sa,
564                    is_pending_cast: false,
565                    is_creature_spell: false,
566                    is_permanent_spell: false,
567                    cast_from_zone: Some(ZoneType::Hand),
568                    optional_trigger_decider: None,
569                    optional_trigger_description: None,
570                    optional_trigger_source_name: None,
571                };
572                self.resolve_spell_effect(game, agents, &entry);
573                apply_continuous_effects(game);
574
575                if becomes_starting_player {
576                    new_first = takes_action;
577                }
578            }
579
580            takes_action = game.next_player(takes_action);
581            if takes_action == first_player {
582                break;
583            }
584        }
585
586        if new_first != first_player {
587            game.turn.active_player = new_first;
588            game.turn.priority_player = new_first;
589        }
590    }
591
592    fn collect_opening_hand_actions(
593        &self,
594        game: &GameState,
595        player: PlayerId,
596        first_player: PlayerId,
597    ) -> Vec<SpellAbility> {
598        let mut usable = Vec::new();
599
600        for &card_id in game.cards_in_zone(ZoneType::Hand, player) {
601            let card = game.card(card_id);
602            for kw in card.keywords.as_string_list() {
603                if !kw.starts_with("MayEffectFromOpeningHand") {
604                    continue;
605                }
606                let split: Vec<&str> = kw.split(':').collect();
607                let Some(effect_name) = split.get(1).copied() else {
608                    continue;
609                };
610                if split.get(2).copied() == Some("!PlayFirst") && first_player == player {
611                    continue;
612                }
613                let Some(raw) = card.svars.get(effect_name) else {
614                    continue;
615                };
616                usable.push(crate::spellability::build_spell_ability(
617                    game, card_id, raw, player,
618                ));
619            }
620        }
621
622        usable
623    }
624
625    /// Run the full game until someone wins or loses.
626    /// Returns the winner's PlayerId.
627    pub fn run(
628        &mut self,
629        game: &mut GameState,
630        agents: &mut [Box<dyn PlayerAgent>],
631        rng: &mut impl rand::Rng,
632        max_turns: u32,
633    ) -> Option<PlayerId> {
634        self.setup(game, agents, rng);
635        self.run_opening_hand_actions(game, agents);
636
637        self.trigger_handler.reset_active_triggers(game);
638        self.trigger_handler
639            .run_trigger(TriggerType::NewGame, RunParams::default(), true);
640
641        while !game.game_over && game.turn.turn_number <= max_turns {
642            if self.is_aborted() {
643                // Host requested a shutdown (user conceded / returned to
644                // menu). Mark the game as over without picking a winner
645                // so the agent thread can fall through and drop.
646                game.game_over = true;
647                break;
648            }
649            self.run_turn(game, agents, rng);
650        }
651
652        game.winner
653    }
654
655    /// Run a single turn.
656    pub fn run_turn(
657        &mut self,
658        game: &mut GameState,
659        agents: &mut [Box<dyn PlayerAgent>],
660        _rng: &mut impl rand::Rng,
661    ) {
662        let _perf_scope =
663            crate::perf::ParamsLookupScopeGuard::enter(crate::perf::ParamsLookupScope::GameLoop);
664        let active = game.active_player();
665        let active_name = game.player(active).name.clone();
666
667        // SkipTurn (issue #22): if the active player has skip_turns > 0, skip entirely.
668        if game.player(active).skip_turns > 0 {
669            game.player_decrement_skip_turns(active);
670            self.log_turn_skipped(game, active, game.player(active).skip_turns);
671            // Still advance turn state so the next player gets their turn
672            game.turn.next_player_turn(&game.player_order.clone());
673            return;
674        }
675
676        game.new_turn_for_player(active);
677        self.log_turn_begin(&active_name, game.turn.turn_number);
678
679        // Snapshot + notify all agents of the turn change (display-only, before any actions)
680        let turn_number = game.turn.turn_number;
681        for agent in agents.iter_mut() {
682            agent.snapshot_state(game, &self.mana_pools);
683        }
684        let (checkpoint_id, label) = self.record_checkpoint(game, true);
685        for agent in agents.iter_mut() {
686            agent.notify(
687                crate::agent::notification::GameNotification::SnapshotCreated {
688                    checkpoint_id,
689                    label: label.clone(),
690                },
691            );
692        }
693        for agent in agents.iter_mut() {
694            agent.notify(crate::agent::notification::GameNotification::TurnChanged {
695                active_player: active,
696                turn_number,
697            });
698        }
699
700        // Recompute continuous static effects for the new turn.
701        apply_continuous_effects(game);
702        // Rebuild active triggers after statics so granted triggers are included.
703        self.trigger_handler.reset_active_triggers(game);
704
705        self.run_turn_state_machine(game, agents);
706    }
707
708    pub(crate) fn log_turn_begin(&self, player_name: &str, turn_number: u32) {
709        self.game_log.log(
710            GameLogEntryType::TurnBegin,
711            0,
712            format!("{player_name} turn begins (turn {turn_number})"),
713        );
714    }
715
716    pub(crate) fn log_turn_skipped(
717        &self,
718        game: &GameState,
719        player: PlayerId,
720        remaining_skip_turns: i32,
721    ) {
722        self.game_log.log(
723            GameLogEntryType::TurnSkip,
724            0,
725            format!(
726                "{} turn skipped (remaining skip-turn effects: {})",
727                game.player(player).name,
728                remaining_skip_turns
729            ),
730        );
731    }
732
733    pub(crate) fn log_phase_begin(&self, phase: PhaseType) {
734        self.game_log.log(
735            GameLogEntryType::PhaseBegin,
736            1,
737            format!("Phase {}", phase.script_name()),
738        );
739    }
740
741    pub(crate) fn log_waiting_for_priority(&self, game: &GameState, player: PlayerId) {
742        self.game_log.log(
743            GameLogEntryType::PriorityWaiting,
744            2,
745            format!("Waiting for {} priority response", game.player(player).name),
746        );
747    }
748
749    pub(crate) fn log_priority_response(&self, game: &GameState, player: PlayerId, action: &str) {
750        self.game_log.log(
751            GameLogEntryType::PriorityResponse,
752            2,
753            format!("{} responded with {}", game.player(player).name, action),
754        );
755    }
756
757    pub(crate) fn log_priority_pass(&self, game: &GameState, player: PlayerId) {
758        self.game_log.log(
759            GameLogEntryType::PriorityPass,
760            2,
761            format!("{} passed priority", game.player(player).name),
762        );
763    }
764
765    pub(crate) fn log_stack_push(&self, item_name: &str, player_name: &str) {
766        self.game_log.log(
767            GameLogEntryType::StackPush,
768            2,
769            format!("{item_name} pushed to stack ({player_name})"),
770        );
771    }
772
773    pub(crate) fn log_stack_resolved_item(&self, item_name: &str) {
774        self.game_log.log(
775            GameLogEntryType::StackResolve,
776            2,
777            format!("{item_name} resolved"),
778        );
779    }
780}
781
782/// Helper: run SBA with trigger handler and legend-rule agent callback.
783/// Mirrors Java's GameAction.checkStateEffects() + handleLegendRule() which
784/// delegates the "keep which legendary?" choice to the player controller.
785fn check_sba(
786    game: &mut GameState,
787    trigger_handler: &mut TriggerHandler,
788    agents: &mut [Box<dyn PlayerAgent>],
789) -> bool {
790    let _perf_scope =
791        crate::perf::ParamsLookupScopeGuard::enter(crate::perf::ParamsLookupScope::PrioritySba);
792    let result = game.check_state_based_actions_with_trigger_agents(Some(trigger_handler), agents);
793    if result {
794        // Flush triggers fired during SBA before re-registering. This preserves
795        // triggers from Animate effects (pump_trigger_count) that were active
796        // when creatures died.
797        trigger_handler.flush_waiting_triggers(game);
798        // Re-register triggers after SBA may have moved cards between zones.
799        // This ensures triggers with non-Battlefield active zones (e.g.
800        // TriggerZones$ Graveyard) are registered when cards die.
801        trigger_handler.reset_active_triggers(game);
802    }
803    result
804}
805
806mod action_space;
807mod cast_spell;
808mod combat_phase;
809mod cost_payment;
810mod game_action;
811pub(crate) use game_action::{fire_sacrificed_once_for_batch, perform_sacrifice};
812pub(crate) mod mana_payment;
813mod phase_handler;
814mod playability;
815mod priority;
816mod stack_resolution;
817mod state_observer;
818mod trigger_handler;
819
820#[cfg(test)]
821mod tests {
822    use std::sync::atomic::{AtomicBool, Ordering};
823    use std::sync::{Arc, Mutex};
824
825    use forge_foundation::{CardTypeLine, ColorSet, ManaCost};
826    use rand::SeedableRng;
827
828    use crate::agent::{PlayCardMode, PlayerAgent, TargetChoice};
829    use crate::card::Card;
830    use crate::player::actions::PlayerAction;
831
832    use super::*;
833
834    struct RecordingPassAgent {
835        phases_seen: Arc<Mutex<Vec<PhaseType>>>,
836        bad_priority_seen: Arc<AtomicBool>,
837        last_phase: Option<PhaseType>,
838        last_priority: Option<PlayerId>,
839    }
840
841    struct InvalidPlayAgent;
842    struct OpeningHandAgent {
843        accept: bool,
844    }
845
846    impl PlayerAgent for InvalidPlayAgent {
847        fn mulligan_decision(
848            &mut self,
849            _player: PlayerId,
850            _hand: &[CardId],
851            _mulligan_count: u32,
852        ) -> bool {
853            true
854        }
855
856        fn choose_action(
857            &mut self,
858            player: PlayerId,
859            action_space: Option<&crate::agent::PriorityActionSpace>,
860            request_action_space: &mut dyn FnMut() -> crate::agent::PriorityActionSpace,
861        ) -> PlayerAction {
862            PlayerAction::CastSpell(crate::agent::PlayOption {
863                card_id: CardId(u32::MAX),
864                mode: PlayCardMode::Normal,
865                alt_cost_index: 0,
866            })
867        }
868
869        fn choose_attackers(
870            &mut self,
871            _player: PlayerId,
872            _available: &[CardId],
873            _possible_defenders: &[crate::combat::DefenderId],
874        ) -> Vec<(CardId, crate::combat::DefenderId)> {
875            Vec::new()
876        }
877
878        fn choose_blockers(
879            &mut self,
880            _player: PlayerId,
881            _attackers: &[CardId],
882            _available_blockers: &[CardId],
883            _max_blockers: Option<usize>,
884        ) -> Vec<(CardId, CardId)> {
885            Vec::new()
886        }
887
888        fn choose_target_player(
889            &mut self,
890            _player: PlayerId,
891            valid: &[PlayerId],
892            _sa: Option<&crate::spellability::SpellAbility>,
893        ) -> Option<PlayerId> {
894            valid.first().copied()
895        }
896
897        fn choose_target_card(
898            &mut self,
899            _player: PlayerId,
900            valid: &[CardId],
901            _sa: Option<&crate::spellability::SpellAbility>,
902        ) -> Option<CardId> {
903            valid.first().copied()
904        }
905
906        fn choose_target_any(
907            &mut self,
908            _player: PlayerId,
909            valid_players: &[PlayerId],
910            valid_cards: &[CardId],
911            _sa: Option<&crate::spellability::SpellAbility>,
912        ) -> TargetChoice {
913            if let Some(&pid) = valid_players.first() {
914                TargetChoice::Player(pid)
915            } else if let Some(&cid) = valid_cards.first() {
916                TargetChoice::Card(cid)
917            } else {
918                TargetChoice::None
919            }
920        }
921
922        fn choose_land_or_spell(&mut self, _player: PlayerId) -> Option<bool> {
923            None
924        }
925
926        fn choose_targets_for(
927            &mut self,
928            _sa: &mut crate::spellability::SpellAbility,
929            _game: &GameState,
930            _mana_pools: &[ManaPool],
931        ) -> bool {
932            false
933        }
934
935        fn notify(&mut self, _message: crate::agent::notification::GameNotification) {}
936    }
937
938    impl RecordingPassAgent {
939        fn new(
940            phases_seen: Arc<Mutex<Vec<PhaseType>>>,
941            bad_priority_seen: Arc<AtomicBool>,
942        ) -> Self {
943            Self {
944                phases_seen,
945                bad_priority_seen,
946                last_phase: None,
947                last_priority: None,
948            }
949        }
950    }
951
952    impl PlayerAgent for RecordingPassAgent {
953        fn snapshot_state(&mut self, game: &GameState, _mana_pools: &[ManaPool]) {
954            self.last_phase = Some(game.turn.phase);
955            self.last_priority = Some(game.turn.priority_player);
956        }
957
958        fn mulligan_decision(
959            &mut self,
960            _player: PlayerId,
961            _hand: &[CardId],
962            _mulligan_count: u32,
963        ) -> bool {
964            true
965        }
966
967        fn choose_action(
968            &mut self,
969            player: PlayerId,
970            action_space: Option<&crate::agent::PriorityActionSpace>,
971            request_action_space: &mut dyn FnMut() -> crate::agent::PriorityActionSpace,
972        ) -> PlayerAction {
973            if self.last_priority != Some(player) {
974                self.bad_priority_seen.store(true, Ordering::SeqCst);
975            }
976            if let Some(phase) = self.last_phase {
977                self.phases_seen.lock().unwrap().push(phase);
978            }
979            PlayerAction::PassPriority
980        }
981
982        fn choose_attackers(
983            &mut self,
984            _player: PlayerId,
985            _available: &[CardId],
986            _possible_defenders: &[crate::combat::DefenderId],
987        ) -> Vec<(CardId, crate::combat::DefenderId)> {
988            Vec::new()
989        }
990
991        fn choose_blockers(
992            &mut self,
993            _player: PlayerId,
994            _attackers: &[CardId],
995            _available_blockers: &[CardId],
996            _max_blockers: Option<usize>,
997        ) -> Vec<(CardId, CardId)> {
998            Vec::new()
999        }
1000
1001        fn choose_target_player(
1002            &mut self,
1003            _player: PlayerId,
1004            valid: &[PlayerId],
1005            _sa: Option<&crate::spellability::SpellAbility>,
1006        ) -> Option<PlayerId> {
1007            valid.first().copied()
1008        }
1009
1010        fn choose_target_card(
1011            &mut self,
1012            _player: PlayerId,
1013            valid: &[CardId],
1014            _sa: Option<&crate::spellability::SpellAbility>,
1015        ) -> Option<CardId> {
1016            valid.first().copied()
1017        }
1018
1019        fn choose_target_any(
1020            &mut self,
1021            _player: PlayerId,
1022            valid_players: &[PlayerId],
1023            valid_cards: &[CardId],
1024            _sa: Option<&crate::spellability::SpellAbility>,
1025        ) -> TargetChoice {
1026            if let Some(&pid) = valid_players.first() {
1027                TargetChoice::Player(pid)
1028            } else if let Some(&cid) = valid_cards.first() {
1029                TargetChoice::Card(cid)
1030            } else {
1031                TargetChoice::None
1032            }
1033        }
1034
1035        fn choose_land_or_spell(&mut self, _player: PlayerId) -> Option<bool> {
1036            None
1037        }
1038
1039        fn choose_targets_for(
1040            &mut self,
1041            _sa: &mut crate::spellability::SpellAbility,
1042            _game: &GameState,
1043            _mana_pools: &[ManaPool],
1044        ) -> bool {
1045            false
1046        }
1047
1048        fn notify(&mut self, _message: crate::agent::notification::GameNotification) {}
1049    }
1050
1051    impl PlayerAgent for OpeningHandAgent {
1052        fn mulligan_decision(
1053            &mut self,
1054            _player: PlayerId,
1055            _hand: &[CardId],
1056            _mulligan_count: u32,
1057        ) -> bool {
1058            true
1059        }
1060
1061        fn choose_action(
1062            &mut self,
1063            player: PlayerId,
1064            action_space: Option<&crate::agent::PriorityActionSpace>,
1065            request_action_space: &mut dyn FnMut() -> crate::agent::PriorityActionSpace,
1066        ) -> PlayerAction {
1067            PlayerAction::PassPriority
1068        }
1069
1070        fn choose_attackers(
1071            &mut self,
1072            _player: PlayerId,
1073            _available: &[CardId],
1074            _possible_defenders: &[crate::combat::DefenderId],
1075        ) -> Vec<(CardId, crate::combat::DefenderId)> {
1076            Vec::new()
1077        }
1078
1079        fn choose_blockers(
1080            &mut self,
1081            _player: PlayerId,
1082            _attackers: &[CardId],
1083            _available_blockers: &[CardId],
1084            _max_blockers: Option<usize>,
1085        ) -> Vec<(CardId, CardId)> {
1086            Vec::new()
1087        }
1088
1089        fn choose_target_player(
1090            &mut self,
1091            _player: PlayerId,
1092            valid: &[PlayerId],
1093            _sa: Option<&crate::spellability::SpellAbility>,
1094        ) -> Option<PlayerId> {
1095            valid.first().copied()
1096        }
1097
1098        fn choose_target_card(
1099            &mut self,
1100            _player: PlayerId,
1101            valid: &[CardId],
1102            _sa: Option<&crate::spellability::SpellAbility>,
1103        ) -> Option<CardId> {
1104            valid.first().copied()
1105        }
1106
1107        fn choose_target_any(
1108            &mut self,
1109            _player: PlayerId,
1110            valid_players: &[PlayerId],
1111            valid_cards: &[CardId],
1112            _sa: Option<&crate::spellability::SpellAbility>,
1113        ) -> TargetChoice {
1114            if let Some(&pid) = valid_players.first() {
1115                TargetChoice::Player(pid)
1116            } else if let Some(&cid) = valid_cards.first() {
1117                TargetChoice::Card(cid)
1118            } else {
1119                TargetChoice::None
1120            }
1121        }
1122
1123        fn choose_land_or_spell(&mut self, _player: PlayerId) -> Option<bool> {
1124            None
1125        }
1126
1127        fn confirm_action(
1128            &mut self,
1129            _player: PlayerId,
1130            _mode: Option<&str>,
1131            _message: &str,
1132            _options: &[String],
1133            _source: Option<crate::ids::CardId>,
1134            _api: Option<crate::ability::api_type::ApiType>,
1135        ) -> bool {
1136            self.accept
1137        }
1138
1139        fn choose_targets_for(
1140            &mut self,
1141            _sa: &mut crate::spellability::SpellAbility,
1142            _game: &GameState,
1143            _mana_pools: &[ManaPool],
1144        ) -> bool {
1145            false
1146        }
1147
1148        fn notify(&mut self, _message: crate::agent::notification::GameNotification) {}
1149    }
1150
1151    fn zero_cost_instant(owner: PlayerId) -> Card {
1152        Card::new(
1153            CardId(0),
1154            "Test Instant".to_string(),
1155            owner,
1156            CardTypeLine::parse("Instant"),
1157            ManaCost::no_cost(),
1158            ColorSet::COLORLESS,
1159            None,
1160            None,
1161            vec![],
1162            vec![],
1163        )
1164    }
1165
1166    fn mana_land(owner: PlayerId, name: &str, produced: &str) -> Card {
1167        Card::new(
1168            CardId(0),
1169            name.to_string(),
1170            owner,
1171            CardTypeLine::parse("Land"),
1172            ManaCost::no_cost(),
1173            ColorSet::COLORLESS,
1174            None,
1175            None,
1176            vec![],
1177            vec![format!(
1178                "AB$ Mana | Cost$ T | Produced$ {} | SpellDescription$ Add mana.",
1179                produced
1180            )],
1181        )
1182    }
1183
1184    fn vanilla_spell(owner: PlayerId, name: &str, cost: &str) -> Card {
1185        Card::new(
1186            CardId(0),
1187            name.to_string(),
1188            owner,
1189            CardTypeLine::parse("Sorcery"),
1190            ManaCost::parse(cost),
1191            ColorSet::COLORLESS,
1192            None,
1193            None,
1194            vec![],
1195            vec![],
1196        )
1197    }
1198
1199    fn evoked_etb_creature(owner: PlayerId) -> Card {
1200        let mut card = Card::new(
1201            CardId(0),
1202            "Mulldrifter Test".to_string(),
1203            owner,
1204            CardTypeLine::parse("Creature - Elemental"),
1205            ManaCost::parse("4 U"),
1206            ColorSet::BLUE,
1207            Some(2),
1208            Some(2),
1209            vec!["Evoke:2 U".to_string()],
1210            vec![],
1211        );
1212
1213        let mut next_trigger_id = 0;
1214        let etb_draw = crate::trigger::parse_trigger(
1215            "Mode$ ChangesZone | Destination$ Battlefield | ValidCard$ Card.Self | Execute$ TrigDraw | TriggerDescription$ When CARDNAME enters the battlefield, draw two cards.",
1216            &mut next_trigger_id,
1217        )
1218        .expect("valid ETB trigger");
1219        card.add_trigger(etb_draw);
1220        card.base_trigger_count = card.triggers.len();
1221        card.svars.insert(
1222            "TrigDraw".to_string(),
1223            "DB$ Draw | NumCards$ 2 | Defined$ You".to_string(),
1224        );
1225        card
1226    }
1227
1228    fn activated_permanent(
1229        owner: PlayerId,
1230        name: &str,
1231        type_line: &str,
1232        abilities: Vec<&str>,
1233    ) -> Card {
1234        Card::new(
1235            CardId(0),
1236            name.to_string(),
1237            owner,
1238            CardTypeLine::parse(type_line),
1239            ManaCost::no_cost(),
1240            ColorSet::COLORLESS,
1241            None,
1242            None,
1243            vec![],
1244            abilities.into_iter().map(|s| s.to_string()).collect(),
1245        )
1246    }
1247
1248    fn opening_hand_card(owner: PlayerId, name: &str, keyword: &str, svar_text: &str) -> Card {
1249        let mut card = Card::new(
1250            CardId(0),
1251            name.to_string(),
1252            owner,
1253            CardTypeLine::parse("Enchantment"),
1254            ManaCost::parse("2 W"),
1255            ColorSet::WHITE,
1256            None,
1257            None,
1258            vec![keyword.to_string()],
1259            vec![],
1260        );
1261        card.svars
1262            .insert("FromHand".to_string(), svar_text.to_string());
1263        card
1264    }
1265
1266    #[test]
1267    fn priority_round_ignores_illegal_actions() {
1268        let p0 = PlayerId(0);
1269        let p1 = PlayerId(1);
1270        let mut game = GameState::new(&["A", "B"], 20);
1271
1272        let c0 = game.create_card(zero_cost_instant(p0));
1273        let c1 = game.create_card(zero_cost_instant(p1));
1274        game.move_card(c0, ZoneType::Hand, p0);
1275        game.move_card(c1, ZoneType::Hand, p1);
1276
1277        game.turn.active_player = p0;
1278        game.turn.priority_player = p0;
1279        game.turn.phase = PhaseType::Upkeep;
1280
1281        let seen = Arc::new(Mutex::new(Vec::new()));
1282        let bad = Arc::new(AtomicBool::new(false));
1283        let mut agents: Vec<Box<dyn PlayerAgent>> = vec![
1284            Box::new(InvalidPlayAgent),
1285            Box::new(RecordingPassAgent::new(seen, bad)),
1286        ];
1287
1288        let mut game_loop = GameLoop::new(2);
1289        game_loop.priority_round(&mut game, &mut agents, false);
1290
1291        assert!(game.stack.is_empty());
1292        assert!(game.cards_in_zone(ZoneType::Hand, p0).contains(&c0));
1293        assert!(game.cards_in_zone(ZoneType::Hand, p1).contains(&c1));
1294        assert_eq!(game.turn.priority_player, game.active_player());
1295    }
1296
1297    #[test]
1298    fn action_space_excludes_nonland_mana_abilities_from_main_actions() {
1299        let p0 = PlayerId(0);
1300        let mut game = GameState::new(&["A", "B"], 20);
1301
1302        let goose = game.create_card(activated_permanent(
1303            p0,
1304            "Gilded Goose",
1305            "Creature - Bird",
1306            vec![
1307                "AB$ Token | Cost$ 1 G T | TokenScript$ c_a_food_sac | TokenOwner$ You | SpellDescription$ Create a Food Token.",
1308                "AB$ Mana | Cost$ T Sac<1/Food> | Produced$ Any | SpellDescription$ Add one mana of any color.",
1309            ],
1310        ));
1311        let food = game.create_card(activated_permanent(
1312            p0,
1313            "Food Token",
1314            "Artifact Food",
1315            vec!["AB$ GainLife | Cost$ 2 T Sac<1/CARDNAME> | LifeAmount$ 3 | SpellDescription$ You gain 3 life."],
1316        ));
1317        let forest = game.create_card(mana_land(p0, "Forest", "G"));
1318        let island = game.create_card(mana_land(p0, "Island", "U"));
1319
1320        for cid in [goose, food, forest, island] {
1321            game.move_card(cid, ZoneType::Battlefield, p0);
1322            game.card_mut(cid).summoning_sick = false;
1323        }
1324
1325        game.turn.turn_number = 20;
1326        game.turn.active_player = p0;
1327        game.turn.priority_player = p0;
1328        game.turn.phase = PhaseType::Main1;
1329
1330        let gl = GameLoop::new(2);
1331        let action_space = gl.action_space(&game, p0, true);
1332
1333        let has = |cid, idx| {
1334            action_space
1335                .activatable
1336                .iter()
1337                .any(|a| a.card_id == cid && a.ability_index == idx)
1338        };
1339        assert!(has(food, 0));
1340        assert!(has(goose, 0));
1341        assert!(!has(goose, 1));
1342    }
1343
1344    #[test]
1345    fn evoke_keeps_etb_triggers_when_spell_resolves() {
1346        let p0 = PlayerId(0);
1347        let _p1 = PlayerId(1);
1348        let mut game = GameState::new(&["A", "B"], 20);
1349
1350        let evoked = game.create_card(evoked_etb_creature(p0));
1351        game.move_card(evoked, ZoneType::Stack, p0);
1352
1353        let mut sa = SpellAbility::new_simple(Some(evoked), p0, "SP$ Permanent");
1354        sa.alt_cost = Some(crate::spellability::AlternativeCost::Evoke);
1355
1356        game.stack.push(StackEntry {
1357            id: 1,
1358            spell_ability: sa,
1359            is_pending_cast: false,
1360            is_creature_spell: true,
1361            is_permanent_spell: true,
1362            cast_from_zone: Some(ZoneType::Hand),
1363            optional_trigger_decider: None,
1364            optional_trigger_description: None,
1365            optional_trigger_source_name: None,
1366        });
1367
1368        let mut gl = GameLoop::new(2);
1369        let mut agents: Vec<Box<dyn PlayerAgent>> = vec![
1370            Box::new(RecordingPassAgent::new(
1371                Arc::new(Mutex::new(Vec::new())),
1372                Arc::new(AtomicBool::new(false)),
1373            )),
1374            Box::new(RecordingPassAgent::new(
1375                Arc::new(Mutex::new(Vec::new())),
1376                Arc::new(AtomicBool::new(false)),
1377            )),
1378        ];
1379
1380        gl.resolve_stack(&mut game, &mut agents);
1381        gl.process_triggers(&mut game, &mut agents);
1382
1383        assert_eq!(game.card(evoked).zone, ZoneType::Battlefield);
1384        assert!(
1385            game.stack
1386                .iter()
1387                .any(|entry| entry.spell_ability.api
1388                    == Some(crate::ability::api_type::ApiType::Draw)),
1389            "ETB draw trigger should be on stack for an evoked creature"
1390        );
1391        assert!(
1392            game.stack.iter().any(|entry| entry.spell_ability.api
1393                == Some(crate::ability::api_type::ApiType::Sacrifice)),
1394            "Evoke sacrifice trigger should be on stack"
1395        );
1396    }
1397
1398    #[test]
1399    fn opening_hand_action_resolves_generic_keyword_effect() {
1400        let p0 = PlayerId(0);
1401        let p1 = PlayerId(1);
1402        let mut game = GameState::new(&["A", "B"], 20);
1403        game.turn.active_player = p0;
1404        game.turn.priority_player = p0;
1405
1406        let card_id = game.create_card(opening_hand_card(
1407            p0,
1408            "Opening Hand Test",
1409            "MayEffectFromOpeningHand:FromHand",
1410            "DB$ ChangeZone | Defined$ Self | Origin$ Hand | Destination$ Battlefield | SpellDescription$ Test opening hand action.",
1411        ));
1412        game.move_card(card_id, ZoneType::Hand, p0);
1413
1414        let mut agents: Vec<Box<dyn PlayerAgent>> = vec![
1415            Box::new(OpeningHandAgent { accept: true }),
1416            Box::new(OpeningHandAgent { accept: true }),
1417        ];
1418
1419        let mut game_loop = GameLoop::new(2);
1420        game_loop.run_opening_hand_actions(&mut game, &mut agents);
1421
1422        assert_eq!(game.card(card_id).zone, ZoneType::Battlefield);
1423        assert!(game
1424            .cards_in_zone(ZoneType::Battlefield, p0)
1425            .contains(&card_id));
1426        assert!(!game.cards_in_zone(ZoneType::Hand, p0).contains(&card_id));
1427        let _ = p1;
1428    }
1429
1430    #[test]
1431    fn opening_hand_action_respects_not_play_first_restriction() {
1432        let p0 = PlayerId(0);
1433        let p1 = PlayerId(1);
1434        let mut game = GameState::new(&["A", "B"], 20);
1435        game.turn.active_player = p0;
1436        game.turn.priority_player = p0;
1437
1438        let card_id = game.create_card(opening_hand_card(
1439            p0,
1440            "Opening Hand Skip Test",
1441            "MayEffectFromOpeningHand:FromHand:!PlayFirst",
1442            "DB$ ChangeZone | Defined$ Self | Origin$ Hand | Destination$ Battlefield | SpellDescription$ Test opening hand action.",
1443        ));
1444        game.move_card(card_id, ZoneType::Hand, p0);
1445
1446        let mut agents: Vec<Box<dyn PlayerAgent>> = vec![
1447            Box::new(OpeningHandAgent { accept: true }),
1448            Box::new(OpeningHandAgent { accept: true }),
1449        ];
1450
1451        let mut game_loop = GameLoop::new(2);
1452        game_loop.run_opening_hand_actions(&mut game, &mut agents);
1453
1454        assert_eq!(game.card(card_id).zone, ZoneType::Hand);
1455        assert!(game.cards_in_zone(ZoneType::Hand, p0).contains(&card_id));
1456        let _ = p1;
1457    }
1458}