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