Skip to main content

manabrew_agent_interface/
game_view_dto.rs

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