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
13use crate::ability::effects::{self, EffectContext};
15use crate::agent::{CombatCostAction, MainPhaseAction, ManaCostAction, PlayerAgent};
16use crate::card::Card;
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
35pub struct GameLoop {
39 pub mana_pools: Vec<ManaPool>,
40 pub combat: CombatState,
41 pub trigger_handler: TriggerHandler,
42 pub game_log: GameLog,
43 pub token_templates: HashMap<String, Card>,
46 pub token_art_variants: HashMap<(String, String), usize>,
52 pub token_fallback: HashMap<String, String>,
54 pub edition_dates: HashMap<String, String>,
56 pub game_rng: Box<dyn GameRng>,
60 pub experimental_restore_snapshot: bool,
62 previous_game_state: Option<GameSnapshot>,
64 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 mana_undo_stacks: Vec<Vec<ManaUndoRecord>>,
72 mana_undo_disqualified: bool,
73 pub abort_signal: Option<Arc<AtomicBool>>,
80 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 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 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 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 fn make_snapshot(&self, game: &GameState, include_stack: bool) -> GameSnapshot {
255 GameSnapshot::capture(
256 game,
257 &self.mana_pools,
258 &self.combat,
259 &self.trigger_handler,
260 include_stack,
261 )
262 }
263
264 pub fn restore_snapshot(&mut self, game: &mut GameState, snapshot: &GameSnapshot) {
266 snapshot.restore_game_state(
267 game,
268 &mut self.mana_pools,
269 &mut self.combat,
270 &mut self.trigger_handler,
271 );
272 }
273
274 pub fn stash_game_state(&mut self, game: &GameState) {
276 if self.experimental_restore_snapshot {
277 self.previous_game_state = Some(self.make_snapshot(game, true));
278 }
279 }
280
281 pub fn restore_game_state(&mut self, game: &mut GameState) -> bool {
283 if !self.experimental_restore_snapshot {
284 return false;
285 }
286 let Some(snapshot) = self.previous_game_state.as_ref() else {
287 return false;
288 };
289 crate::perf::increment(crate::perf::Metric::SnapshotClones, 1);
290 let snapshot = snapshot.clone();
291 self.restore_snapshot(game, &snapshot);
292 true
293 }
294
295 pub fn restore_checkpoint(&mut self, game: &mut GameState, checkpoint_id: u64) -> bool {
296 let Some((_, _, snapshot)) = self
297 .checkpoints
298 .iter()
299 .find(|(id, _, _)| *id == checkpoint_id)
300 else {
301 return false;
302 };
303 crate::perf::increment(crate::perf::Metric::SnapshotClones, 1);
304 let snapshot = snapshot.clone();
305 self.restore_snapshot(game, &snapshot);
306 true
307 }
308
309 fn record_checkpoint(&mut self, game: &GameState, include_stack: bool) -> (u64, String) {
310 let checkpoint_id = self.next_checkpoint_id;
311 self.next_checkpoint_id += 1;
312 let label = format!(
313 "Turn {} {}",
314 game.turn.turn_number,
315 game.turn.phase.script_name()
316 );
317 let snap = self.make_snapshot(game, include_stack);
318 self.checkpoints
319 .push_back((checkpoint_id, label.clone(), snap));
320 while self.checkpoints.len() > 256 {
321 self.checkpoints.pop_front();
322 }
323 (checkpoint_id, label)
324 }
325
326 pub(crate) fn apply_pending_snapshot_restore(
327 &mut self,
328 game: &mut GameState,
329 agents: &mut [Box<dyn PlayerAgent>],
330 ) -> bool {
331 let mut requested = None;
332 for agent in agents.iter_mut() {
333 if let Some(id) = agent.take_restore_request() {
334 requested = Some(id);
335 }
336 }
337 let Some(checkpoint_id) = requested else {
338 return false;
339 };
340 let restored = self.restore_checkpoint(game, checkpoint_id);
341 if restored {
342 for agent in agents.iter_mut() {
343 agent.snapshot_state(game, &self.mana_pools);
344 agent.notify(crate::agent::notification::GameNotification::StateChanged);
345 }
346 }
347 restored
348 }
349
350 pub fn get_tappable_lands(&self, game: &GameState, player: PlayerId) -> Vec<CardId> {
352 game.cards_in_zone(ZoneType::Battlefield, player)
353 .to_vec()
354 .into_iter()
355 .filter(|&cid| {
356 let c = game.card(cid);
357 c.is_land() && !c.tapped
358 })
359 .collect()
360 }
361
362 pub fn get_untappable_lands(
364 &self,
365 _game: &GameState,
366 player: PlayerId,
367 _pool_snapshot: &ManaPool,
368 ) -> Vec<CardId> {
369 self.undoable_mana_sources(player)
370 }
371
372 pub fn setup(
375 &mut self,
376 game: &mut GameState,
377 agents: &mut [Box<dyn PlayerAgent>],
378 rng: &mut impl rand::Rng,
379 ) {
380 self.roll_for_first_player(game, agents, rng);
381
382 for &pid in &game.player_order.clone() {
383 game.shuffle_library(pid, rng);
384 game.draw_cards(pid, 7);
385 }
386
387 let first_player = game.active_player();
388 crate::mulligan::run_london_mulligans(
389 game,
390 agents,
391 rng,
392 first_player,
393 &self.mana_pools,
394 &self.game_log,
395 );
396 }
397
398 pub fn roll_for_first_player(
406 &mut self,
407 game: &mut GameState,
408 agents: &mut [Box<dyn PlayerAgent>],
409 rng: &mut impl rand::Rng,
410 ) {
411 const SIDES: i32 = 20;
412 let players: Vec<PlayerId> = game.player_order.clone();
413 if players.len() < 2 {
414 return;
415 }
416
417 let mut contenders: Vec<PlayerId> = players.clone();
420 let (final_rolls, winner) = loop {
421 let rolls: Vec<(PlayerId, i32)> = contenders
422 .iter()
423 .map(|&pid| (pid, rng.gen_range(1..=SIDES)))
424 .collect();
425
426 for (pid, value) in &rolls {
427 let player_name = game.player(*pid).name.clone();
428 self.game_log.log(
429 GameLogEntryType::Info,
430 0,
431 format!("{player_name} rolls a {value} (d{SIDES})"),
432 );
433 }
434
435 let highest = rolls.iter().map(|(_, v)| *v).max().unwrap_or(0);
436 let top: Vec<PlayerId> = rolls
437 .iter()
438 .filter(|(_, v)| *v == highest)
439 .map(|(p, _)| *p)
440 .collect();
441 if top.len() == 1 {
442 break (rolls, top[0]);
443 }
444 self.game_log.log(
445 GameLogEntryType::Info,
446 0,
447 "Tie — rerolling among tied players".to_string(),
448 );
449 contenders = top;
450 };
451
452 let winner_name = game.player(winner).name.clone();
453 self.game_log.log(
454 GameLogEntryType::Info,
455 0,
456 format!("{winner_name} goes first"),
457 );
458
459 for agent in agents.iter_mut() {
460 agent.snapshot_state(game, &self.mana_pools);
461 }
462 crate::agent::game_log::broadcast_notification(
463 agents,
464 crate::agent::notification::GameNotification::FirstPlayerRoll {
465 sides: SIDES,
466 rolls: final_rolls,
467 winner,
468 },
469 );
470 for agent in agents.iter_mut() {
474 agent.await_display_ack();
475 }
476
477 game.turn.active_player = winner;
478 game.turn.priority_player = winner;
479 }
480
481 pub fn run_opening_hand_actions(
487 &mut self,
488 game: &mut GameState,
489 agents: &mut [Box<dyn PlayerAgent>],
490 ) {
491 let first_player = game.active_player();
492 let mut takes_action = first_player;
493 let mut new_first = first_player;
494
495 loop {
496 let usable = self.collect_opening_hand_actions(game, takes_action, first_player);
497 for mut sa in usable {
498 let Some(source_id) = sa.source else {
499 continue;
500 };
501 if game.card(source_id).zone != ZoneType::Hand {
502 continue;
503 }
504
505 agents[takes_action.index()].snapshot_state(game, &self.mana_pools);
506 let card_name = game.card(source_id).card_name.clone();
507 let prompt = sa
508 .ir
509 .spell_description_text
510 .as_deref()
511 .unwrap_or("Use opening hand effect?");
512 let accepted = agents[takes_action.index()].confirm_action(
513 takes_action,
514 Some("FromOpeningHand"),
515 prompt,
516 &[],
517 Some(source_id),
518 sa.api,
519 );
520 if !accepted {
521 continue;
522 }
523
524 if sa.uses_targeting() && !sa.setup_targets(game, agents, &self.mana_pools) {
525 continue;
526 }
527
528 let becomes_starting_player = sa.ir.become_starting_player;
529 let entry = StackEntry {
530 id: 0,
531 spell_ability: sa,
532 is_pending_cast: false,
533 is_creature_spell: false,
534 is_permanent_spell: false,
535 cast_from_zone: Some(ZoneType::Hand),
536 optional_trigger_decider: None,
537 optional_trigger_description: None,
538 optional_trigger_source_name: None,
539 };
540 self.resolve_spell_effect(game, agents, &entry);
541 apply_continuous_effects(game);
542
543 if becomes_starting_player {
544 new_first = takes_action;
545 }
546 }
547
548 takes_action = game.next_player(takes_action);
549 if takes_action == first_player {
550 break;
551 }
552 }
553
554 if new_first != first_player {
555 game.turn.active_player = new_first;
556 game.turn.priority_player = new_first;
557 }
558 }
559
560 fn collect_opening_hand_actions(
561 &self,
562 game: &GameState,
563 player: PlayerId,
564 first_player: PlayerId,
565 ) -> Vec<SpellAbility> {
566 let mut usable = Vec::new();
567
568 for &card_id in game.cards_in_zone(ZoneType::Hand, player) {
569 let card = game.card(card_id);
570 for kw in card.keywords.as_string_list() {
571 if !kw.starts_with("MayEffectFromOpeningHand") {
572 continue;
573 }
574 let split: Vec<&str> = kw.split(':').collect();
575 let Some(effect_name) = split.get(1).copied() else {
576 continue;
577 };
578 if split.get(2).copied() == Some("!PlayFirst") && first_player == player {
579 continue;
580 }
581 let Some(raw) = card.svars.get(effect_name) else {
582 continue;
583 };
584 usable.push(crate::spellability::build_spell_ability(
585 game, card_id, raw, player,
586 ));
587 }
588 }
589
590 usable
591 }
592
593 pub fn run(
596 &mut self,
597 game: &mut GameState,
598 agents: &mut [Box<dyn PlayerAgent>],
599 rng: &mut impl rand::Rng,
600 max_turns: u32,
601 ) -> Option<PlayerId> {
602 self.setup(game, agents, rng);
603 self.run_opening_hand_actions(game, agents);
604
605 self.trigger_handler.reset_active_triggers(game);
606 self.trigger_handler
607 .run_trigger(TriggerType::NewGame, RunParams::default(), true);
608
609 while !game.game_over && game.turn.turn_number <= max_turns {
610 if self.is_aborted() {
611 game.game_over = true;
615 break;
616 }
617 self.run_turn(game, agents, rng);
618 }
619
620 game.winner
621 }
622
623 pub fn run_turn(
625 &mut self,
626 game: &mut GameState,
627 agents: &mut [Box<dyn PlayerAgent>],
628 _rng: &mut impl rand::Rng,
629 ) {
630 let _perf_scope =
631 crate::perf::ParamsLookupScopeGuard::enter(crate::perf::ParamsLookupScope::GameLoop);
632 let active = game.active_player();
633 let active_name = game.player(active).name.clone();
634
635 if game.player(active).skip_turns > 0 {
637 game.player_decrement_skip_turns(active);
638 self.log_turn_skipped(game, active, game.player(active).skip_turns);
639 game.turn.next_player_turn(&game.player_order.clone());
641 return;
642 }
643
644 game.new_turn_for_player(active);
645 self.log_turn_begin(&active_name, game.turn.turn_number);
646
647 let turn_number = game.turn.turn_number;
649 for agent in agents.iter_mut() {
650 agent.snapshot_state(game, &self.mana_pools);
651 }
652 let (checkpoint_id, label) = self.record_checkpoint(game, true);
653 for agent in agents.iter_mut() {
654 agent.notify(
655 crate::agent::notification::GameNotification::SnapshotCreated {
656 checkpoint_id,
657 label: label.clone(),
658 },
659 );
660 }
661 for agent in agents.iter_mut() {
662 agent.notify(crate::agent::notification::GameNotification::TurnChanged {
663 active_player: active,
664 turn_number,
665 });
666 }
667
668 apply_continuous_effects(game);
670 self.trigger_handler.reset_active_triggers(game);
672
673 self.run_turn_state_machine(game, agents);
674 }
675
676 pub(crate) fn log_turn_begin(&self, player_name: &str, turn_number: u32) {
677 self.game_log.log(
678 GameLogEntryType::TurnBegin,
679 0,
680 format!("{player_name} turn begins (turn {turn_number})"),
681 );
682 }
683
684 pub(crate) fn log_turn_skipped(
685 &self,
686 game: &GameState,
687 player: PlayerId,
688 remaining_skip_turns: i32,
689 ) {
690 self.game_log.log(
691 GameLogEntryType::TurnSkip,
692 0,
693 format!(
694 "{} turn skipped (remaining skip-turn effects: {})",
695 game.player(player).name,
696 remaining_skip_turns
697 ),
698 );
699 }
700
701 pub(crate) fn log_phase_begin(&self, phase: PhaseType) {
702 self.game_log.log(
703 GameLogEntryType::PhaseBegin,
704 1,
705 format!("Phase {}", phase.script_name()),
706 );
707 }
708
709 pub(crate) fn log_waiting_for_priority(&self, game: &GameState, player: PlayerId) {
710 self.game_log.log(
711 GameLogEntryType::PriorityWaiting,
712 2,
713 format!("Waiting for {} priority response", game.player(player).name),
714 );
715 }
716
717 pub(crate) fn log_priority_response(&self, game: &GameState, player: PlayerId, action: &str) {
718 self.game_log.log(
719 GameLogEntryType::PriorityResponse,
720 2,
721 format!("{} responded with {}", game.player(player).name, action),
722 );
723 }
724
725 pub(crate) fn log_priority_pass(&self, game: &GameState, player: PlayerId) {
726 self.game_log.log(
727 GameLogEntryType::PriorityPass,
728 2,
729 format!("{} passed priority", game.player(player).name),
730 );
731 }
732
733 pub(crate) fn log_stack_push(&self, item_name: &str, player_name: &str) {
734 self.game_log.log(
735 GameLogEntryType::StackPush,
736 2,
737 format!("{item_name} pushed to stack ({player_name})"),
738 );
739 }
740
741 pub(crate) fn log_stack_resolved_item(&self, item_name: &str) {
742 self.game_log.log(
743 GameLogEntryType::StackResolve,
744 2,
745 format!("{item_name} resolved"),
746 );
747 }
748}
749
750fn check_sba(
754 game: &mut GameState,
755 trigger_handler: &mut TriggerHandler,
756 agents: &mut [Box<dyn PlayerAgent>],
757) -> bool {
758 let _perf_scope =
759 crate::perf::ParamsLookupScopeGuard::enter(crate::perf::ParamsLookupScope::PrioritySba);
760 let result = game.check_state_based_actions_with_trigger_agents(Some(trigger_handler), agents);
761 if result {
762 trigger_handler.flush_waiting_triggers(game);
766 trigger_handler.reset_active_triggers(game);
770 }
771 result
772}
773
774mod action_space;
775mod cast_spell;
776mod combat_phase;
777mod cost_payment;
778mod game_action;
779pub(crate) use game_action::{fire_sacrificed_once_for_batch, perform_sacrifice};
780pub(crate) mod mana_payment;
781mod phase_handler;
782mod playability;
783mod priority;
784mod stack_resolution;
785mod state_observer;
786mod trigger_handler;
787
788#[cfg(test)]
789mod tests {
790 use std::sync::atomic::{AtomicBool, Ordering};
791 use std::sync::{Arc, Mutex};
792
793 use forge_foundation::{CardTypeLine, ColorSet, ManaCost};
794 use rand::SeedableRng;
795
796 use crate::agent::{PlayCardMode, PlayerAgent, TargetChoice};
797 use crate::card::Card;
798 use crate::player::actions::PlayerAction;
799
800 use super::*;
801
802 struct RecordingPassAgent {
803 phases_seen: Arc<Mutex<Vec<PhaseType>>>,
804 bad_priority_seen: Arc<AtomicBool>,
805 last_phase: Option<PhaseType>,
806 last_priority: Option<PlayerId>,
807 }
808
809 struct InvalidPlayAgent;
810 struct OpeningHandAgent {
811 accept: bool,
812 }
813
814 impl PlayerAgent for InvalidPlayAgent {
815 fn mulligan_decision(
816 &mut self,
817 _player: PlayerId,
818 _hand: &[CardId],
819 _mulligan_count: u32,
820 ) -> bool {
821 true
822 }
823
824 fn choose_action(
825 &mut self,
826 player: PlayerId,
827 action_space: Option<&crate::agent::PriorityActionSpace>,
828 request_action_space: &mut dyn FnMut() -> crate::agent::PriorityActionSpace,
829 ) -> PlayerAction {
830 PlayerAction::CastSpell(crate::agent::PlayOption {
831 card_id: CardId(u32::MAX),
832 mode: PlayCardMode::Normal,
833 alt_cost_index: 0,
834 })
835 }
836
837 fn choose_attackers(
838 &mut self,
839 _player: PlayerId,
840 _available: &[CardId],
841 _possible_defenders: &[crate::combat::DefenderId],
842 ) -> Vec<(CardId, crate::combat::DefenderId)> {
843 Vec::new()
844 }
845
846 fn choose_blockers(
847 &mut self,
848 _player: PlayerId,
849 _attackers: &[CardId],
850 _available_blockers: &[CardId],
851 _max_blockers: Option<usize>,
852 ) -> Vec<(CardId, CardId)> {
853 Vec::new()
854 }
855
856 fn choose_target_player(
857 &mut self,
858 _player: PlayerId,
859 valid: &[PlayerId],
860 _sa: Option<&crate::spellability::SpellAbility>,
861 ) -> Option<PlayerId> {
862 valid.first().copied()
863 }
864
865 fn choose_target_card(
866 &mut self,
867 _player: PlayerId,
868 valid: &[CardId],
869 _sa: Option<&crate::spellability::SpellAbility>,
870 ) -> Option<CardId> {
871 valid.first().copied()
872 }
873
874 fn choose_target_any(
875 &mut self,
876 _player: PlayerId,
877 valid_players: &[PlayerId],
878 valid_cards: &[CardId],
879 _sa: Option<&crate::spellability::SpellAbility>,
880 ) -> TargetChoice {
881 if let Some(&pid) = valid_players.first() {
882 TargetChoice::Player(pid)
883 } else if let Some(&cid) = valid_cards.first() {
884 TargetChoice::Card(cid)
885 } else {
886 TargetChoice::None
887 }
888 }
889
890 fn choose_land_or_spell(&mut self, _player: PlayerId) -> Option<bool> {
891 None
892 }
893
894 fn choose_targets_for(
895 &mut self,
896 _sa: &mut crate::spellability::SpellAbility,
897 _game: &GameState,
898 _mana_pools: &[ManaPool],
899 ) -> bool {
900 false
901 }
902
903 fn notify(&mut self, _message: crate::agent::notification::GameNotification) {}
904 }
905
906 impl RecordingPassAgent {
907 fn new(
908 phases_seen: Arc<Mutex<Vec<PhaseType>>>,
909 bad_priority_seen: Arc<AtomicBool>,
910 ) -> Self {
911 Self {
912 phases_seen,
913 bad_priority_seen,
914 last_phase: None,
915 last_priority: None,
916 }
917 }
918 }
919
920 impl PlayerAgent for RecordingPassAgent {
921 fn snapshot_state(&mut self, game: &GameState, _mana_pools: &[ManaPool]) {
922 self.last_phase = Some(game.turn.phase);
923 self.last_priority = Some(game.turn.priority_player);
924 }
925
926 fn mulligan_decision(
927 &mut self,
928 _player: PlayerId,
929 _hand: &[CardId],
930 _mulligan_count: u32,
931 ) -> bool {
932 true
933 }
934
935 fn choose_action(
936 &mut self,
937 player: PlayerId,
938 action_space: Option<&crate::agent::PriorityActionSpace>,
939 request_action_space: &mut dyn FnMut() -> crate::agent::PriorityActionSpace,
940 ) -> PlayerAction {
941 if self.last_priority != Some(player) {
942 self.bad_priority_seen.store(true, Ordering::SeqCst);
943 }
944 if let Some(phase) = self.last_phase {
945 self.phases_seen.lock().unwrap().push(phase);
946 }
947 PlayerAction::PassPriority
948 }
949
950 fn choose_attackers(
951 &mut self,
952 _player: PlayerId,
953 _available: &[CardId],
954 _possible_defenders: &[crate::combat::DefenderId],
955 ) -> Vec<(CardId, crate::combat::DefenderId)> {
956 Vec::new()
957 }
958
959 fn choose_blockers(
960 &mut self,
961 _player: PlayerId,
962 _attackers: &[CardId],
963 _available_blockers: &[CardId],
964 _max_blockers: Option<usize>,
965 ) -> Vec<(CardId, CardId)> {
966 Vec::new()
967 }
968
969 fn choose_target_player(
970 &mut self,
971 _player: PlayerId,
972 valid: &[PlayerId],
973 _sa: Option<&crate::spellability::SpellAbility>,
974 ) -> Option<PlayerId> {
975 valid.first().copied()
976 }
977
978 fn choose_target_card(
979 &mut self,
980 _player: PlayerId,
981 valid: &[CardId],
982 _sa: Option<&crate::spellability::SpellAbility>,
983 ) -> Option<CardId> {
984 valid.first().copied()
985 }
986
987 fn choose_target_any(
988 &mut self,
989 _player: PlayerId,
990 valid_players: &[PlayerId],
991 valid_cards: &[CardId],
992 _sa: Option<&crate::spellability::SpellAbility>,
993 ) -> TargetChoice {
994 if let Some(&pid) = valid_players.first() {
995 TargetChoice::Player(pid)
996 } else if let Some(&cid) = valid_cards.first() {
997 TargetChoice::Card(cid)
998 } else {
999 TargetChoice::None
1000 }
1001 }
1002
1003 fn choose_land_or_spell(&mut self, _player: PlayerId) -> Option<bool> {
1004 None
1005 }
1006
1007 fn choose_targets_for(
1008 &mut self,
1009 _sa: &mut crate::spellability::SpellAbility,
1010 _game: &GameState,
1011 _mana_pools: &[ManaPool],
1012 ) -> bool {
1013 false
1014 }
1015
1016 fn notify(&mut self, _message: crate::agent::notification::GameNotification) {}
1017 }
1018
1019 impl PlayerAgent for OpeningHandAgent {
1020 fn mulligan_decision(
1021 &mut self,
1022 _player: PlayerId,
1023 _hand: &[CardId],
1024 _mulligan_count: u32,
1025 ) -> bool {
1026 true
1027 }
1028
1029 fn choose_action(
1030 &mut self,
1031 player: PlayerId,
1032 action_space: Option<&crate::agent::PriorityActionSpace>,
1033 request_action_space: &mut dyn FnMut() -> crate::agent::PriorityActionSpace,
1034 ) -> PlayerAction {
1035 PlayerAction::PassPriority
1036 }
1037
1038 fn choose_attackers(
1039 &mut self,
1040 _player: PlayerId,
1041 _available: &[CardId],
1042 _possible_defenders: &[crate::combat::DefenderId],
1043 ) -> Vec<(CardId, crate::combat::DefenderId)> {
1044 Vec::new()
1045 }
1046
1047 fn choose_blockers(
1048 &mut self,
1049 _player: PlayerId,
1050 _attackers: &[CardId],
1051 _available_blockers: &[CardId],
1052 _max_blockers: Option<usize>,
1053 ) -> Vec<(CardId, CardId)> {
1054 Vec::new()
1055 }
1056
1057 fn choose_target_player(
1058 &mut self,
1059 _player: PlayerId,
1060 valid: &[PlayerId],
1061 _sa: Option<&crate::spellability::SpellAbility>,
1062 ) -> Option<PlayerId> {
1063 valid.first().copied()
1064 }
1065
1066 fn choose_target_card(
1067 &mut self,
1068 _player: PlayerId,
1069 valid: &[CardId],
1070 _sa: Option<&crate::spellability::SpellAbility>,
1071 ) -> Option<CardId> {
1072 valid.first().copied()
1073 }
1074
1075 fn choose_target_any(
1076 &mut self,
1077 _player: PlayerId,
1078 valid_players: &[PlayerId],
1079 valid_cards: &[CardId],
1080 _sa: Option<&crate::spellability::SpellAbility>,
1081 ) -> TargetChoice {
1082 if let Some(&pid) = valid_players.first() {
1083 TargetChoice::Player(pid)
1084 } else if let Some(&cid) = valid_cards.first() {
1085 TargetChoice::Card(cid)
1086 } else {
1087 TargetChoice::None
1088 }
1089 }
1090
1091 fn choose_land_or_spell(&mut self, _player: PlayerId) -> Option<bool> {
1092 None
1093 }
1094
1095 fn confirm_action(
1096 &mut self,
1097 _player: PlayerId,
1098 _mode: Option<&str>,
1099 _message: &str,
1100 _options: &[String],
1101 _source: Option<crate::ids::CardId>,
1102 _api: Option<crate::ability::api_type::ApiType>,
1103 ) -> bool {
1104 self.accept
1105 }
1106
1107 fn choose_targets_for(
1108 &mut self,
1109 _sa: &mut crate::spellability::SpellAbility,
1110 _game: &GameState,
1111 _mana_pools: &[ManaPool],
1112 ) -> bool {
1113 false
1114 }
1115
1116 fn notify(&mut self, _message: crate::agent::notification::GameNotification) {}
1117 }
1118
1119 fn zero_cost_instant(owner: PlayerId) -> Card {
1120 Card::new(
1121 CardId(0),
1122 "Test Instant".to_string(),
1123 owner,
1124 CardTypeLine::parse("Instant"),
1125 ManaCost::no_cost(),
1126 ColorSet::COLORLESS,
1127 None,
1128 None,
1129 vec![],
1130 vec![],
1131 )
1132 }
1133
1134 fn mana_land(owner: PlayerId, name: &str, produced: &str) -> Card {
1135 Card::new(
1136 CardId(0),
1137 name.to_string(),
1138 owner,
1139 CardTypeLine::parse("Land"),
1140 ManaCost::no_cost(),
1141 ColorSet::COLORLESS,
1142 None,
1143 None,
1144 vec![],
1145 vec![format!(
1146 "AB$ Mana | Cost$ T | Produced$ {} | SpellDescription$ Add mana.",
1147 produced
1148 )],
1149 )
1150 }
1151
1152 fn vanilla_spell(owner: PlayerId, name: &str, cost: &str) -> Card {
1153 Card::new(
1154 CardId(0),
1155 name.to_string(),
1156 owner,
1157 CardTypeLine::parse("Sorcery"),
1158 ManaCost::parse(cost),
1159 ColorSet::COLORLESS,
1160 None,
1161 None,
1162 vec![],
1163 vec![],
1164 )
1165 }
1166
1167 fn evoked_etb_creature(owner: PlayerId) -> Card {
1168 let mut card = Card::new(
1169 CardId(0),
1170 "Mulldrifter Test".to_string(),
1171 owner,
1172 CardTypeLine::parse("Creature - Elemental"),
1173 ManaCost::parse("4 U"),
1174 ColorSet::BLUE,
1175 Some(2),
1176 Some(2),
1177 vec!["Evoke:2 U".to_string()],
1178 vec![],
1179 );
1180
1181 let mut next_trigger_id = 0;
1182 let etb_draw = crate::trigger::parse_trigger(
1183 "Mode$ ChangesZone | Destination$ Battlefield | ValidCard$ Card.Self | Execute$ TrigDraw | TriggerDescription$ When CARDNAME enters the battlefield, draw two cards.",
1184 &mut next_trigger_id,
1185 )
1186 .expect("valid ETB trigger");
1187 card.add_trigger(etb_draw);
1188 card.base_trigger_count = card.triggers.len();
1189 card.svars.insert(
1190 "TrigDraw".to_string(),
1191 "DB$ Draw | NumCards$ 2 | Defined$ You".to_string(),
1192 );
1193 card
1194 }
1195
1196 fn activated_permanent(
1197 owner: PlayerId,
1198 name: &str,
1199 type_line: &str,
1200 abilities: Vec<&str>,
1201 ) -> Card {
1202 Card::new(
1203 CardId(0),
1204 name.to_string(),
1205 owner,
1206 CardTypeLine::parse(type_line),
1207 ManaCost::no_cost(),
1208 ColorSet::COLORLESS,
1209 None,
1210 None,
1211 vec![],
1212 abilities.into_iter().map(|s| s.to_string()).collect(),
1213 )
1214 }
1215
1216 fn opening_hand_card(owner: PlayerId, name: &str, keyword: &str, svar_text: &str) -> Card {
1217 let mut card = Card::new(
1218 CardId(0),
1219 name.to_string(),
1220 owner,
1221 CardTypeLine::parse("Enchantment"),
1222 ManaCost::parse("2 W"),
1223 ColorSet::WHITE,
1224 None,
1225 None,
1226 vec![keyword.to_string()],
1227 vec![],
1228 );
1229 card.svars
1230 .insert("FromHand".to_string(), svar_text.to_string());
1231 card
1232 }
1233
1234 #[test]
1235 fn priority_round_ignores_illegal_actions() {
1236 let p0 = PlayerId(0);
1237 let p1 = PlayerId(1);
1238 let mut game = GameState::new(&["A", "B"], 20);
1239
1240 let c0 = game.create_card(zero_cost_instant(p0));
1241 let c1 = game.create_card(zero_cost_instant(p1));
1242 game.move_card(c0, ZoneType::Hand, p0);
1243 game.move_card(c1, ZoneType::Hand, p1);
1244
1245 game.turn.active_player = p0;
1246 game.turn.priority_player = p0;
1247 game.turn.phase = PhaseType::Upkeep;
1248
1249 let seen = Arc::new(Mutex::new(Vec::new()));
1250 let bad = Arc::new(AtomicBool::new(false));
1251 let mut agents: Vec<Box<dyn PlayerAgent>> = vec![
1252 Box::new(InvalidPlayAgent),
1253 Box::new(RecordingPassAgent::new(seen, bad)),
1254 ];
1255
1256 let mut game_loop = GameLoop::new(2);
1257 game_loop.priority_round(&mut game, &mut agents, false);
1258
1259 assert!(game.stack.is_empty());
1260 assert!(game.cards_in_zone(ZoneType::Hand, p0).contains(&c0));
1261 assert!(game.cards_in_zone(ZoneType::Hand, p1).contains(&c1));
1262 assert_eq!(game.turn.priority_player, game.active_player());
1263 }
1264
1265 #[test]
1266 fn action_space_excludes_nonland_mana_abilities_from_main_actions() {
1267 let p0 = PlayerId(0);
1268 let mut game = GameState::new(&["A", "B"], 20);
1269
1270 let goose = game.create_card(activated_permanent(
1271 p0,
1272 "Gilded Goose",
1273 "Creature - Bird",
1274 vec![
1275 "AB$ Token | Cost$ 1 G T | TokenScript$ c_a_food_sac | TokenOwner$ You | SpellDescription$ Create a Food Token.",
1276 "AB$ Mana | Cost$ T Sac<1/Food> | Produced$ Any | SpellDescription$ Add one mana of any color.",
1277 ],
1278 ));
1279 let food = game.create_card(activated_permanent(
1280 p0,
1281 "Food Token",
1282 "Artifact Food",
1283 vec!["AB$ GainLife | Cost$ 2 T Sac<1/CARDNAME> | LifeAmount$ 3 | SpellDescription$ You gain 3 life."],
1284 ));
1285 let forest = game.create_card(mana_land(p0, "Forest", "G"));
1286 let island = game.create_card(mana_land(p0, "Island", "U"));
1287
1288 for cid in [goose, food, forest, island] {
1289 game.move_card(cid, ZoneType::Battlefield, p0);
1290 game.card_mut(cid).summoning_sick = false;
1291 }
1292
1293 game.turn.turn_number = 20;
1294 game.turn.active_player = p0;
1295 game.turn.priority_player = p0;
1296 game.turn.phase = PhaseType::Main1;
1297
1298 let gl = GameLoop::new(2);
1299 let action_space = gl.action_space(&game, p0, true);
1300
1301 let has = |cid, idx| {
1302 action_space
1303 .activatable
1304 .iter()
1305 .any(|a| a.card_id == cid && a.ability_index == idx)
1306 };
1307 assert!(has(food, 0));
1308 assert!(has(goose, 0));
1309 assert!(!has(goose, 1));
1310 }
1311
1312 #[test]
1313 fn evoke_keeps_etb_triggers_when_spell_resolves() {
1314 let p0 = PlayerId(0);
1315 let _p1 = PlayerId(1);
1316 let mut game = GameState::new(&["A", "B"], 20);
1317
1318 let evoked = game.create_card(evoked_etb_creature(p0));
1319 game.move_card(evoked, ZoneType::Stack, p0);
1320
1321 let mut sa = SpellAbility::new_simple(Some(evoked), p0, "SP$ Permanent");
1322 sa.alt_cost = Some(crate::spellability::AlternativeCost::Evoke);
1323
1324 game.stack.push(StackEntry {
1325 id: 1,
1326 spell_ability: sa,
1327 is_pending_cast: false,
1328 is_creature_spell: true,
1329 is_permanent_spell: true,
1330 cast_from_zone: Some(ZoneType::Hand),
1331 optional_trigger_decider: None,
1332 optional_trigger_description: None,
1333 optional_trigger_source_name: None,
1334 });
1335
1336 let mut gl = GameLoop::new(2);
1337 let mut agents: Vec<Box<dyn PlayerAgent>> = vec![
1338 Box::new(RecordingPassAgent::new(
1339 Arc::new(Mutex::new(Vec::new())),
1340 Arc::new(AtomicBool::new(false)),
1341 )),
1342 Box::new(RecordingPassAgent::new(
1343 Arc::new(Mutex::new(Vec::new())),
1344 Arc::new(AtomicBool::new(false)),
1345 )),
1346 ];
1347
1348 gl.resolve_stack(&mut game, &mut agents);
1349 gl.process_triggers(&mut game, &mut agents);
1350
1351 assert_eq!(game.card(evoked).zone, ZoneType::Battlefield);
1352 assert!(
1353 game.stack
1354 .iter()
1355 .any(|entry| entry.spell_ability.api
1356 == Some(crate::ability::api_type::ApiType::Draw)),
1357 "ETB draw trigger should be on stack for an evoked creature"
1358 );
1359 assert!(
1360 game.stack.iter().any(|entry| entry.spell_ability.api
1361 == Some(crate::ability::api_type::ApiType::Sacrifice)),
1362 "Evoke sacrifice trigger should be on stack"
1363 );
1364 }
1365
1366 #[test]
1367 fn opening_hand_action_resolves_generic_keyword_effect() {
1368 let p0 = PlayerId(0);
1369 let p1 = PlayerId(1);
1370 let mut game = GameState::new(&["A", "B"], 20);
1371 game.turn.active_player = p0;
1372 game.turn.priority_player = p0;
1373
1374 let card_id = game.create_card(opening_hand_card(
1375 p0,
1376 "Opening Hand Test",
1377 "MayEffectFromOpeningHand:FromHand",
1378 "DB$ ChangeZone | Defined$ Self | Origin$ Hand | Destination$ Battlefield | SpellDescription$ Test opening hand action.",
1379 ));
1380 game.move_card(card_id, ZoneType::Hand, p0);
1381
1382 let mut agents: Vec<Box<dyn PlayerAgent>> = vec![
1383 Box::new(OpeningHandAgent { accept: true }),
1384 Box::new(OpeningHandAgent { accept: true }),
1385 ];
1386
1387 let mut game_loop = GameLoop::new(2);
1388 game_loop.run_opening_hand_actions(&mut game, &mut agents);
1389
1390 assert_eq!(game.card(card_id).zone, ZoneType::Battlefield);
1391 assert!(game
1392 .cards_in_zone(ZoneType::Battlefield, p0)
1393 .contains(&card_id));
1394 assert!(!game.cards_in_zone(ZoneType::Hand, p0).contains(&card_id));
1395 let _ = p1;
1396 }
1397
1398 #[test]
1399 fn opening_hand_action_respects_not_play_first_restriction() {
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 Skip Test",
1409 "MayEffectFromOpeningHand:FromHand:!PlayFirst",
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::Hand);
1423 assert!(game.cards_in_zone(ZoneType::Hand, p0).contains(&card_id));
1424 let _ = p1;
1425 }
1426}