Skip to main content

manabrew_agent_interface/agent_impl/
mod.rs

1use forge_foundation::{ManaAtom, ZoneType};
2use manabrew_engine::agent::notification::GameNotification;
3use manabrew_engine::agent::{
4    BinaryChoiceKind, CombatCostAction, GameEntity, ManaCostAction, PlayOption, PlayerAgent,
5    PriorityActionSpace, RollSwapChoice, TargetChoice,
6};
7use manabrew_engine::card::CounterType;
8use manabrew_engine::combat::DefenderId;
9use manabrew_engine::game::GameState;
10use manabrew_engine::ids::{CardId, PlayerId};
11use manabrew_engine::mana::ManaPool;
12use manabrew_engine::player::actions::player_action::AbilityRef;
13use manabrew_engine::player::actions::PlayerAction as EnginePlayerAction;
14
15use crate::game_log_event::GameLogEntryDto;
16use crate::game_snapshot_event::GameSnapshotEventDto;
17use crate::game_view_dto::{GameViewDto, GameViewDtoExt};
18use crate::ids_codec::{card_id_str, parse_card_id, parse_player_id, player_id_str};
19use crate::mana_action_id::{mana_ability_actions, parse_tap_action_id};
20use crate::prompt::*;
21
22mod choices;
23mod combat;
24mod costs;
25mod library;
26mod targeting;
27
28/// Match a mana symbol letter (e.g. "U") or a full color name (e.g. "Blue")
29/// against a list of color strings.  Handles the Blue/U mismatch where the
30/// mana symbol "U" doesn't match the first character of "Blue".
31pub(crate) fn find_matching_color<'a>(
32    pending: &str,
33    colors: impl Iterator<Item = &'a String>,
34) -> Option<String> {
35    let mana_to_name: &[(&str, &str)] = &[
36        ("W", "White"),
37        ("U", "Blue"),
38        ("B", "Black"),
39        ("R", "Red"),
40        ("G", "Green"),
41        ("C", "Colorless"),
42    ];
43    colors
44        .into_iter()
45        .find(|c| {
46            // Direct case-insensitive match (covers both "U"=="U" and "Blue"=="Blue")
47            c.eq_ignore_ascii_case(pending)
48            // Mana symbol → full name lookup (covers "U" matching "Blue")
49            || mana_to_name.iter().any(|(sym, name)| {
50                pending.eq_ignore_ascii_case(sym) && c.eq_ignore_ascii_case(name)
51            })
52            // Full name → mana symbol lookup (covers "Blue" matching "U")
53            || mana_to_name.iter().any(|(sym, name)| {
54                pending.eq_ignore_ascii_case(name) && c.eq_ignore_ascii_case(sym)
55            })
56        })
57        .cloned()
58}
59
60pub(crate) fn parse_express_mana_choice(color: Option<&str>) -> Option<u16> {
61    color
62        .map(|color| ManaAtom::from_name(&color.to_ascii_lowercase()))
63        .filter(|&atom| atom != 0)
64}
65
66/// Answers the prompts a `PromptAgent` builds.
67///
68pub trait Responder {
69    fn respond(&mut self, prompt: AgentPrompt) -> ClientToServerMessage;
70    fn present(&mut self, _message: &AgentMessage) {}
71    fn await_ack(&mut self) -> ClientToServerMessage {
72        ClientToServerMessage::Response {
73            action: PromptOutput::DiceRolled(DiceRolledOutput::DiceRolledAcknowledged),
74        }
75    }
76    fn send_log(&mut self, _entry: GameLogEntryDto) {}
77    fn send_snapshot(&mut self, _snapshot: GameSnapshotEventDto) {}
78}
79
80pub struct PromptAgent<R: Responder> {
81    pub player_id: PlayerId,
82    pub game_id: String,
83    pub responder: R,
84    pending_prompt: Option<AgentPrompt>,
85    pub(crate) latest_view: Option<GameViewDto>,
86    pub(crate) pending_restore_checkpoint: Option<u64>,
87    pub pass_until: Option<manabrew_engine::agent::PassUntilTarget>,
88    conceded: bool,
89    next_prompt_id: u32,
90}
91
92impl<R: Responder> PromptAgent<R> {
93    pub fn new(player_id: PlayerId, game_id: String, responder: R) -> Self {
94        Self {
95            player_id,
96            game_id,
97            responder,
98            pending_prompt: None,
99            latest_view: None,
100            pending_restore_checkpoint: None,
101            pass_until: None,
102            conceded: false,
103            next_prompt_id: 0,
104        }
105    }
106
107    fn build_prompt(&mut self, inner: PromptInput, source: Option<CardId>) -> AgentPrompt {
108        self.next_prompt_id += 1;
109        AgentPrompt {
110            prompt_id: self.next_prompt_id,
111            deciding_player_id: player_id_str(self.player_id),
112            source_card_id: source.map(card_id_str),
113            input: inner,
114        }
115    }
116
117    pub(crate) fn send_prompt(&mut self, inner: PromptInput, source: Option<CardId>) {
118        let prompt = self.build_prompt(inner, source);
119        self.emit_state();
120        self.responder
121            .present(&AgentMessage::Prompt(prompt.clone()));
122        self.pending_prompt = Some(prompt);
123    }
124
125    pub(crate) fn recv_action(&mut self) -> PromptOutput {
126        let prompt = self
127            .pending_prompt
128            .take()
129            .expect("recv_action called without a pending prompt");
130        if self.conceded {
131            return PromptOutput::ChooseAction(ChooseActionOutput::Pass { until: None });
132        }
133        match self.responder.respond(prompt) {
134            ClientToServerMessage::Response { action } => action,
135            ClientToServerMessage::Directive { directive } => {
136                self.handle_directive(directive);
137                PromptOutput::ChooseAction(ChooseActionOutput::Pass { until: None })
138            }
139        }
140    }
141
142    fn handle_directive(&mut self, directive: DirectiveInput) {
143        match directive {
144            DirectiveInput::Concede => self.conceded = true,
145        }
146    }
147
148    pub(crate) fn present_prompt(&mut self, inner: PromptInput, source: Option<CardId>) {
149        let prompt = self.build_prompt(inner, source);
150        self.emit_state();
151        self.responder.present(&AgentMessage::Prompt(prompt));
152    }
153
154    pub(crate) fn emit_state(&mut self) {
155        let game_view = self.view();
156        self.responder
157            .present(&AgentMessage::State(StateUpdate { game_view }));
158    }
159
160    pub(crate) fn emit_display(&mut self, event: DisplayEvent) {
161        self.responder.present(&AgentMessage::Display(event));
162    }
163
164    pub(crate) fn view(&self) -> GameViewDto {
165        self.latest_view.clone().unwrap_or_else(|| {
166            // Fallback: empty view
167            GameViewDto::empty(self.game_id.clone())
168        })
169    }
170
171    pub(crate) fn card_ids(cards: &[CardId]) -> Vec<String> {
172        cards.iter().map(|&c| card_id_str(c)).collect()
173    }
174
175    pub(crate) fn player_ids(players: &[PlayerId]) -> Vec<String> {
176        players.iter().map(|&p| player_id_str(p)).collect()
177    }
178
179    pub(crate) fn attack_targets_to_dtos(
180        defenders: &[DefenderId],
181    ) -> Vec<crate::prompt::AttackTargetDto> {
182        use crate::prompt::AttackTargetKind;
183        defenders
184            .iter()
185            .map(|d| match d {
186                DefenderId::Player(pid) => crate::prompt::AttackTargetDto {
187                    id: format!("player-{}", pid.0),
188                    label: format!("Player {}", pid.0),
189                    kind: AttackTargetKind::Player,
190                },
191                // The Rust engine can't distinguish a planeswalker from a
192                // battle yet; approximate any permanent target as a walker.
193                DefenderId::Permanent(cid) => crate::prompt::AttackTargetDto {
194                    id: format!("card-{}", cid.0),
195                    label: format!("Permanent {}", cid.0),
196                    kind: AttackTargetKind::Planeswalker,
197                },
198            })
199            .collect()
200    }
201
202    fn play_option_to_dto(play: &PlayOption) -> PlayOptionDto {
203        use manabrew_engine::agent::PlayCardMode;
204        let card_id = card_id_str(play.card_id);
205        let (mode, mode_label) = match &play.mode {
206            PlayCardMode::Normal => ("normal".to_string(), "Cast normally".to_string()),
207            PlayCardMode::BackFaceLand => (
208                "backFaceLand".to_string(),
209                "Play back face as land".to_string(),
210            ),
211            PlayCardMode::Alternative(alt) => {
212                let name = format!("{:?}", alt);
213                (
214                    format!("alternative:{}", name.to_lowercase()),
215                    format!("Cast with {}", name),
216                )
217            }
218            PlayCardMode::StaticAlternative => (
219                "staticAlternative".to_string(),
220                "Cast with alternative cost".to_string(),
221            ),
222            PlayCardMode::ForetellExile => (
223                "foretellExile".to_string(),
224                "Foretell (exile face-down)".to_string(),
225            ),
226            PlayCardMode::UnlockDoor => ("unlockDoor".to_string(), "Unlock door".to_string()),
227            PlayCardMode::RoomRightSplit => {
228                ("roomRightSplit".to_string(), "Cast right room".to_string())
229            }
230        };
231        PlayOptionDto {
232            card_id,
233            mode,
234            mode_label,
235        }
236    }
237
238    fn parse_play_mode(mode_str: &str) -> Option<manabrew_engine::agent::PlayCardMode> {
239        use manabrew_engine::agent::PlayCardMode;
240        use manabrew_engine::spellability::AlternativeCost;
241        match mode_str {
242            "normal" => Some(PlayCardMode::Normal),
243            "backFaceLand" => Some(PlayCardMode::BackFaceLand),
244            "staticAlternative" => Some(PlayCardMode::StaticAlternative),
245            "foretellExile" => Some(PlayCardMode::ForetellExile),
246            "unlockDoor" => Some(PlayCardMode::UnlockDoor),
247            "roomRightSplit" => Some(PlayCardMode::RoomRightSplit),
248            s if s.starts_with("alternative:") => {
249                let alt_name = &s["alternative:".len()..];
250                let alt = match alt_name {
251                    "flashback" => AlternativeCost::Flashback,
252                    "evoke" => AlternativeCost::Evoke,
253                    "dash" => AlternativeCost::Dash,
254                    "escape" => AlternativeCost::Escape,
255                    "bestow" => AlternativeCost::Bestow,
256                    "madness" => AlternativeCost::Madness,
257                    "overload" => AlternativeCost::Overload,
258                    "spectacle" => AlternativeCost::Spectacle,
259                    "emerge" => AlternativeCost::Emerge,
260                    "blitz" => AlternativeCost::Blitz,
261                    "foretell" => AlternativeCost::Foretell,
262                    "suspend" => AlternativeCost::Suspend,
263                    _ => return None,
264                };
265                Some(PlayCardMode::Alternative(alt))
266            }
267            _ => None,
268        }
269    }
270
271    pub(crate) fn parse_defender_id(id: &str, possible: &[DefenderId]) -> Option<DefenderId> {
272        if let Some(rest) = id.strip_prefix("player-") {
273            let idx: u32 = rest.parse().ok()?;
274            possible
275                .iter()
276                .find(|d| matches!(d, DefenderId::Player(p) if p.0 == idx))
277                .copied()
278        } else if let Some(rest) = id.strip_prefix("card-") {
279            let idx: u32 = rest.parse().ok()?;
280            possible
281                .iter()
282                .find(|d| matches!(d, DefenderId::Permanent(c) if c.0 == idx))
283                .copied()
284        } else {
285            None
286        }
287    }
288
289    pub(crate) fn recv_card_choice_or_first(&mut self, valid: &[CardId]) -> Option<CardId> {
290        match self.recv_action() {
291            PromptOutput::ChooseBoardTargets(ChooseBoardTargetsOutput::BoardTargets { chosen }) => {
292                chosen.into_iter().find_map(|r| match r.kind {
293                    TargetKind::Card => parse_card_id(&r.id),
294                    _ => None,
295                })
296            }
297            _ => valid.first().copied(),
298        }
299    }
300
301    pub(crate) fn recv_player_choice_or_first(&mut self, valid: &[PlayerId]) -> Option<PlayerId> {
302        match self.recv_action() {
303            PromptOutput::ChooseBoardTargets(ChooseBoardTargetsOutput::BoardTargets { chosen }) => {
304                chosen.into_iter().find_map(|r| match r.kind {
305                    TargetKind::Player => parse_player_id(&r.id),
306                    _ => None,
307                })
308            }
309            _ => valid.first().copied(),
310        }
311    }
312
313    pub(crate) fn recv_spell_choice_or_first(&mut self, valid: &[u32]) -> Option<u32> {
314        match self.recv_action() {
315            PromptOutput::ChooseBoardTargets(ChooseBoardTargetsOutput::BoardTargets { chosen }) => {
316                chosen.into_iter().find_map(|r| match r.kind {
317                    TargetKind::Spell => crate::ids_codec::parse_stack_id(&r.id),
318                    _ => None,
319                })
320            }
321            _ => valid.first().copied(),
322        }
323    }
324}
325
326impl<R: Responder> PlayerAgent for PromptAgent<R> {
327    fn choose_targets_for(
328        &mut self,
329        sa: &mut manabrew_engine::spellability::SpellAbility,
330        game: &GameState,
331        mana_pools: &[ManaPool],
332    ) -> bool {
333        manabrew_engine::spellability::choose_targets_by_kind(self, sa, game, mana_pools)
334    }
335
336    fn get_pass_until(&self) -> Option<manabrew_engine::agent::PassUntilTarget> {
337        self.pass_until
338    }
339
340    fn clear_pass_until(&mut self) {
341        self.pass_until = None;
342    }
343
344    fn snapshot_state(&mut self, game: &GameState, mana_pools: &[ManaPool]) {
345        self.latest_view = Some(GameViewDto::from_engine(
346            game,
347            mana_pools,
348            self.player_id,
349            &self.game_id,
350        ));
351    }
352
353    fn mulligan_decision(
354        &mut self,
355        player: PlayerId,
356        hand: &[CardId],
357        mulligan_count: u32,
358    ) -> bool {
359        choices::mulligan_decision(self, player, hand, mulligan_count)
360    }
361
362    fn mulligan_decision_send(&mut self, player: PlayerId, hand: &[CardId], mulligan_count: u32) {
363        choices::mulligan_decision_send(self, player, hand, mulligan_count);
364    }
365
366    fn mulligan_decision_recv(
367        &mut self,
368        player: PlayerId,
369        hand: &[CardId],
370        mulligan_count: u32,
371    ) -> bool {
372        choices::mulligan_decision_recv(self, player, hand, mulligan_count)
373    }
374
375    fn choose_cards_to_bottom(
376        &mut self,
377        player: PlayerId,
378        hand: &[CardId],
379        count: usize,
380    ) -> Vec<CardId> {
381        choices::choose_cards_to_bottom(self, player, hand, count)
382    }
383
384    fn choose_cards_to_bottom_send(&mut self, player: PlayerId, hand: &[CardId], count: usize) {
385        choices::choose_cards_to_bottom_send(self, player, hand, count);
386    }
387
388    fn choose_cards_to_bottom_recv(
389        &mut self,
390        player: PlayerId,
391        hand: &[CardId],
392        count: usize,
393    ) -> Vec<CardId> {
394        choices::choose_cards_to_bottom_recv(self, player, hand, count)
395    }
396
397    fn choose_action(
398        &mut self,
399        _player: PlayerId,
400        action_space: Option<&PriorityActionSpace>,
401        request_action_space: &mut dyn FnMut() -> PriorityActionSpace,
402    ) -> EnginePlayerAction {
403        if self.conceded {
404            return EnginePlayerAction::Concede;
405        }
406        let requested_action_space;
407        let action_space = match action_space {
408            Some(action_space) => action_space,
409            None => {
410                requested_action_space = request_action_space();
411                &requested_action_space
412            }
413        };
414        let playable = &action_space.playable;
415        let untappable_lands = &action_space.untappable_lands;
416        let _activatable = &action_space.activatable;
417        let playable_options: Vec<PlayOptionDto> = playable
418            .iter()
419            .map(|play| Self::play_option_to_dto(play))
420            .collect();
421        let untappable_land_ids: Vec<String> =
422            untappable_lands.iter().map(|&c| card_id_str(c)).collect();
423
424        let mut actions: Vec<AvailableAction> = Vec::new();
425        for (play, opt) in playable.iter().zip(playable_options.iter()) {
426            let card_id = card_id_str(play.card_id);
427            actions.push(AvailableAction {
428                id: format!("cast:{card_id}:{}", opt.mode),
429                kind: AvailableActionKind::Cast {
430                    card_id: card_id.clone(),
431                    mode: opt.mode.clone(),
432                    mode_label: opt.mode_label.clone(),
433                },
434            });
435        }
436        for a in action_space
437            .activatable
438            .iter()
439            .chain(action_space.mana_abilities.iter())
440        {
441            let card_id = card_id_str(a.card_id);
442            if a.is_mana_ability {
443                actions.extend(mana_ability_actions(
444                    &card_id,
445                    a.ability_index,
446                    &a.description,
447                    a.cost.clone(),
448                    a.produced_mana.clone(),
449                    a.produced_mana_amount,
450                ));
451            } else {
452                actions.push(AvailableAction {
453                    id: format!("ability:{card_id}:{}", a.ability_index),
454                    kind: AvailableActionKind::ActivateAbility(ActivatableAbilityInfo {
455                        card_id,
456                        ability_index: a.ability_index,
457                        description: a.description.clone(),
458                        cost: a.cost.clone(),
459                        is_mana_ability: false,
460                        produced_mana: None,
461                    }),
462                });
463            }
464        }
465        for card_id in &untappable_land_ids {
466            actions.push(AvailableAction {
467                id: format!("untap:{card_id}"),
468                kind: AvailableActionKind::UndoMana {
469                    card_id: card_id.clone(),
470                },
471            });
472        }
473
474        self.send_prompt(
475            PromptInput::ChooseAction(
476                manabrew_protocol::prompts::choose_action::ChooseActionInput { actions },
477            ),
478            None,
479        );
480        let prompt = self
481            .pending_prompt
482            .take()
483            .expect("choose_action called without a pending prompt");
484        let action = match self.responder.respond(prompt) {
485            ClientToServerMessage::Response { action } => action,
486            ClientToServerMessage::Directive {
487                directive: DirectiveInput::Concede,
488            } => return EnginePlayerAction::Concede,
489        };
490        match action {
491            PromptOutput::ChooseAction(ChooseActionOutput::Act { action_id }) => {
492                if let Some(rest) = action_id.strip_prefix("cast:") {
493                    let (id_part, mode) = rest.split_once(':').unwrap_or((rest, "normal"));
494                    let resolved = parse_card_id(id_part).and_then(|cid| {
495                        Self::parse_play_mode(mode)
496                            .and_then(|m| {
497                                playable
498                                    .iter()
499                                    .copied()
500                                    .find(|play| play.card_id == cid && play.mode == m)
501                            })
502                            .or_else(|| playable.iter().copied().find(|play| play.card_id == cid))
503                    });
504                    resolved
505                        .map(EnginePlayerAction::CastSpell)
506                        .unwrap_or(EnginePlayerAction::PassPriority)
507                } else if let Some(rest) = action_id.strip_prefix("tap:") {
508                    let tap = parse_tap_action_id(rest);
509                    match parse_card_id(tap.card_id) {
510                        Some(cid) => EnginePlayerAction::ActivateMana(
511                            cid,
512                            tap.ability_index,
513                            parse_express_mana_choice(tap.color),
514                        ),
515                        None => EnginePlayerAction::PassPriority,
516                    }
517                } else if let Some(rest) = action_id.strip_prefix("ability:") {
518                    let (id_part, idx) = rest.split_once(':').unwrap_or((rest, ""));
519                    match (parse_card_id(id_part), idx.parse::<usize>()) {
520                        (Some(cid), Ok(ability_index)) => {
521                            EnginePlayerAction::ActivateAbility(AbilityRef {
522                                card_id: cid,
523                                ability_index,
524                            })
525                        }
526                        _ => EnginePlayerAction::PassPriority,
527                    }
528                } else if let Some(id_part) = action_id.strip_prefix("untap:") {
529                    parse_card_id(id_part)
530                        .map(EnginePlayerAction::UndoMana)
531                        .unwrap_or(EnginePlayerAction::PassPriority)
532                } else {
533                    EnginePlayerAction::PassPriority
534                }
535            }
536            PromptOutput::ChooseAction(ChooseActionOutput::Pass { until }) => {
537                self.pass_until = until.and_then(|u| {
538                    Some(manabrew_engine::agent::PassUntilTarget {
539                        player: crate::ids_codec::parse_player_id(&u.player_id)?,
540                        phase: forge_foundation::PhaseType::from_step_string(&u.phase)?,
541                    })
542                });
543                EnginePlayerAction::PassPriority
544            }
545            PromptOutput::ChooseAction(ChooseActionOutput::RestoreSnapshot { checkpoint_id }) => {
546                self.pending_restore_checkpoint = Some(checkpoint_id);
547                EnginePlayerAction::PassPriority
548            }
549            _ => EnginePlayerAction::PassPriority,
550        }
551    }
552
553    fn choose_attackers(
554        &mut self,
555        player: PlayerId,
556        available: &[CardId],
557        possible_defenders: &[DefenderId],
558    ) -> Vec<(CardId, DefenderId)> {
559        combat::choose_attackers(self, player, available, possible_defenders)
560    }
561
562    fn choose_blockers(
563        &mut self,
564        player: PlayerId,
565        attackers: &[CardId],
566        available_blockers: &[CardId],
567        max_blockers: Option<usize>,
568    ) -> Vec<(CardId, CardId)> {
569        combat::choose_blockers(self, player, attackers, available_blockers, max_blockers)
570    }
571
572    fn choose_damage_assignment_order(
573        &mut self,
574        player: PlayerId,
575        attacker: CardId,
576        blockers: &[CardId],
577    ) -> Vec<CardId> {
578        combat::choose_damage_assignment_order(self, player, attacker, blockers)
579    }
580
581    fn assign_combat_damage(
582        &mut self,
583        game: &GameState,
584        player: PlayerId,
585        attacker: CardId,
586        blockers_in_order: &[CardId],
587        defender_id: Option<DefenderId>,
588        damage_to_assign: i32,
589    ) -> Vec<(Option<CardId>, i32)> {
590        let attacker_has_deathtouch = game.card(attacker).has_deathtouch();
591        combat::choose_combat_damage_assignment(
592            self,
593            player,
594            attacker,
595            blockers_in_order,
596            defender_id,
597            damage_to_assign,
598            attacker_has_deathtouch,
599        )
600    }
601
602    fn choose_target_player(
603        &mut self,
604        player: PlayerId,
605        valid: &[PlayerId],
606        sa: Option<&manabrew_engine::spellability::SpellAbility>,
607    ) -> Option<PlayerId> {
608        let source = sa.and_then(|s| s.source);
609        let intent = sa
610            .map(crate::game_view_dto::targeting_intent_of)
611            .unwrap_or(crate::game_view_dto::TargetingIntent::Hostile);
612        let hostile = intent.is_hostile();
613        targeting::choose_target_player(self, player, valid, source, hostile, intent)
614    }
615
616    fn choose_target_card(
617        &mut self,
618        player: PlayerId,
619        valid: &[CardId],
620        sa: Option<&manabrew_engine::spellability::SpellAbility>,
621    ) -> Option<CardId> {
622        let source = sa.and_then(|s| s.source);
623        let intent = sa
624            .map(crate::game_view_dto::targeting_intent_of)
625            .unwrap_or(crate::game_view_dto::TargetingIntent::Hostile);
626        let hostile = intent.is_hostile();
627        targeting::choose_target_card(self, player, valid, source, hostile, intent)
628    }
629
630    fn choose_target_card_from_zone(
631        &mut self,
632        player: PlayerId,
633        zone: ZoneType,
634        valid: &[CardId],
635        sa: Option<&manabrew_engine::spellability::SpellAbility>,
636    ) -> Option<CardId> {
637        let source = sa.and_then(|s| s.source);
638        let intent = sa
639            .map(crate::game_view_dto::targeting_intent_of)
640            .unwrap_or(crate::game_view_dto::TargetingIntent::Hostile);
641        let hostile = intent.is_hostile();
642        targeting::choose_target_card_from_zone(self, player, zone, valid, source, hostile, intent)
643    }
644
645    fn choose_target_any(
646        &mut self,
647        player: PlayerId,
648        valid_players: &[PlayerId],
649        valid_cards: &[CardId],
650        sa: Option<&manabrew_engine::spellability::SpellAbility>,
651    ) -> TargetChoice {
652        let source = sa.and_then(|s| s.source);
653        let intent = sa
654            .map(crate::game_view_dto::targeting_intent_of)
655            .unwrap_or(crate::game_view_dto::TargetingIntent::Hostile);
656        let hostile = intent.is_hostile();
657        targeting::choose_target_any(
658            self,
659            player,
660            valid_players,
661            valid_cards,
662            source,
663            hostile,
664            intent,
665        )
666    }
667
668    fn choose_sacrifice(
669        &mut self,
670        player: PlayerId,
671        valid: &[CardId],
672        source: Option<CardId>,
673    ) -> Option<CardId> {
674        targeting::choose_sacrifice(self, player, valid, source)
675    }
676
677    fn reveal_cards(
678        &mut self,
679        game: &GameState,
680        _player: PlayerId,
681        cards: &[CardId],
682        zone: ZoneType,
683        owner: PlayerId,
684        message_prefix: Option<&str>,
685    ) {
686        choices::reveal_cards(self, game, cards, zone, owner, message_prefix)
687    }
688
689    fn choose_scry(
690        &mut self,
691        game: &GameState,
692        player: PlayerId,
693        source: Option<CardId>,
694        cards: &[CardId],
695    ) -> Vec<Vec<CardId>> {
696        library::choose_scry(self, game, player, source, cards)
697    }
698
699    fn choose_surveil(
700        &mut self,
701        game: &GameState,
702        player: PlayerId,
703        source: Option<CardId>,
704        cards: &[CardId],
705    ) -> Vec<Vec<CardId>> {
706        library::choose_surveil(self, game, player, source, cards)
707    }
708
709    fn choose_dig(
710        &mut self,
711        game: &GameState,
712        player: PlayerId,
713        valid: &[CardId],
714        max: usize,
715        optional: bool,
716    ) -> Vec<CardId> {
717        library::choose_dig(self, game, player, valid, max, optional)
718    }
719
720    fn choose_discard(&mut self, player: PlayerId, hand: &[CardId], num: usize) -> Vec<CardId> {
721        choices::choose_discard(self, player, hand, num)
722    }
723
724    fn choose_discard_any_number(
725        &mut self,
726        player: PlayerId,
727        hand: &[CardId],
728        min: usize,
729        max: usize,
730    ) -> Vec<CardId> {
731        choices::choose_discard_any_number(self, player, hand, min, max)
732    }
733
734    fn choose_legend_keep(&mut self, player: PlayerId, duplicates: &[CardId]) -> CardId {
735        choices::choose_legend_keep(self, player, duplicates)
736    }
737
738    fn choose_target_spell(
739        &mut self,
740        player: PlayerId,
741        valid: &[u32],
742        source: Option<CardId>,
743    ) -> Option<u32> {
744        targeting::choose_target_spell(self, player, valid, source)
745    }
746
747    fn choose_mode(
748        &mut self,
749        player: PlayerId,
750        descriptions: &[String],
751        min: usize,
752        max: usize,
753        source_card_id: Option<CardId>,
754    ) -> Vec<usize> {
755        choices::choose_mode(self, player, descriptions, min, max, source_card_id)
756    }
757
758    fn choose_spell_abilities_for_effect(
759        &mut self,
760        player: PlayerId,
761        abilities: &[manabrew_engine::spellability::SpellAbility],
762        num: usize,
763    ) -> Vec<usize> {
764        choices::choose_spell_abilities_for_effect(self, player, abilities, num)
765    }
766
767    fn get_ability_to_play(
768        &mut self,
769        player: PlayerId,
770        abilities: &[manabrew_engine::spellability::SpellAbility],
771    ) -> Option<usize> {
772        choices::get_ability_to_play(self, player, abilities)
773    }
774
775    fn choose_single_entity_for_effect(
776        &mut self,
777        player: PlayerId,
778        valid: &[GameEntity],
779        is_optional: bool,
780    ) -> Option<GameEntity> {
781        choices::choose_single_entity_for_effect(self, player, valid, is_optional)
782    }
783
784    fn choose_entities_for_effect(
785        &mut self,
786        player: PlayerId,
787        candidates: &[GameEntity],
788        min: usize,
789        max: usize,
790    ) -> Vec<GameEntity> {
791        choices::choose_entities_for_effect(self, player, candidates, min, max)
792    }
793
794    fn choose_single_replacement_effect(
795        &mut self,
796        player: PlayerId,
797        descriptions: &[String],
798    ) -> usize {
799        choices::choose_single_replacement_effect(self, player, descriptions)
800    }
801
802    fn confirm_replacement_effect(
803        &mut self,
804        player: PlayerId,
805        question: &str,
806        effect_description: &str,
807        source: Option<CardId>,
808    ) -> bool {
809        choices::confirm_replacement_effect(self, player, question, effect_description, source)
810    }
811
812    fn choose_optional_trigger(
813        &mut self,
814        player: PlayerId,
815        description: &str,
816        source: Option<CardId>,
817        api: Option<manabrew_engine::ability::api_type::ApiType>,
818    ) -> bool {
819        choices::choose_optional_trigger(self, player, description, source, api)
820    }
821
822    fn confirm_action(
823        &mut self,
824        player: PlayerId,
825        mode: Option<&str>,
826        message: &str,
827        options: &[String],
828        source: Option<CardId>,
829        api: Option<manabrew_engine::ability::api_type::ApiType>,
830    ) -> bool {
831        choices::confirm_action(self, player, mode, message, options, source, api)
832    }
833
834    fn confirm_payment(
835        &mut self,
836        player: PlayerId,
837        cost_kind: &str,
838        message: &str,
839        source: Option<CardId>,
840        api: Option<manabrew_engine::ability::api_type::ApiType>,
841    ) -> bool {
842        choices::confirm_payment(self, player, cost_kind, message, source, api)
843    }
844
845    fn pay_cost_to_prevent_effect(
846        &mut self,
847        player: PlayerId,
848        cost_kind: &str,
849        message: &str,
850        source: Option<CardId>,
851        api: Option<manabrew_engine::ability::api_type::ApiType>,
852        can_pay: bool,
853        targets: &[manabrew_engine::agent::GameEntity],
854        effect_text: &str,
855    ) -> bool {
856        choices::pay_cost_to_prevent_effect(
857            self,
858            player,
859            cost_kind,
860            message,
861            source,
862            api,
863            can_pay,
864            targets,
865            effect_text,
866        )
867    }
868
869    fn choose_binary(
870        &mut self,
871        player: PlayerId,
872        question: &str,
873        kind: BinaryChoiceKind,
874        default_choice: Option<bool>,
875        source: Option<CardId>,
876        api: Option<manabrew_engine::ability::api_type::ApiType>,
877    ) -> bool {
878        choices::choose_binary(self, player, question, kind, default_choice, source, api)
879    }
880
881    fn choose_phyrexian_pay_life(
882        &mut self,
883        player: PlayerId,
884        color: &str,
885        source: Option<CardId>,
886    ) -> bool {
887        costs::choose_phyrexian_pay_life(self, player, color, source)
888    }
889
890    fn choose_kicker(
891        &mut self,
892        player: PlayerId,
893        kicker_cost: &str,
894        source: Option<CardId>,
895    ) -> bool {
896        costs::choose_kicker(self, player, kicker_cost, source)
897    }
898
899    fn choose_buyback(
900        &mut self,
901        player: PlayerId,
902        buyback_cost: &str,
903        source: Option<CardId>,
904    ) -> bool {
905        costs::choose_buyback(self, player, buyback_cost, source)
906    }
907
908    fn choose_multikicker(
909        &mut self,
910        player: PlayerId,
911        cost: &str,
912        max_kicks: u32,
913        source: Option<CardId>,
914    ) -> u32 {
915        costs::choose_multikicker(self, player, cost, max_kicks, source)
916    }
917
918    fn choose_replicate(
919        &mut self,
920        player: PlayerId,
921        cost: &str,
922        max_replicates: u32,
923        source: Option<CardId>,
924    ) -> u32 {
925        costs::choose_replicate(self, player, cost, max_replicates, source)
926    }
927
928    fn choose_color(&mut self, player: PlayerId, valid_colors: &[String]) -> Option<String> {
929        choices::choose_color(self, player, valid_colors)
930    }
931
932    fn choose_colors(
933        &mut self,
934        player: PlayerId,
935        valid_colors: &[String],
936        min: usize,
937        max: usize,
938    ) -> Vec<String> {
939        choices::choose_colors(self, player, valid_colors, min, max)
940    }
941
942    fn choose_cards_for_effect(
943        &mut self,
944        player: PlayerId,
945        valid: &[CardId],
946        min: usize,
947        max: usize,
948    ) -> Vec<CardId> {
949        choices::choose_cards_for_effect(self, player, valid, min, max)
950    }
951
952    fn choose_single_card_for_zone_change(
953        &mut self,
954        game: &GameState,
955        player: PlayerId,
956        valid: &[CardId],
957        select_prompt: &str,
958        is_optional: bool,
959    ) -> Option<CardId> {
960        choices::choose_single_card_for_zone_change(
961            self,
962            game,
963            player,
964            valid,
965            select_prompt,
966            is_optional,
967        )
968    }
969
970    fn choose_cards_for_zone_change(
971        &mut self,
972        game: &GameState,
973        player: PlayerId,
974        valid: &[CardId],
975        min: usize,
976        max: usize,
977        select_prompt: &str,
978    ) -> Vec<CardId> {
979        choices::choose_cards_for_zone_change(self, game, player, valid, min, max, select_prompt)
980    }
981
982    fn choose_type(
983        &mut self,
984        player: PlayerId,
985        type_category: &str,
986        valid_types: &[String],
987    ) -> Option<String> {
988        choices::choose_type(self, player, type_category, valid_types)
989    }
990
991    fn choose_counter_type(
992        &mut self,
993        player: PlayerId,
994        options: &[CounterType],
995        prompt: &str,
996    ) -> Option<CounterType> {
997        choices::choose_counter_type(self, player, options, prompt)
998    }
999
1000    fn choose_card_name(&mut self, player: PlayerId, valid_names: &[String]) -> Option<String> {
1001        choices::choose_card_name(self, player, valid_names)
1002    }
1003
1004    fn choose_number(
1005        &mut self,
1006        player: PlayerId,
1007        source: Option<CardId>,
1008        title: &str,
1009        description: Option<&str>,
1010        min: i32,
1011        max: i32,
1012    ) -> Option<i32> {
1013        choices::choose_number(self, player, source, title, description, min, max)
1014    }
1015
1016    fn choose_number_from_list(
1017        &mut self,
1018        player: PlayerId,
1019        choices: &[i32],
1020        message: &str,
1021        source_card_id: Option<CardId>,
1022    ) -> Option<i32> {
1023        choices::choose_number_from_list(self, player, choices, message, source_card_id)
1024    }
1025
1026    fn choose_roll_to_ignore(
1027        &mut self,
1028        player: PlayerId,
1029        rolls: &[i32],
1030        source: Option<CardId>,
1031    ) -> Option<i32> {
1032        choices::choose_roll_to_ignore(self, player, rolls, source)
1033    }
1034
1035    fn choose_roll_to_swap(
1036        &mut self,
1037        player: PlayerId,
1038        rolls: &[i32],
1039        source: Option<CardId>,
1040    ) -> Option<i32> {
1041        choices::choose_roll_to_swap(self, player, rolls, source)
1042    }
1043
1044    fn choose_dice_to_reroll(
1045        &mut self,
1046        player: PlayerId,
1047        rolls: &[i32],
1048        source: Option<CardId>,
1049    ) -> Vec<i32> {
1050        choices::choose_dice_to_reroll(self, player, rolls, source)
1051    }
1052
1053    fn choose_roll_to_modify(
1054        &mut self,
1055        player: PlayerId,
1056        rolls: &[i32],
1057        source: Option<CardId>,
1058    ) -> Option<i32> {
1059        choices::choose_roll_to_modify(self, player, rolls, source)
1060    }
1061
1062    fn choose_roll_swap_value(
1063        &mut self,
1064        player: PlayerId,
1065        current_result: i32,
1066        power: i32,
1067        toughness: i32,
1068        source: Option<CardId>,
1069    ) -> Option<RollSwapChoice> {
1070        choices::choose_roll_swap_value(self, player, current_result, power, toughness, source)
1071    }
1072
1073    fn flip_coin_call(&mut self, player: PlayerId) -> bool {
1074        choices::flip_coin_call(self, player)
1075    }
1076
1077    fn pay_combat_cost(
1078        &mut self,
1079        player: PlayerId,
1080        attacker: CardId,
1081        cost: i32,
1082        description: &str,
1083        mana_ability_options: &[manabrew_engine::agent::ManaAbilityOption],
1084        tappable_lands: &[CardId],
1085        untappable_lands: &[CardId],
1086        mana_pool_total: i32,
1087    ) -> CombatCostAction {
1088        combat::pay_combat_cost(
1089            self,
1090            player,
1091            attacker,
1092            cost,
1093            description,
1094            mana_ability_options,
1095            tappable_lands,
1096            untappable_lands,
1097            mana_pool_total,
1098        )
1099    }
1100
1101    fn choose_improvise(
1102        &mut self,
1103        player: PlayerId,
1104        untapped_artifacts: &[CardId],
1105        remaining_cost: &forge_foundation::ManaCost,
1106        source: Option<CardId>,
1107    ) -> Vec<CardId> {
1108        costs::choose_improvise(self, player, untapped_artifacts, remaining_cost, source)
1109    }
1110
1111    fn choose_convoke(
1112        &mut self,
1113        player: PlayerId,
1114        untapped_creatures: &[CardId],
1115        remaining_cost: &forge_foundation::ManaCost,
1116        source: Option<CardId>,
1117    ) -> Vec<CardId> {
1118        costs::choose_convoke(self, player, untapped_creatures, remaining_cost, source)
1119    }
1120
1121    fn pay_mana_cost(
1122        &mut self,
1123        player: PlayerId,
1124        card_id: CardId,
1125        card_name: &str,
1126        mana_cost: &str,
1127        mana_cost_display: &str,
1128        _mana_cost_checkpoint: &str,
1129        can_confirm_from_pool: bool,
1130        _allow_reserved_source_reuse: bool,
1131        _reserved_sacrifices: &[CardId],
1132        mana_ability_options: &[manabrew_engine::agent::ManaAbilityOption],
1133        tappable_lands: &[CardId],
1134        untappable_lands: &[CardId],
1135        mana_pool: &ManaPool,
1136    ) -> ManaCostAction {
1137        costs::pay_mana_cost(
1138            self,
1139            player,
1140            card_id,
1141            card_name,
1142            mana_cost,
1143            mana_cost_display,
1144            can_confirm_from_pool,
1145            mana_ability_options,
1146            tappable_lands,
1147            untappable_lands,
1148            mana_pool,
1149        )
1150    }
1151
1152    fn await_display_ack(&mut self) {
1153        if self.conceded {
1154            return;
1155        }
1156        if let ClientToServerMessage::Directive { directive } = self.responder.await_ack() {
1157            self.handle_directive(directive);
1158        }
1159    }
1160
1161    fn specify_mana_combo(
1162        &mut self,
1163        player: PlayerId,
1164        available_colors: &[String],
1165        amount: usize,
1166        source: Option<CardId>,
1167        express_choice: Option<u16>,
1168    ) -> Vec<String> {
1169        costs::specify_mana_combo(
1170            self,
1171            player,
1172            available_colors,
1173            amount,
1174            source,
1175            express_choice,
1176        )
1177    }
1178
1179    fn exert_attackers(&mut self, player: PlayerId, attackers: &[CardId]) -> Vec<CardId> {
1180        combat::exert_attackers(self, player, attackers)
1181    }
1182
1183    fn enlist_attackers(&mut self, player: PlayerId, attackers: &[CardId]) -> Vec<CardId> {
1184        combat::enlist_attackers(self, player, attackers)
1185    }
1186
1187    fn choose_reorder_library(
1188        &mut self,
1189        game: &GameState,
1190        player: PlayerId,
1191        cards: &[CardId],
1192    ) -> Vec<CardId> {
1193        library::choose_reorder_library(self, game, player, cards)
1194    }
1195
1196    fn help_pay_assist(&mut self, player: PlayerId, card_name: &str, max_generic: u32) -> u32 {
1197        choices::help_pay_assist(self, player, card_name, max_generic)
1198    }
1199
1200    fn choose_random_discard(
1201        &mut self,
1202        player: PlayerId,
1203        hand: &[CardId],
1204        num: usize,
1205    ) -> Vec<CardId> {
1206        choices::choose_random_discard(self, player, hand, num)
1207    }
1208
1209    fn choose_land_or_spell(&mut self, player: PlayerId) -> Option<bool> {
1210        choices::choose_land_or_spell(self, player)
1211    }
1212
1213    fn notify(&mut self, event: GameNotification) {
1214        match event {
1215            GameNotification::Event(log_event) => {
1216                self.responder
1217                    .send_log(GameLogEntryDto::from_event(log_event));
1218            }
1219            GameNotification::CardPlayed {
1220                player,
1221                card_id,
1222                card_name,
1223                set_code,
1224            } => {
1225                self.emit_display(DisplayEvent::CardPlayed {
1226                    card_id: card_id_str(card_id),
1227                    card_name,
1228                    set_code,
1229                    player_id: player_id_str(player),
1230                });
1231                self.emit_state();
1232            }
1233            GameNotification::TurnChanged {
1234                active_player,
1235                turn_number,
1236            } => {
1237                let player_id = player_id_str(active_player);
1238                let active_player_name = self
1239                    .latest_view
1240                    .as_ref()
1241                    .and_then(|v| v.players.iter().find(|p| p.id == player_id))
1242                    .map(|p| p.name.clone())
1243                    .unwrap_or_else(|| format!("Player {}", active_player.0));
1244                self.responder.send_log(GameLogEntryDto::from_event(
1245                    manabrew_engine::agent::GameLogEvent::rule(format!(
1246                        "TURN {} — {}",
1247                        turn_number, active_player_name
1248                    ))
1249                    .with_player(active_player),
1250                ));
1251                self.emit_display(DisplayEvent::TurnChanged {
1252                    active_player_id: player_id,
1253                    active_player_name,
1254                    turn_number,
1255                });
1256                self.emit_state();
1257            }
1258            GameNotification::PhaseChanged { .. } | GameNotification::StateChanged => {
1259                self.emit_state();
1260            }
1261            GameNotification::PriorityChanged { .. } => {
1262                self.emit_state();
1263            }
1264            GameNotification::FirstPlayerRoll {
1265                sides,
1266                rolls,
1267                winner,
1268            } => {
1269                let view = self.view();
1270                let winner_id = player_id_str(winner);
1271                let entries = rolls
1272                    .into_iter()
1273                    .map(|(pid, value)| {
1274                        let id = player_id_str(pid);
1275                        let name = view
1276                            .players
1277                            .iter()
1278                            .find(|p| p.id == id)
1279                            .map(|p| p.name.clone())
1280                            .unwrap_or_else(|| id.clone());
1281                        manabrew_protocol::prompts::dice_rolled::DiceRollEntry {
1282                            label: Some(name),
1283                            highlighted: id == winner_id,
1284                            player_id: Some(id),
1285                            natural_results: vec![value],
1286                            final_results: vec![value],
1287                            ignored_rolls: vec![],
1288                        }
1289                    })
1290                    .collect();
1291                self.present_prompt(
1292                    PromptInput::DiceRolled(
1293                        manabrew_protocol::prompts::dice_rolled::DiceRolledInput {
1294                            sides,
1295                            rolls: entries,
1296                            title: Some("Roll for first player".to_string()),
1297                            source_card_name: None,
1298                        },
1299                    ),
1300                    None,
1301                );
1302                // Caller is responsible for `await_display_ack` after the
1303                // full broadcast — see `roll_for_first_player`.
1304            }
1305            GameNotification::DiceRolled {
1306                player,
1307                sides,
1308                natural_results,
1309                final_results,
1310                ignored_rolls,
1311                source_card_name,
1312            } => {
1313                // Send the prompt to every agent's transport. The caller
1314                // is responsible for issuing a parallel `await_display_ack`
1315                // pass after broadcasting — that way all clients see the
1316                // animation start at the same time and we wait once for
1317                // the slowest player rather than serially per-agent.
1318                self.present_prompt(
1319                    PromptInput::DiceRolled(
1320                        manabrew_protocol::prompts::dice_rolled::DiceRolledInput {
1321                            sides,
1322                            rolls: vec![manabrew_protocol::prompts::dice_rolled::DiceRollEntry {
1323                                label: None,
1324                                player_id: Some(player_id_str(player)),
1325                                natural_results,
1326                                final_results,
1327                                ignored_rolls,
1328                                highlighted: false,
1329                            }],
1330                            title: None,
1331                            source_card_name,
1332                        },
1333                    ),
1334                    None,
1335                );
1336            }
1337            GameNotification::SnapshotCreated {
1338                checkpoint_id,
1339                label,
1340            } => {
1341                if let Some(view) = self.latest_view.clone() {
1342                    self.responder.send_snapshot(GameSnapshotEventDto::new(
1343                        checkpoint_id,
1344                        label,
1345                        view,
1346                    ));
1347                }
1348            }
1349            GameNotification::GameOver => {
1350                self.emit_state();
1351                self.present_prompt(
1352                    PromptInput::GameOver(manabrew_protocol::prompts::game_over::GameOverInput {}),
1353                    None,
1354                );
1355            }
1356            GameNotification::ManaPaymentResolved { .. } => {}
1357            GameNotification::ActivatedAbilityPaymentFailed { .. } => {
1358                self.emit_state();
1359            }
1360        }
1361    }
1362
1363    fn take_restore_request(&mut self) -> Option<u64> {
1364        self.pending_restore_checkpoint.take()
1365    }
1366}