Skip to main content

manabrew_agent_interface/
game_view_dto.rs

1use std::collections::{BTreeMap, HashMap};
2
3use forge_foundation::ZoneType;
4use manabrew_engine::game::GameState;
5use manabrew_engine::ids::{CardId, PlayerId};
6use manabrew_engine::mana::ManaPool;
7use manabrew_engine::spellability::SpellAbility;
8
9pub use manabrew_protocol::game::*;
10use manabrew_protocol::prompts::common::{TargetKind, TargetRef};
11
12use crate::ids_codec::{card_id_str, player_id_str, stack_id_str};
13
14/// Classify the targeting intent of a spell ability from its `ApiType`
15/// and (where needed) parameters. Falls back to `Hostile` / `Friendly`
16/// when the API type is unknown or ambiguous.
17pub fn targeting_intent_of(sa: &SpellAbility) -> TargetingIntent {
18    use manabrew_engine::ability::api_type::ApiType;
19    let Some(api) = sa.api else {
20        return TargetingIntent::Hostile;
21    };
22    match api {
23        ApiType::DealDamage | ApiType::DamageAll | ApiType::EachDamage => TargetingIntent::Damage,
24        ApiType::Destroy | ApiType::DestroyAll => TargetingIntent::Destroy,
25        ApiType::Sacrifice | ApiType::SacrificeAll => TargetingIntent::Sacrifice,
26        ApiType::ChangeZone | ApiType::ChangeZoneAll => classify_change_zone(sa),
27        ApiType::Mill => TargetingIntent::Mill,
28        ApiType::Discard => TargetingIntent::Discard,
29        ApiType::Counter => TargetingIntent::Counter,
30        ApiType::ControlSpell => TargetingIntent::GainControl,
31        ApiType::Tap | ApiType::TapAll => TargetingIntent::Tap,
32        ApiType::Untap | ApiType::UntapAll => TargetingIntent::Untap,
33        ApiType::TapOrUntap | ApiType::TapOrUntapAll => TargetingIntent::Tap,
34        ApiType::CopyPermanent | ApiType::CopySpellAbility | ApiType::Clone => {
35            TargetingIntent::Copy
36        }
37        ApiType::Pump
38        | ApiType::PumpAll
39        | ApiType::Animate
40        | ApiType::AnimateAll
41        | ApiType::Protection
42        | ApiType::ProtectionAll => TargetingIntent::Buff,
43        ApiType::PutCounter | ApiType::PutCounterAll => classify_put_counter(sa),
44        ApiType::RemoveCounter | ApiType::RemoveCounterAll => TargetingIntent::Debuff,
45        ApiType::Debuff => TargetingIntent::Debuff,
46        ApiType::GainLife => TargetingIntent::Heal,
47        ApiType::LoseLife => TargetingIntent::LoseLife,
48        ApiType::Draw => TargetingIntent::Draw,
49        ApiType::Reveal | ApiType::RevealHand | ApiType::LookAt | ApiType::PeekAndReveal => {
50            TargetingIntent::Reveal
51        }
52        ApiType::GainControl
53        | ApiType::GainControlVariant
54        | ApiType::ExchangeControl
55        | ApiType::ExchangeControlVariant => TargetingIntent::GainControl,
56        ApiType::Fight => TargetingIntent::Fight,
57        ApiType::Attach | ApiType::Unattach => TargetingIntent::Attach,
58        _ => TargetingIntent::Hostile,
59    }
60}
61
62/// Distinguish Exile vs Bounce vs generic Hostile for ChangeZone effects.
63fn classify_change_zone(sa: &SpellAbility) -> TargetingIntent {
64    // Returning a card out of the graveyard/exile is recursion of your own
65    // cards (regrowth, reanimate), not a hostile bounce/blink.
66    let from_dead = matches!(
67        sa.ir.origin_zone,
68        Some(ZoneType::Graveyard) | Some(ZoneType::Exile)
69    );
70    match sa.ir.destination_zone {
71        Some(ZoneType::Hand) | Some(ZoneType::Library) | Some(ZoneType::Battlefield)
72            if from_dead =>
73        {
74            TargetingIntent::Friendly
75        }
76        Some(ZoneType::Exile) => TargetingIntent::Exile,
77        Some(ZoneType::Hand) | Some(ZoneType::Library) => TargetingIntent::Bounce,
78        Some(ZoneType::Graveyard) => TargetingIntent::Destroy,
79        Some(ZoneType::Battlefield) => TargetingIntent::Friendly,
80        _ => TargetingIntent::Hostile,
81    }
82}
83
84/// PutCounter effects can be buffs (+1/+1) or debuffs (-1/-1) depending on
85/// the counter type. Default to Buff since most targeted put-counter
86/// effects place positive counters.
87fn classify_put_counter(sa: &SpellAbility) -> TargetingIntent {
88    match sa.ir.counter_type.as_ref() {
89        Some(manabrew_engine::card::CounterType::M1M1) => TargetingIntent::Debuff,
90        Some(_) => TargetingIntent::Buff,
91        None => {
92            let counter_type = sa.ir.counter_type_text.as_deref().unwrap_or("");
93            if counter_type.starts_with("M1M1") || counter_type.contains("-1/-1") {
94                TargetingIntent::Debuff
95            } else {
96                TargetingIntent::Buff
97            }
98        }
99    }
100}
101
102pub fn intent_is_hostile(intent: TargetingIntent) -> bool {
103    matches!(
104        intent,
105        TargetingIntent::Damage
106            | TargetingIntent::Destroy
107            | TargetingIntent::Sacrifice
108            | TargetingIntent::Exile
109            | TargetingIntent::Bounce
110            | TargetingIntent::Mill
111            | TargetingIntent::Discard
112            | TargetingIntent::Counter
113            | TargetingIntent::Tap
114            | TargetingIntent::Debuff
115            | TargetingIntent::LoseLife
116            | TargetingIntent::GainControl
117            | TargetingIntent::Fight
118            | TargetingIntent::Hostile
119    )
120}
121
122/// Determine if a spell ability's effect is hostile based on its API type.
123/// Kept for backwards compatibility; new code should use `targeting_intent_of`.
124pub fn is_hostile_api(sa: &SpellAbility) -> bool {
125    intent_is_hostile(targeting_intent_of(sa))
126}
127
128fn collect_stack_targets(root: &SpellAbility) -> Vec<TargetRef> {
129    let mut out = Vec::new();
130    let mut current = Some(root);
131
132    while let Some(sa) = current {
133        let intent = targeting_intent_of(sa);
134        let oracle = stack_target_oracle(sa);
135
136        if let Some(cid) = sa.target_chosen.target_card {
137            out.push(TargetRef {
138                kind: TargetKind::Card,
139                id: card_id_str(cid),
140                intent: Some(intent),
141                oracle: oracle.clone(),
142            });
143        }
144        if let Some(pid) = sa.target_chosen.target_player {
145            out.push(TargetRef {
146                kind: TargetKind::Player,
147                id: player_id_str(pid),
148                intent: Some(intent),
149                oracle: oracle.clone(),
150            });
151        }
152        if let Some(stack_id) = sa.target_chosen.target_stack_entry {
153            out.push(TargetRef {
154                kind: TargetKind::Spell,
155                id: stack_id_str(stack_id),
156                intent: Some(intent),
157                oracle: oracle.clone(),
158            });
159        }
160
161        current = sa.sub_ability.as_deref();
162    }
163
164    out
165}
166
167fn stack_target_oracle(sa: &SpellAbility) -> Option<String> {
168    let desc = if !sa.stack_description.trim().is_empty() {
169        sa.stack_description.trim()
170    } else if !sa.description.trim().is_empty() {
171        sa.description.trim()
172    } else {
173        return None;
174    };
175    Some(desc.to_string())
176}
177
178fn mana_pool_to_map(pool: &ManaPool) -> BTreeMap<ManaColor, u32> {
179    let mut m = BTreeMap::new();
180    for (color, amount) in [
181        (ManaColor::White, pool.white()),
182        (ManaColor::Blue, pool.blue()),
183        (ManaColor::Black, pool.black()),
184        (ManaColor::Red, pool.red()),
185        (ManaColor::Green, pool.green()),
186        (ManaColor::Colorless, pool.colorless()),
187    ] {
188        if amount > 0 {
189            m.insert(color, amount as u32);
190        }
191    }
192    m
193}
194
195fn phase_to_step(phase: forge_foundation::PhaseType) -> StepKind {
196    use forge_foundation::PhaseType::*;
197    match phase {
198        Untap => StepKind::Untap,
199        Upkeep => StepKind::Upkeep,
200        Draw => StepKind::Draw,
201        Main1 => StepKind::Main1,
202        CombatBegin => StepKind::CombatBegin,
203        CombatDeclareAttackers => StepKind::CombatDeclareAttackers,
204        CombatDeclareBlockers => StepKind::CombatDeclareBlockers,
205        CombatFirstStrikeDamage => StepKind::CombatFirstStrikeDamage,
206        CombatDamage => StepKind::CombatDamage,
207        CombatEnd => StepKind::CombatEnd,
208        Main2 => StepKind::Main2,
209        EndOfTurn => StepKind::EndOfTurn,
210        Cleanup => StepKind::Cleanup,
211    }
212}
213
214pub(crate) fn step_to_phase(step: StepKind) -> forge_foundation::PhaseType {
215    use forge_foundation::PhaseType::*;
216    match step {
217        StepKind::Untap => Untap,
218        StepKind::Upkeep => Upkeep,
219        StepKind::Draw => Draw,
220        StepKind::Main1 => Main1,
221        StepKind::CombatBegin => CombatBegin,
222        StepKind::CombatDeclareAttackers => CombatDeclareAttackers,
223        StepKind::CombatDeclareBlockers => CombatDeclareBlockers,
224        StepKind::CombatFirstStrikeDamage => CombatFirstStrikeDamage,
225        StepKind::CombatDamage => CombatDamage,
226        StepKind::CombatEnd => CombatEnd,
227        StepKind::Main2 => Main2,
228        StepKind::EndOfTurn => EndOfTurn,
229        StepKind::Cleanup => Cleanup,
230    }
231}
232
233pub fn zone_kind_of(zone: ZoneType) -> ZoneKind {
234    match zone {
235        ZoneType::Hand | ZoneType::ExtraHand => ZoneKind::Hand,
236        ZoneType::Graveyard | ZoneType::Flashback => ZoneKind::Graveyard,
237        ZoneType::Battlefield | ZoneType::Merged => ZoneKind::Battlefield,
238        ZoneType::Exile => ZoneKind::Exile,
239        ZoneType::Command => ZoneKind::Command,
240        _ => ZoneKind::Library,
241    }
242}
243
244pub fn target_ref_card(id: String) -> TargetRef {
245    TargetRef {
246        kind: TargetKind::Card,
247        id,
248        intent: None,
249        oracle: None,
250    }
251}
252
253pub fn target_ref_player(id: String) -> TargetRef {
254    TargetRef {
255        kind: TargetKind::Player,
256        id,
257        intent: None,
258        oracle: None,
259    }
260}
261
262pub fn target_ref_spell(id: String) -> TargetRef {
263    TargetRef {
264        kind: TargetKind::Spell,
265        id,
266        intent: None,
267        oracle: None,
268    }
269}
270
271fn day_time_of(game: &GameState) -> DayTime {
272    if game.is_neither_day_nor_night() {
273        DayTime::Neither
274    } else if game.is_night {
275        DayTime::Night
276    } else {
277        DayTime::Day
278    }
279}
280
281fn should_show_command_zone_card(game: &GameState, cid: CardId) -> bool {
282    let card = game.card(cid);
283    !(card.type_line.core_types.is_empty()
284        && card
285            .type_line
286            .subtypes
287            .iter()
288            .any(|subtype| subtype.eq_ignore_ascii_case("Effect")))
289}
290
291pub fn card_to_dto(game: &GameState, cid: CardId) -> CardDto {
292    let card = game.card(cid);
293    let types: Vec<String> = card
294        .type_line
295        .core_types
296        .iter()
297        .map(|ct| ct.name().to_string())
298        .collect();
299    let subtypes: Vec<String> = card.type_line.subtypes.clone();
300    let supertypes: Vec<String> = card
301        .type_line
302        .supertypes
303        .iter()
304        .map(|st| st.name().to_string())
305        .collect();
306
307    let power = card.base_power.map(|_| card.power().to_string());
308    let toughness = card.base_toughness.map(|_| card.toughness().to_string());
309    let base_power = card.base_power;
310    let base_toughness = card.base_toughness;
311
312    // Collect non-zero counters, using the variant name as key (e.g. "P1P1", "M1M1", "Loyalty")
313    let counters: BTreeMap<String, u32> = card
314        .counters
315        .iter()
316        .filter(|(_, &v)| v > 0)
317        .map(|(k, &v)| (format!("{k:?}"), v as u32))
318        .collect();
319
320    // Build ability text from abilities
321    let text = card
322        .abilities
323        .iter()
324        .filter_map(|a| {
325            // Extract SpellDescription$ if present
326            for part in a.split('|') {
327                let part = part.trim();
328                if let Some(desc) = part.strip_prefix("SpellDescription$ ") {
329                    return Some(desc.to_string());
330                }
331            }
332            None
333        })
334        .collect::<Vec<_>>()
335        .join("\n");
336
337    // Face-down cards show as nameless 2/2 creatures with no info
338    let morph_pt = manabrew_engine::spellability::MORPH_PT.to_string();
339    let (
340        name,
341        types,
342        subtypes,
343        supertypes,
344        power,
345        toughness,
346        base_power,
347        base_toughness,
348        text,
349        color,
350        mana_cost_str,
351        cmc,
352    ) = if card.face_down && card.zone == ZoneType::Battlefield {
353        (
354            "Face-down creature".to_string(),
355            vec!["Creature".to_string()],
356            vec![],
357            vec![],
358            Some(morph_pt.clone()),
359            Some(morph_pt),
360            None,
361            None,
362            String::new(),
363            String::new(),
364            String::new(),
365            0,
366        )
367    } else {
368        (
369            card.card_name.clone(),
370            types,
371            subtypes,
372            supertypes,
373            power,
374            toughness,
375            base_power,
376            base_toughness,
377            text,
378            card.color.to_string(),
379            card.mana_cost.to_string(),
380            card.mana_cost.cmc(),
381        )
382    };
383
384    CardDto {
385        id: card_id_str(cid),
386        identity: CardIdentity {
387            name,
388            set_code: card.set_code.clone().unwrap_or_default(),
389            card_number: card.card_number.clone().unwrap_or_default(),
390            is_token: card.is_token,
391        },
392        color,
393        mana_cost: mana_cost_str,
394        cmc,
395        types,
396        subtypes,
397        supertypes,
398        power,
399        toughness,
400        base_power,
401        base_toughness,
402        text,
403        controller_id: player_id_str(card.controller),
404        owner_id: player_id_str(card.owner),
405        tapped: card.tapped,
406        is_crewed: card.is_crewed,
407        is_attacking: card.attacking_player.is_some(),
408        attacking_player_id: card.attacking_player.map(player_id_str),
409        attack_target_id: None,
410        // Merge intrinsic keywords with those granted by continuous effects (layer 6)
411        // and temporary pump keywords (KW$ parameter, until end of turn).
412        keywords: {
413            let mut all_kw = card.keywords.as_string_list();
414            for k in card
415                .granted_keywords
416                .iter_strings()
417                .chain(card.pump_keywords.iter_strings())
418            {
419                if !all_kw.iter().any(|e| e.eq_ignore_ascii_case(k)) {
420                    all_kw.push(k.to_string());
421                }
422            }
423            all_kw
424        },
425        counters,
426        damage: card.damage,
427        summoning_sick: card.summoning_sick && !card.has_haste(),
428        is_copy: card.copied_permanent.is_some(),
429        is_double_faced: card.other_part.is_some(),
430        flashback_cost: card.get_flashback_cost(),
431        kicker_cost: card.get_kicker_cost(),
432        is_transformed: card.is_transformed,
433        is_face_down: card.face_down,
434        is_bestowed: card.is_bestowed,
435        attached_to: card.attached_to.map(card_id_str),
436        attachment_ids: card
437            .attachments
438            .iter()
439            .map(|&aid| card_id_str(aid))
440            .collect(),
441        merged_card_ids: card
442            .melded_with
443            .iter()
444            .map(|&mid| card_id_str(mid))
445            .collect(),
446        phased_out: card.phased_out,
447        exerted: card.exerted,
448        is_ring_bearer: game.player(card.controller).ring_bearer == Some(cid),
449        effective_mana_cost: {
450            let is_command_zone_commander =
451                card.zone == ZoneType::Command && game.player_is_commander(card.controller, cid);
452            if is_command_zone_commander && !card.is_land() {
453                let cost_adj = manabrew_engine::staticability::static_ability_cost_change::compute_cost_adjustment(
454                    game, card, card.controller, card.zone,
455                );
456                let mut adjusted = if !cost_adj.is_empty() {
457                    cost_adj.apply(&card.mana_cost)
458                } else {
459                    card.mana_cost.clone()
460                };
461
462                if is_command_zone_commander {
463                    let commander_tax = game.player_commander_tax(card.controller, cid);
464                    if commander_tax > 0 {
465                        adjusted =
466                            adjusted.add(&forge_foundation::ManaCost::generic(commander_tax));
467                    }
468                }
469
470                let adjusted_str = adjusted.to_string();
471                if adjusted_str != card.mana_cost.to_string() {
472                    Some(adjusted_str)
473                } else {
474                    None
475                }
476            } else {
477                None
478            }
479        },
480        madness_cost: card.get_madness_cost(),
481        is_madness_exiled: card.zone == forge_foundation::ZoneType::Exile
482            && card.get_madness_cost().is_some(),
483        is_plotted: card
484            .keywords
485            .iter_strings()
486            .chain(card.granted_keywords.iter_strings())
487            .any(|kw| kw.starts_with(manabrew_engine::card::KEYWORD_PLOTTED_PREFIX)),
488        is_warp_exiled: card.has_keyword(manabrew_engine::card::KEYWORD_WARP_EXILED),
489        foil: card.paper_foil,
490        // Combat death prediction is computed by the Forge harness only; the
491        // Rust engine doesn't surface it yet.
492        would_die_in_combat: false,
493    }
494}
495
496pub trait GameViewDtoExt {
497    fn from_engine(
498        game: &GameState,
499        mana_pools: &[ManaPool],
500        human_player: PlayerId,
501        game_id: &str,
502    ) -> Self;
503
504    fn all_zone_cards(&self) -> impl Iterator<Item = &CardDto>;
505}
506
507impl GameViewDtoExt for GameViewDto {
508    fn from_engine(
509        game: &GameState,
510        mana_pools: &[ManaPool],
511        human_player: PlayerId,
512        game_id: &str,
513    ) -> Self {
514        let mut players = Vec::new();
515        let mut zones: Vec<ZoneDto> = Vec::new();
516        let visible_zone = |zone: ZoneType, kind: ZoneKind, pid: PlayerId| -> ZoneDto {
517            let cards: Vec<CardView> = game
518                .cards_in_zone(zone, pid)
519                .iter()
520                .map(|&cid| CardView::Visible(card_to_dto(game, cid)))
521                .collect();
522            let count = cards.len();
523            ZoneDto {
524                zone: kind,
525                owner_id: player_id_str(pid),
526                cards,
527                count,
528            }
529        };
530        for &pid in &game.player_order {
531            let ps = game.player(pid);
532            let pool = mana_pools.get(pid.index()).cloned().unwrap_or_default();
533            let commander_damage: HashMap<String, i32> = ps
534                .commander_damage_received
535                .iter()
536                .map(|(&card_raw_id, &dmg)| (card_id_str(CardId(card_raw_id)), dmg))
537                .collect();
538
539            zones.push(visible_zone(ZoneType::Hand, ZoneKind::Hand, pid));
540            zones.push(visible_zone(ZoneType::Graveyard, ZoneKind::Graveyard, pid));
541            zones.push(visible_zone(ZoneType::Exile, ZoneKind::Exile, pid));
542            let command_cards: Vec<CardView> = game
543                .cards_in_zone(ZoneType::Command, pid)
544                .iter()
545                .copied()
546                .filter(|&cid| should_show_command_zone_card(game, cid))
547                .map(|cid| CardView::Visible(card_to_dto(game, cid)))
548                .collect();
549            zones.push(ZoneDto {
550                zone: ZoneKind::Command,
551                owner_id: player_id_str(pid),
552                count: command_cards.len(),
553                cards: command_cards,
554            });
555            // Library bulk is hidden; only the count is public.
556            zones.push(ZoneDto {
557                zone: ZoneKind::Library,
558                owner_id: player_id_str(pid),
559                cards: Vec::new(),
560                count: game.cards_in_zone(ZoneType::Library, pid).len(),
561            });
562
563            let mut counters = BTreeMap::new();
564            for (kind, value) in [
565                (PlayerCounterKind::Poison, ps.poison_counters),
566                (PlayerCounterKind::Energy, ps.energy_counters),
567                (PlayerCounterKind::Radiation, ps.radiation_counters),
568            ] {
569                if value > 0 {
570                    counters.insert(kind, value as u32);
571                }
572            }
573
574            players.push(PlayerDto {
575                id: player_id_str(pid),
576                name: ps.name.clone(),
577                status: if ps.has_conceded {
578                    PlayerStatus::Conceded
579                } else if ps.has_lost {
580                    PlayerStatus::Lost
581                } else {
582                    PlayerStatus::Playing
583                },
584                is_human: pid == human_player,
585                life: ps.life,
586                counters,
587                mana_pool: mana_pool_to_map(&pool),
588                commander_damage,
589                has_city_blessing: ps.has_city_blessing,
590                ring_level: ps.ring_level,
591                speed: ps.speed,
592            });
593        }
594
595        // Battlefield -- bucketed by controller.
596        let mut battlefield_by_controller: HashMap<String, Vec<CardView>> = HashMap::new();
597        for &owner in &game.player_order {
598            for &cid in game.cards_in_zone(ZoneType::Battlefield, owner) {
599                let controller_id = player_id_str(game.card(cid).controller);
600                battlefield_by_controller
601                    .entry(controller_id)
602                    .or_default()
603                    .push(CardView::Visible(card_to_dto(game, cid)));
604            }
605        }
606        for &pid in &game.player_order {
607            let owner_id = player_id_str(pid);
608            let cards = battlefield_by_controller
609                .remove(&owner_id)
610                .unwrap_or_default();
611            zones.push(ZoneDto {
612                zone: ZoneKind::Battlefield,
613                owner_id,
614                count: cards.len(),
615                cards,
616            });
617        }
618
619        // Stack
620        let stack: Vec<StackObjectDto> = game
621            .stack
622            .iter()
623            .map(|entry| {
624                let source_card = entry.spell_ability.source.map(|cid| game.card(cid));
625                let identity = CardIdentity {
626                    name: source_card
627                        .map(|c| c.card_name.clone())
628                        .unwrap_or_else(|| "Ability".to_string()),
629                    set_code: source_card
630                        .and_then(|c| c.set_code.clone())
631                        .unwrap_or_default(),
632                    card_number: source_card
633                        .and_then(|c| c.card_number.clone())
634                        .unwrap_or_default(),
635                    is_token: source_card.map(|c| c.is_token).unwrap_or(false),
636                };
637                StackObjectDto {
638                    id: format!("stack-{}", entry.id),
639                    source_id: entry
640                        .spell_ability
641                        .source
642                        .map(card_id_str)
643                        .unwrap_or_default(),
644                    controller_id: player_id_str(entry.spell_ability.activating_player),
645                    identity,
646                    text: entry.spell_ability.ability_text.clone(),
647                    is_permanent_spell: entry.is_creature_spell || entry.is_permanent_spell,
648                    is_casting: entry.is_pending_cast,
649                    targets: collect_stack_targets(&entry.spell_ability),
650                }
651            })
652            .collect();
653
654        GameViewDto {
655            game_id: game_id.to_string(),
656            turn: game.turn.turn_number,
657            step: phase_to_step(game.turn.phase),
658            combat_assignments: game
659                .turn
660                .combat_block_assignments
661                .iter()
662                .map(|(blocker, attacker)| CombatAssignmentDto {
663                    blocker_id: card_id_str(*blocker),
664                    attacker_id: card_id_str(*attacker),
665                })
666                .collect(),
667            active_player_id: player_id_str(game.active_player()),
668            priority_player_id: player_id_str(game.turn.priority_player),
669            players,
670            zones,
671            stack,
672            game_over: game.game_over,
673            winner_id: game.winner.map(player_id_str),
674            monarch_id: game.monarch.map(player_id_str),
675            initiative_holder_id: game.initiative_holder.map(player_id_str),
676            day_time: day_time_of(game),
677        }
678    }
679
680    fn all_zone_cards(&self) -> impl Iterator<Item = &CardDto> {
681        self.zones.iter().flat_map(|zone| {
682            zone.cards.iter().filter_map(|card| match card {
683                CardView::Visible(dto) => Some(dto),
684                CardView::Hidden { .. } => None,
685            })
686        })
687    }
688}