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