Skip to main content

manabrew_agent_interface/agent_impl/
mod.rs

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