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