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                        is_class_level_up: Some(a.is_class_level_up),
585                        produced_mana: None,
586                    }),
587                });
588            }
589        }
590        for card_id in &untappable_land_ids {
591            actions.push(AvailableAction {
592                id: format!("untap:{card_id}"),
593                kind: AvailableActionKind::UndoMana {
594                    card_id: card_id.clone(),
595                },
596            });
597        }
598
599        self.send_prompt(
600            PromptInput::ChooseAction(
601                manabrew_protocol::prompts::choose_action::ChooseActionInput { actions },
602            ),
603            None,
604        );
605        let prompt = self
606            .pending_prompt
607            .take()
608            .expect("choose_action called without a pending prompt");
609        let action = match self.responder.respond(prompt) {
610            ClientToServerMessage::Response { action, .. } => action,
611            ClientToServerMessage::Directive {
612                directive: DirectiveInput::Concede,
613            } => return EnginePlayerAction::Concede,
614        };
615        match action {
616            PromptOutput::ChooseAction(ChooseActionOutput::Act { action_id }) => {
617                if let Some(rest) = action_id.strip_prefix("cast:") {
618                    let (id_part, mode) = rest.split_once(':').unwrap_or((rest, "normal"));
619                    let resolved = parse_card_id(id_part).and_then(|cid| {
620                        Self::parse_play_mode(mode)
621                            .and_then(|m| {
622                                playable
623                                    .iter()
624                                    .copied()
625                                    .find(|play| play.card_id == cid && play.mode == m)
626                            })
627                            .or_else(|| playable.iter().copied().find(|play| play.card_id == cid))
628                    });
629                    resolved
630                        .map(EnginePlayerAction::CastSpell)
631                        .unwrap_or(EnginePlayerAction::PassPriority)
632                } else if let Some(rest) = action_id.strip_prefix("tap:") {
633                    let tap = parse_tap_action_id(rest);
634                    match parse_card_id(tap.card_id) {
635                        Some(cid) => EnginePlayerAction::ActivateMana(
636                            cid,
637                            tap.ability_index,
638                            parse_express_mana_choice(tap.color),
639                        ),
640                        None => EnginePlayerAction::PassPriority,
641                    }
642                } else if let Some(rest) = action_id.strip_prefix("ability:") {
643                    let (id_part, idx) = rest.split_once(':').unwrap_or((rest, ""));
644                    match (parse_card_id(id_part), idx.parse::<usize>()) {
645                        (Some(cid), Ok(ability_index)) => {
646                            EnginePlayerAction::ActivateAbility(AbilityRef {
647                                card_id: cid,
648                                ability_index,
649                            })
650                        }
651                        _ => EnginePlayerAction::PassPriority,
652                    }
653                } else if let Some(id_part) = action_id.strip_prefix("untap:") {
654                    parse_card_id(id_part)
655                        .map(EnginePlayerAction::UndoMana)
656                        .unwrap_or(EnginePlayerAction::PassPriority)
657                } else {
658                    EnginePlayerAction::PassPriority
659                }
660            }
661            PromptOutput::ChooseAction(ChooseActionOutput::Pass { until, .. }) => {
662                self.pass_until = until.and_then(|u| {
663                    Some(manabrew_engine::agent::PassUntilTarget {
664                        player: crate::ids_codec::parse_player_id(&u.player_id)?,
665                        phase: crate::game_view_dto::step_to_phase(u.phase),
666                    })
667                });
668                EnginePlayerAction::PassPriority
669            }
670            PromptOutput::ChooseAction(ChooseActionOutput::RestoreSnapshot { checkpoint_id }) => {
671                self.pending_restore_checkpoint = Some(checkpoint_id);
672                EnginePlayerAction::PassPriority
673            }
674            _ => EnginePlayerAction::PassPriority,
675        }
676    }
677
678    fn choose_attackers(
679        &mut self,
680        player: PlayerId,
681        available: &[CardId],
682        possible_defenders: &[DefenderId],
683    ) -> Vec<(CardId, DefenderId)> {
684        combat::choose_attackers(self, player, available, possible_defenders)
685    }
686
687    fn choose_blockers(
688        &mut self,
689        player: PlayerId,
690        attackers: &[CardId],
691        available_blockers: &[CardId],
692        max_blockers: Option<usize>,
693    ) -> Vec<(CardId, CardId)> {
694        combat::choose_blockers(self, player, attackers, available_blockers, max_blockers)
695    }
696
697    fn choose_damage_assignment_order(
698        &mut self,
699        player: PlayerId,
700        attacker: CardId,
701        blockers: &[CardId],
702    ) -> Vec<CardId> {
703        combat::choose_damage_assignment_order(self, player, attacker, blockers)
704    }
705
706    fn assign_combat_damage(
707        &mut self,
708        game: &GameState,
709        player: PlayerId,
710        attacker: CardId,
711        blockers_in_order: &[CardId],
712        defender_id: Option<DefenderId>,
713        damage_to_assign: i32,
714    ) -> Vec<(Option<CardId>, i32)> {
715        let attacker_has_deathtouch = game.card(attacker).has_deathtouch();
716        combat::choose_combat_damage_assignment(
717            self,
718            player,
719            attacker,
720            blockers_in_order,
721            defender_id,
722            damage_to_assign,
723            attacker_has_deathtouch,
724        )
725    }
726
727    fn choose_target_player(
728        &mut self,
729        player: PlayerId,
730        valid: &[PlayerId],
731        sa: Option<&manabrew_engine::spellability::SpellAbility>,
732    ) -> Option<PlayerId> {
733        let source = sa.and_then(|s| s.source);
734        let intent = sa
735            .map(crate::game_view_dto::targeting_intent_of)
736            .unwrap_or(crate::game_view_dto::TargetingIntent::Hostile);
737        let hostile = crate::game_view_dto::intent_is_hostile(intent);
738        targeting::choose_target_player(self, player, valid, source, hostile, intent)
739    }
740
741    fn choose_target_card(
742        &mut self,
743        player: PlayerId,
744        valid: &[CardId],
745        sa: Option<&manabrew_engine::spellability::SpellAbility>,
746    ) -> Option<CardId> {
747        let source = sa.and_then(|s| s.source);
748        let intent = sa
749            .map(crate::game_view_dto::targeting_intent_of)
750            .unwrap_or(crate::game_view_dto::TargetingIntent::Hostile);
751        let hostile = crate::game_view_dto::intent_is_hostile(intent);
752        targeting::choose_target_card(self, player, valid, source, hostile, intent)
753    }
754
755    fn choose_target_card_from_zone(
756        &mut self,
757        player: PlayerId,
758        zone: ZoneType,
759        valid: &[CardId],
760        sa: Option<&manabrew_engine::spellability::SpellAbility>,
761    ) -> Option<CardId> {
762        let source = sa.and_then(|s| s.source);
763        let intent = sa
764            .map(crate::game_view_dto::targeting_intent_of)
765            .unwrap_or(crate::game_view_dto::TargetingIntent::Hostile);
766        let hostile = crate::game_view_dto::intent_is_hostile(intent);
767        targeting::choose_target_card_from_zone(self, player, zone, valid, source, hostile, intent)
768    }
769
770    fn choose_target_any(
771        &mut self,
772        player: PlayerId,
773        valid_players: &[PlayerId],
774        valid_cards: &[CardId],
775        sa: Option<&manabrew_engine::spellability::SpellAbility>,
776    ) -> TargetChoice {
777        let source = sa.and_then(|s| s.source);
778        let intent = sa
779            .map(crate::game_view_dto::targeting_intent_of)
780            .unwrap_or(crate::game_view_dto::TargetingIntent::Hostile);
781        let hostile = crate::game_view_dto::intent_is_hostile(intent);
782        targeting::choose_target_any(
783            self,
784            player,
785            valid_players,
786            valid_cards,
787            source,
788            hostile,
789            intent,
790        )
791    }
792
793    fn choose_sacrifice(
794        &mut self,
795        player: PlayerId,
796        valid: &[CardId],
797        source: Option<CardId>,
798    ) -> Option<CardId> {
799        targeting::choose_sacrifice(self, player, valid, source)
800    }
801
802    fn reveal_cards(
803        &mut self,
804        game: &GameState,
805        _player: PlayerId,
806        cards: &[CardId],
807        zone: ZoneType,
808        owner: PlayerId,
809        message_prefix: Option<&str>,
810    ) {
811        choices::reveal_cards(self, game, cards, zone, owner, message_prefix)
812    }
813
814    fn choose_scry(
815        &mut self,
816        game: &GameState,
817        player: PlayerId,
818        source: Option<CardId>,
819        cards: &[CardId],
820    ) -> Vec<Vec<CardId>> {
821        library::choose_scry(self, game, player, source, cards)
822    }
823
824    fn choose_surveil(
825        &mut self,
826        game: &GameState,
827        player: PlayerId,
828        source: Option<CardId>,
829        cards: &[CardId],
830    ) -> Vec<Vec<CardId>> {
831        library::choose_surveil(self, game, player, source, cards)
832    }
833
834    fn choose_dig(
835        &mut self,
836        game: &GameState,
837        player: PlayerId,
838        valid: &[CardId],
839        max: usize,
840        optional: bool,
841    ) -> Vec<CardId> {
842        library::choose_dig(self, game, player, valid, max, optional)
843    }
844
845    fn choose_discard(&mut self, player: PlayerId, hand: &[CardId], num: usize) -> Vec<CardId> {
846        choices::choose_discard(self, player, hand, num)
847    }
848
849    fn choose_discard_any_number(
850        &mut self,
851        player: PlayerId,
852        hand: &[CardId],
853        min: usize,
854        max: usize,
855    ) -> Vec<CardId> {
856        choices::choose_discard_any_number(self, player, hand, min, max)
857    }
858
859    fn choose_legend_keep(&mut self, player: PlayerId, duplicates: &[CardId]) -> CardId {
860        choices::choose_legend_keep(self, player, duplicates)
861    }
862
863    fn choose_target_spell(
864        &mut self,
865        player: PlayerId,
866        valid: &[u32],
867        source: Option<CardId>,
868    ) -> Option<u32> {
869        targeting::choose_target_spell(self, player, valid, source)
870    }
871
872    fn choose_mode(
873        &mut self,
874        player: PlayerId,
875        descriptions: &[String],
876        min: usize,
877        max: usize,
878        source_card_id: Option<CardId>,
879    ) -> Vec<usize> {
880        choices::choose_mode(self, player, descriptions, min, max, source_card_id)
881    }
882
883    fn choose_spell_abilities_for_effect(
884        &mut self,
885        player: PlayerId,
886        abilities: &[manabrew_engine::spellability::SpellAbility],
887        num: usize,
888    ) -> Vec<usize> {
889        choices::choose_spell_abilities_for_effect(self, player, abilities, num)
890    }
891
892    fn get_ability_to_play(
893        &mut self,
894        player: PlayerId,
895        abilities: &[manabrew_engine::spellability::SpellAbility],
896    ) -> Option<usize> {
897        choices::get_ability_to_play(self, player, abilities)
898    }
899
900    fn choose_single_entity_for_effect(
901        &mut self,
902        player: PlayerId,
903        valid: &[GameEntity],
904        is_optional: bool,
905    ) -> Option<GameEntity> {
906        choices::choose_single_entity_for_effect(self, player, valid, is_optional)
907    }
908
909    fn choose_entities_for_effect(
910        &mut self,
911        player: PlayerId,
912        candidates: &[GameEntity],
913        min: usize,
914        max: usize,
915    ) -> Vec<GameEntity> {
916        choices::choose_entities_for_effect(self, player, candidates, min, max)
917    }
918
919    fn choose_single_replacement_effect(
920        &mut self,
921        player: PlayerId,
922        descriptions: &[String],
923    ) -> usize {
924        choices::choose_single_replacement_effect(self, player, descriptions)
925    }
926
927    fn confirm_replacement_effect(
928        &mut self,
929        player: PlayerId,
930        question: &str,
931        effect_description: &str,
932        source: Option<CardId>,
933    ) -> bool {
934        choices::confirm_replacement_effect(self, player, question, effect_description, source)
935    }
936
937    fn choose_optional_trigger(
938        &mut self,
939        player: PlayerId,
940        description: &str,
941        source: Option<CardId>,
942        api: Option<manabrew_engine::ability::api_type::ApiType>,
943    ) -> bool {
944        choices::choose_optional_trigger(self, player, description, source, api)
945    }
946
947    fn confirm_action(
948        &mut self,
949        player: PlayerId,
950        mode: Option<&str>,
951        message: &str,
952        options: &[String],
953        source: Option<CardId>,
954        api: Option<manabrew_engine::ability::api_type::ApiType>,
955    ) -> bool {
956        choices::confirm_action(self, player, mode, message, options, source, api)
957    }
958
959    fn confirm_payment(
960        &mut self,
961        player: PlayerId,
962        cost_kind: &str,
963        message: &str,
964        source: Option<CardId>,
965        api: Option<manabrew_engine::ability::api_type::ApiType>,
966    ) -> bool {
967        choices::confirm_payment(self, player, cost_kind, message, source, api)
968    }
969
970    fn pay_cost_to_prevent_effect(
971        &mut self,
972        player: PlayerId,
973        cost_kind: &str,
974        message: &str,
975        source: Option<CardId>,
976        api: Option<manabrew_engine::ability::api_type::ApiType>,
977        can_pay: bool,
978        targets: &[manabrew_engine::agent::GameEntity],
979        effect_text: &str,
980    ) -> bool {
981        choices::pay_cost_to_prevent_effect(
982            self,
983            player,
984            cost_kind,
985            message,
986            source,
987            api,
988            can_pay,
989            targets,
990            effect_text,
991        )
992    }
993
994    fn choose_binary(
995        &mut self,
996        player: PlayerId,
997        question: &str,
998        kind: BinaryChoiceKind,
999        default_choice: Option<bool>,
1000        source: Option<CardId>,
1001        api: Option<manabrew_engine::ability::api_type::ApiType>,
1002    ) -> bool {
1003        choices::choose_binary(self, player, question, kind, default_choice, source, api)
1004    }
1005
1006    fn choose_phyrexian_pay_life(
1007        &mut self,
1008        player: PlayerId,
1009        color: &str,
1010        source: Option<CardId>,
1011    ) -> bool {
1012        costs::choose_phyrexian_pay_life(self, player, color, source)
1013    }
1014
1015    fn choose_kicker(
1016        &mut self,
1017        player: PlayerId,
1018        kicker_cost: &str,
1019        source: Option<CardId>,
1020    ) -> bool {
1021        costs::choose_kicker(self, player, kicker_cost, source)
1022    }
1023
1024    fn choose_buyback(
1025        &mut self,
1026        player: PlayerId,
1027        buyback_cost: &str,
1028        source: Option<CardId>,
1029    ) -> bool {
1030        costs::choose_buyback(self, player, buyback_cost, source)
1031    }
1032
1033    fn choose_multikicker(
1034        &mut self,
1035        player: PlayerId,
1036        cost: &str,
1037        max_kicks: u32,
1038        source: Option<CardId>,
1039    ) -> u32 {
1040        costs::choose_multikicker(self, player, cost, max_kicks, source)
1041    }
1042
1043    fn choose_replicate(
1044        &mut self,
1045        player: PlayerId,
1046        cost: &str,
1047        max_replicates: u32,
1048        source: Option<CardId>,
1049    ) -> u32 {
1050        costs::choose_replicate(self, player, cost, max_replicates, source)
1051    }
1052
1053    fn choose_color(&mut self, player: PlayerId, valid_colors: &[String]) -> Option<String> {
1054        choices::choose_color(self, player, valid_colors)
1055    }
1056
1057    fn choose_colors(
1058        &mut self,
1059        player: PlayerId,
1060        valid_colors: &[String],
1061        min: usize,
1062        max: usize,
1063    ) -> Vec<String> {
1064        choices::choose_colors(self, player, valid_colors, min, max)
1065    }
1066
1067    fn choose_cards_for_effect(
1068        &mut self,
1069        player: PlayerId,
1070        valid: &[CardId],
1071        min: usize,
1072        max: usize,
1073    ) -> Vec<CardId> {
1074        choices::choose_cards_for_effect(self, player, valid, min, max)
1075    }
1076
1077    fn choose_single_card_for_zone_change(
1078        &mut self,
1079        game: &GameState,
1080        player: PlayerId,
1081        valid: &[CardId],
1082        select_prompt: &str,
1083        is_optional: bool,
1084    ) -> Option<CardId> {
1085        choices::choose_single_card_for_zone_change(
1086            self,
1087            game,
1088            player,
1089            valid,
1090            select_prompt,
1091            is_optional,
1092        )
1093    }
1094
1095    fn choose_cards_for_zone_change(
1096        &mut self,
1097        game: &GameState,
1098        player: PlayerId,
1099        valid: &[CardId],
1100        min: usize,
1101        max: usize,
1102        select_prompt: &str,
1103    ) -> Vec<CardId> {
1104        choices::choose_cards_for_zone_change(self, game, player, valid, min, max, select_prompt)
1105    }
1106
1107    fn choose_type(
1108        &mut self,
1109        player: PlayerId,
1110        type_category: &str,
1111        valid_types: &[String],
1112    ) -> Option<String> {
1113        choices::choose_type(self, player, type_category, valid_types)
1114    }
1115
1116    fn choose_counter_type(
1117        &mut self,
1118        player: PlayerId,
1119        options: &[CounterType],
1120        prompt: &str,
1121    ) -> Option<CounterType> {
1122        choices::choose_counter_type(self, player, options, prompt)
1123    }
1124
1125    fn choose_card_name(&mut self, player: PlayerId, valid_names: &[String]) -> Option<String> {
1126        choices::choose_card_name(self, player, valid_names)
1127    }
1128
1129    fn choose_number(
1130        &mut self,
1131        player: PlayerId,
1132        source: Option<CardId>,
1133        title: &str,
1134        description: Option<&str>,
1135        min: i32,
1136        max: i32,
1137    ) -> Option<i32> {
1138        choices::choose_number(self, player, source, title, description, min, max)
1139    }
1140
1141    fn choose_number_from_list(
1142        &mut self,
1143        player: PlayerId,
1144        choices: &[i32],
1145        message: &str,
1146        source_card_id: Option<CardId>,
1147    ) -> Option<i32> {
1148        choices::choose_number_from_list(self, player, choices, message, source_card_id)
1149    }
1150
1151    fn choose_roll_to_ignore(
1152        &mut self,
1153        player: PlayerId,
1154        rolls: &[i32],
1155        source: Option<CardId>,
1156    ) -> Option<i32> {
1157        choices::choose_roll_to_ignore(self, player, rolls, source)
1158    }
1159
1160    fn choose_roll_to_swap(
1161        &mut self,
1162        player: PlayerId,
1163        rolls: &[i32],
1164        source: Option<CardId>,
1165    ) -> Option<i32> {
1166        choices::choose_roll_to_swap(self, player, rolls, source)
1167    }
1168
1169    fn choose_dice_to_reroll(
1170        &mut self,
1171        player: PlayerId,
1172        rolls: &[i32],
1173        source: Option<CardId>,
1174    ) -> Vec<i32> {
1175        choices::choose_dice_to_reroll(self, player, rolls, source)
1176    }
1177
1178    fn choose_roll_to_modify(
1179        &mut self,
1180        player: PlayerId,
1181        rolls: &[i32],
1182        source: Option<CardId>,
1183    ) -> Option<i32> {
1184        choices::choose_roll_to_modify(self, player, rolls, source)
1185    }
1186
1187    fn choose_roll_swap_value(
1188        &mut self,
1189        player: PlayerId,
1190        current_result: i32,
1191        power: i32,
1192        toughness: i32,
1193        source: Option<CardId>,
1194    ) -> Option<RollSwapChoice> {
1195        choices::choose_roll_swap_value(self, player, current_result, power, toughness, source)
1196    }
1197
1198    fn flip_coin_call(&mut self, player: PlayerId) -> bool {
1199        choices::flip_coin_call(self, player)
1200    }
1201
1202    fn pay_combat_cost(
1203        &mut self,
1204        player: PlayerId,
1205        attacker: CardId,
1206        cost: i32,
1207        description: &str,
1208        mana_ability_options: &[manabrew_engine::agent::ManaAbilityOption],
1209        tappable_lands: &[CardId],
1210        untappable_lands: &[CardId],
1211        mana_pool_total: i32,
1212    ) -> CombatCostAction {
1213        combat::pay_combat_cost(
1214            self,
1215            player,
1216            attacker,
1217            cost,
1218            description,
1219            mana_ability_options,
1220            tappable_lands,
1221            untappable_lands,
1222            mana_pool_total,
1223        )
1224    }
1225
1226    fn choose_improvise(
1227        &mut self,
1228        player: PlayerId,
1229        untapped_artifacts: &[CardId],
1230        remaining_cost: &forge_foundation::ManaCost,
1231        source: Option<CardId>,
1232    ) -> Vec<CardId> {
1233        costs::choose_improvise(self, player, untapped_artifacts, remaining_cost, source)
1234    }
1235
1236    fn choose_convoke(
1237        &mut self,
1238        player: PlayerId,
1239        untapped_creatures: &[CardId],
1240        remaining_cost: &forge_foundation::ManaCost,
1241        source: Option<CardId>,
1242    ) -> Vec<CardId> {
1243        costs::choose_convoke(self, player, untapped_creatures, remaining_cost, source)
1244    }
1245
1246    fn pay_mana_cost(
1247        &mut self,
1248        player: PlayerId,
1249        card_id: CardId,
1250        card_name: &str,
1251        mana_cost: &str,
1252        mana_cost_display: &str,
1253        _mana_cost_checkpoint: &str,
1254        can_confirm_from_pool: bool,
1255        _allow_reserved_source_reuse: bool,
1256        _reserved_sacrifices: &[CardId],
1257        mana_ability_options: &[manabrew_engine::agent::ManaAbilityOption],
1258        tappable_lands: &[CardId],
1259        untappable_lands: &[CardId],
1260        mana_pool: &ManaPool,
1261    ) -> ManaCostAction {
1262        costs::pay_mana_cost(
1263            self,
1264            player,
1265            card_id,
1266            card_name,
1267            mana_cost,
1268            mana_cost_display,
1269            can_confirm_from_pool,
1270            mana_ability_options,
1271            tappable_lands,
1272            untappable_lands,
1273            mana_pool,
1274        )
1275    }
1276
1277    fn await_display_ack(&mut self) {
1278        if self.conceded {
1279            return;
1280        }
1281        if let ClientToServerMessage::Directive { directive } = self.responder.await_ack() {
1282            self.handle_directive(directive);
1283        }
1284    }
1285
1286    fn specify_mana_combo(
1287        &mut self,
1288        player: PlayerId,
1289        available_colors: &[String],
1290        amount: usize,
1291        source: Option<CardId>,
1292        express_choice: Option<u16>,
1293    ) -> Vec<String> {
1294        costs::specify_mana_combo(
1295            self,
1296            player,
1297            available_colors,
1298            amount,
1299            source,
1300            express_choice,
1301        )
1302    }
1303
1304    fn exert_attackers(&mut self, player: PlayerId, attackers: &[CardId]) -> Vec<CardId> {
1305        combat::exert_attackers(self, player, attackers)
1306    }
1307
1308    fn enlist_attackers(&mut self, player: PlayerId, attackers: &[CardId]) -> Vec<CardId> {
1309        combat::enlist_attackers(self, player, attackers)
1310    }
1311
1312    fn choose_reorder_library(
1313        &mut self,
1314        game: &GameState,
1315        player: PlayerId,
1316        cards: &[CardId],
1317    ) -> Vec<CardId> {
1318        library::choose_reorder_library(self, game, player, cards)
1319    }
1320
1321    fn help_pay_assist(&mut self, player: PlayerId, card_name: &str, max_generic: u32) -> u32 {
1322        choices::help_pay_assist(self, player, card_name, max_generic)
1323    }
1324
1325    fn choose_random_discard(
1326        &mut self,
1327        player: PlayerId,
1328        hand: &[CardId],
1329        num: usize,
1330    ) -> Vec<CardId> {
1331        choices::choose_random_discard(self, player, hand, num)
1332    }
1333
1334    fn choose_land_or_spell(&mut self, player: PlayerId) -> Option<bool> {
1335        choices::choose_land_or_spell(self, player)
1336    }
1337
1338    fn notify(&mut self, event: GameNotification) {
1339        match event {
1340            GameNotification::Event(log_event) => {
1341                self.responder
1342                    .send_log(GameLogEntryDto::from_event(log_event));
1343            }
1344            GameNotification::CardPlayed {
1345                player,
1346                card_id,
1347                card_name,
1348                set_code,
1349            } => {
1350                self.emit_display(DisplayEvent::CardPlayed {
1351                    card_id: card_id_str(card_id),
1352                    card_name,
1353                    set_code,
1354                    player_id: player_id_str(player),
1355                });
1356                self.emit_state();
1357            }
1358            GameNotification::TurnChanged {
1359                active_player,
1360                turn_number,
1361            } => {
1362                let player_id = player_id_str(active_player);
1363                let active_player_name = self
1364                    .latest_view
1365                    .as_ref()
1366                    .and_then(|v| v.players.iter().find(|p| p.id == player_id))
1367                    .map(|p| p.name.clone())
1368                    .unwrap_or_else(|| format!("Player {}", active_player.0));
1369                self.responder.send_log(GameLogEntryDto::from_event(
1370                    manabrew_engine::agent::GameLogEvent::rule(format!(
1371                        "TURN {} — {}",
1372                        turn_number, active_player_name
1373                    ))
1374                    .with_player(active_player),
1375                ));
1376                self.emit_display(DisplayEvent::TurnChanged {
1377                    active_player_id: player_id,
1378                    active_player_name,
1379                    turn_number,
1380                });
1381                self.emit_state();
1382            }
1383            GameNotification::PhaseChanged { .. } | GameNotification::StateChanged => {
1384                self.emit_state();
1385            }
1386            GameNotification::PriorityChanged { .. } => {
1387                self.emit_state();
1388            }
1389            GameNotification::FirstPlayerRoll {
1390                sides,
1391                rolls,
1392                winner,
1393            } => {
1394                let view = self.view();
1395                let winner_id = player_id_str(winner);
1396                let entries = rolls
1397                    .into_iter()
1398                    .map(|(pid, value)| {
1399                        let id = player_id_str(pid);
1400                        let name = view
1401                            .players
1402                            .iter()
1403                            .find(|p| p.id == id)
1404                            .map(|p| p.name.clone())
1405                            .unwrap_or_else(|| id.clone());
1406                        manabrew_protocol::prompts::dice_rolled::DiceRollEntry {
1407                            label: Some(name),
1408                            highlighted: id == winner_id,
1409                            player_id: Some(id),
1410                            natural_results: vec![value],
1411                            final_results: vec![value],
1412                            ignored_rolls: vec![],
1413                        }
1414                    })
1415                    .collect();
1416                self.present_prompt(
1417                    PromptInput::DiceRolled(
1418                        manabrew_protocol::prompts::dice_rolled::DiceRolledInput {
1419                            presentation: PromptPresentation {
1420                                title: "Roll for first player".to_string(),
1421                                description: None,
1422                                text: None,
1423                                targets: Vec::new(),
1424                            },
1425                            sides,
1426                            rolls: entries,
1427                            source_card_name: None,
1428                        },
1429                    ),
1430                    None,
1431                );
1432                // Caller is responsible for `await_display_ack` after the
1433                // full broadcast — see `roll_for_first_player`.
1434            }
1435            GameNotification::DiceRolled {
1436                player,
1437                sides,
1438                natural_results,
1439                final_results,
1440                ignored_rolls,
1441                source_card_name,
1442            } => {
1443                // Send the prompt to every agent's transport. The caller
1444                // is responsible for issuing a parallel `await_display_ack`
1445                // pass after broadcasting — that way all clients see the
1446                // animation start at the same time and we wait once for
1447                // the slowest player rather than serially per-agent.
1448                self.present_prompt(
1449                    PromptInput::DiceRolled(
1450                        manabrew_protocol::prompts::dice_rolled::DiceRolledInput {
1451                            presentation: PromptPresentation {
1452                                title: "Dice roll".to_string(),
1453                                description: None,
1454                                text: None,
1455                                targets: Vec::new(),
1456                            },
1457                            sides,
1458                            rolls: vec![manabrew_protocol::prompts::dice_rolled::DiceRollEntry {
1459                                label: None,
1460                                player_id: Some(player_id_str(player)),
1461                                natural_results,
1462                                final_results,
1463                                ignored_rolls,
1464                                highlighted: false,
1465                            }],
1466                            source_card_name,
1467                        },
1468                    ),
1469                    None,
1470                );
1471            }
1472            GameNotification::SnapshotCreated {
1473                checkpoint_id,
1474                label,
1475            } => {
1476                if let Some(view) = self.latest_view.clone() {
1477                    self.responder.send_snapshot(GameSnapshotEventDto::new(
1478                        checkpoint_id,
1479                        label,
1480                        view,
1481                    ));
1482                }
1483            }
1484            GameNotification::GameOver => {
1485                self.emit_state();
1486                self.present_prompt(
1487                    PromptInput::GameOver(manabrew_protocol::prompts::game_over::GameOverInput {}),
1488                    None,
1489                );
1490            }
1491            GameNotification::ManaPaymentResolved { .. } => {}
1492            GameNotification::ActivatedAbilityPaymentFailed { .. } => {
1493                self.emit_state();
1494            }
1495        }
1496    }
1497
1498    fn take_restore_request(&mut self) -> Option<u64> {
1499        self.pending_restore_checkpoint.take()
1500    }
1501}