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, 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
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(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 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 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 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 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 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 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 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 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 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 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 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 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 game.game_over = true;
647 break;
648 }
649 self.run_turn(game, agents, rng);
650 }
651
652 game.winner
653 }
654
655 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 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 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 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 apply_continuous_effects(game);
702 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
782fn 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 trigger_handler.flush_waiting_triggers(game);
798 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}