Skip to main content

manabrew_agent_interface/
game_view_dto.rs

1use std::collections::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
102/// Determine if a spell ability's effect is hostile based on its API type.
103/// Kept for backwards compatibility; new code should use `targeting_intent_of`.
104pub fn is_hostile_api(sa: &SpellAbility) -> bool {
105    targeting_intent_of(sa).is_hostile()
106}
107
108fn collect_stack_targets(root: &SpellAbility) -> Vec<TargetRef> {
109    let mut out = Vec::new();
110    let mut current = Some(root);
111
112    while let Some(sa) = current {
113        let intent = targeting_intent_of(sa);
114        let oracle = stack_target_oracle(sa);
115
116        if let Some(cid) = sa.target_chosen.target_card {
117            out.push(TargetRef {
118                kind: TargetKind::Card,
119                id: card_id_str(cid),
120                intent: Some(intent),
121                oracle: oracle.clone(),
122            });
123        }
124        if let Some(pid) = sa.target_chosen.target_player {
125            out.push(TargetRef {
126                kind: TargetKind::Player,
127                id: player_id_str(pid),
128                intent: Some(intent),
129                oracle: oracle.clone(),
130            });
131        }
132        if let Some(stack_id) = sa.target_chosen.target_stack_entry {
133            out.push(TargetRef {
134                kind: TargetKind::Spell,
135                id: stack_id_str(stack_id),
136                intent: Some(intent),
137                oracle: oracle.clone(),
138            });
139        }
140
141        current = sa.sub_ability.as_deref();
142    }
143
144    out
145}
146
147fn stack_target_oracle(sa: &SpellAbility) -> Option<String> {
148    let desc = if !sa.stack_description.trim().is_empty() {
149        sa.stack_description.trim()
150    } else if !sa.description.trim().is_empty() {
151        sa.description.trim()
152    } else {
153        return None;
154    };
155    Some(desc.to_string())
156}
157
158fn mana_pool_to_map(pool: &ManaPool) -> HashMap<String, i32> {
159    let mut m = HashMap::new();
160    m.insert("W".into(), pool.white());
161    m.insert("U".into(), pool.blue());
162    m.insert("B".into(), pool.black());
163    m.insert("R".into(), pool.red());
164    m.insert("G".into(), pool.green());
165    m.insert("C".into(), pool.colorless());
166    m
167}
168
169fn phase_to_step(phase: forge_foundation::PhaseType) -> &'static str {
170    use forge_foundation::PhaseType::*;
171    match phase {
172        Untap => "untap",
173        Upkeep => "upkeep",
174        Draw => "draw",
175        Main1 => "main1",
176        CombatBegin => "begin_combat",
177        CombatDeclareAttackers => "declare_attackers",
178        CombatDeclareBlockers => "declare_blockers",
179        CombatFirstStrikeDamage => "first_strike_damage",
180        CombatDamage => "combat_damage",
181        CombatEnd => "end_combat",
182        Main2 => "main2",
183        EndOfTurn => "end",
184        Cleanup => "cleanup",
185    }
186}
187
188/// Parse a frontend step string back to a PhaseType.
189pub fn step_to_phase(step: &str) -> Option<forge_foundation::PhaseType> {
190    use forge_foundation::PhaseType::*;
191    match step {
192        "untap" => Some(Untap),
193        "upkeep" => Some(Upkeep),
194        "draw" => Some(Draw),
195        "main1" => Some(Main1),
196        "begin_combat" => Some(CombatBegin),
197        "declare_attackers" => Some(CombatDeclareAttackers),
198        "declare_blockers" => Some(CombatDeclareBlockers),
199        "first_strike_damage" => Some(CombatFirstStrikeDamage),
200        "combat_damage" => Some(CombatDamage),
201        "end_combat" => Some(CombatEnd),
202        "main2" => Some(Main2),
203        "end" => Some(EndOfTurn),
204        "cleanup" => Some(Cleanup),
205        _ => None,
206    }
207}
208
209fn should_show_command_zone_card(game: &GameState, cid: CardId) -> bool {
210    let card = game.card(cid);
211    !(card.type_line.core_types.is_empty()
212        && card
213            .type_line
214            .subtypes
215            .iter()
216            .any(|subtype| subtype.eq_ignore_ascii_case("Effect")))
217}
218
219pub fn card_to_dto(game: &GameState, cid: CardId, zone_label: &str) -> CardDto {
220    let card = game.card(cid);
221    let types: Vec<String> = card
222        .type_line
223        .core_types
224        .iter()
225        .map(|ct| ct.name().to_string())
226        .collect();
227    let subtypes: Vec<String> = card.type_line.subtypes.clone();
228    let supertypes: Vec<String> = card
229        .type_line
230        .supertypes
231        .iter()
232        .map(|st| st.name().to_string())
233        .collect();
234
235    let power = card.base_power.map(|_| card.power().to_string());
236    let toughness = card.base_toughness.map(|_| card.toughness().to_string());
237    let base_power = card.base_power;
238    let base_toughness = card.base_toughness;
239
240    // Collect non-zero counters, using the variant name as key (e.g. "P1P1", "M1M1", "Loyalty")
241    let counters: HashMap<String, i32> = card
242        .counters
243        .iter()
244        .filter(|(_, &v)| v > 0)
245        .map(|(k, &v)| (format!("{k:?}"), v))
246        .collect();
247
248    // Build ability text from abilities
249    let text = card
250        .abilities
251        .iter()
252        .filter_map(|a| {
253            // Extract SpellDescription$ if present
254            for part in a.split('|') {
255                let part = part.trim();
256                if let Some(desc) = part.strip_prefix("SpellDescription$ ") {
257                    return Some(desc.to_string());
258                }
259            }
260            None
261        })
262        .collect::<Vec<_>>()
263        .join("\n");
264
265    // Face-down cards show as nameless 2/2 creatures with no info
266    let morph_pt = manabrew_engine::spellability::MORPH_PT.to_string();
267    let (
268        name,
269        types,
270        subtypes,
271        supertypes,
272        power,
273        toughness,
274        base_power,
275        base_toughness,
276        text,
277        color,
278        mana_cost_str,
279        cmc,
280    ) = if card.face_down && card.zone == ZoneType::Battlefield {
281        (
282            "Face-down creature".to_string(),
283            vec!["Creature".to_string()],
284            vec![],
285            vec![],
286            Some(morph_pt.clone()),
287            Some(morph_pt),
288            None,
289            None,
290            String::new(),
291            String::new(),
292            String::new(),
293            0,
294        )
295    } else {
296        (
297            card.card_name.clone(),
298            types,
299            subtypes,
300            supertypes,
301            power,
302            toughness,
303            base_power,
304            base_toughness,
305            text,
306            card.color.to_string(),
307            card.mana_cost.to_string(),
308            card.mana_cost.cmc(),
309        )
310    };
311
312    CardDto {
313        id: card_id_str(cid),
314        identity: CardIdentity {
315            name,
316            set_code: card.set_code.clone().unwrap_or_default(),
317            card_number: card.card_number.clone().unwrap_or_default(),
318            is_token: card.is_token,
319        },
320        color,
321        mana_cost: mana_cost_str,
322        cmc,
323        types,
324        subtypes,
325        supertypes,
326        power,
327        toughness,
328        base_power,
329        base_toughness,
330        text,
331        controller_id: player_id_str(card.controller),
332        owner_id: player_id_str(card.owner),
333        zone_id: zone_label.to_string(),
334        tapped: card.tapped,
335        is_crewed: card.is_crewed,
336        is_attacking: card.attacking_player.is_some(),
337        attacking_player_id: card.attacking_player.map(player_id_str),
338        attack_target_id: None,
339        // Merge intrinsic keywords with those granted by continuous effects (layer 6)
340        // and temporary pump keywords (KW$ parameter, until end of turn).
341        keywords: {
342            let mut all_kw = card.keywords.as_string_list();
343            for k in card
344                .granted_keywords
345                .iter_strings()
346                .chain(card.pump_keywords.iter_strings())
347            {
348                if !all_kw.iter().any(|e| e.eq_ignore_ascii_case(k)) {
349                    all_kw.push(k.to_string());
350                }
351            }
352            all_kw
353        },
354        counters,
355        damage: card.damage,
356        summoning_sick: card.summoning_sick && !card.has_haste(),
357        is_copy: card.copied_permanent.is_some(),
358        is_double_faced: card.other_part.is_some(),
359        flashback_cost: card.get_flashback_cost(),
360        kicker_cost: card.get_kicker_cost(),
361        is_transformed: card.is_transformed,
362        is_face_down: card.face_down,
363        is_bestowed: card.is_bestowed,
364        attached_to: card.attached_to.map(card_id_str),
365        attachment_ids: card
366            .attachments
367            .iter()
368            .map(|&aid| card_id_str(aid))
369            .collect(),
370        phased_out: card.phased_out,
371        exerted: card.exerted,
372        is_ring_bearer: game.player(card.controller).ring_bearer == Some(cid),
373        effective_mana_cost: {
374            let is_command_zone_commander =
375                card.zone == ZoneType::Command && game.player_is_commander(card.controller, cid);
376            if is_command_zone_commander && !card.is_land() {
377                let cost_adj = manabrew_engine::staticability::static_ability_cost_change::compute_cost_adjustment(
378                    game, card, card.controller, card.zone,
379                );
380                let mut adjusted = if !cost_adj.is_empty() {
381                    cost_adj.apply(&card.mana_cost)
382                } else {
383                    card.mana_cost.clone()
384                };
385
386                if is_command_zone_commander {
387                    let commander_tax = game.player_commander_tax(card.controller, cid);
388                    if commander_tax > 0 {
389                        adjusted =
390                            adjusted.add(&forge_foundation::ManaCost::generic(commander_tax));
391                    }
392                }
393
394                let adjusted_str = adjusted.to_string();
395                if adjusted_str != card.mana_cost.to_string() {
396                    Some(adjusted_str)
397                } else {
398                    None
399                }
400            } else {
401                None
402            }
403        },
404        madness_cost: card.get_madness_cost(),
405        is_madness_exiled: card.zone == forge_foundation::ZoneType::Exile
406            && card.get_madness_cost().is_some(),
407        is_plotted: card
408            .keywords
409            .iter_strings()
410            .chain(card.granted_keywords.iter_strings())
411            .any(|kw| kw.starts_with(manabrew_engine::card::KEYWORD_PLOTTED_PREFIX)),
412        is_warp_exiled: card.has_keyword(manabrew_engine::card::KEYWORD_WARP_EXILED),
413        foil: card.paper_foil,
414        // Combat death prediction is computed by the Forge harness only; the
415        // Rust engine doesn't surface it yet.
416        would_die_in_combat: false,
417    }
418}
419
420pub trait GameViewDtoExt {
421    fn from_engine(
422        game: &GameState,
423        mana_pools: &[ManaPool],
424        human_player: PlayerId,
425        game_id: &str,
426    ) -> Self;
427}
428
429impl GameViewDtoExt for GameViewDto {
430    fn from_engine(
431        game: &GameState,
432        mana_pools: &[ManaPool],
433        human_player: PlayerId,
434        game_id: &str,
435    ) -> Self {
436        let mut players = Vec::new();
437        for &pid in &game.player_order {
438            let ps = game.player(pid);
439            let pool = mana_pools.get(pid.index()).cloned().unwrap_or_default();
440            let commander_damage: HashMap<String, i32> = ps
441                .commander_damage_received
442                .iter()
443                .map(|(&card_raw_id, &dmg)| (card_id_str(CardId(card_raw_id)), dmg))
444                .collect();
445            let zone_cards = |zone: ZoneType, zone_name: &str| -> Vec<CardDto> {
446                game.cards_in_zone(zone, pid)
447                    .iter()
448                    .map(|&cid| card_to_dto(game, cid, zone_name))
449                    .collect()
450            };
451            let command_zone: Vec<CardDto> = game
452                .cards_in_zone(ZoneType::Command, pid)
453                .iter()
454                .copied()
455                .filter(|&cid| should_show_command_zone_card(game, cid))
456                .map(|cid| card_to_dto(game, cid, "command"))
457                .collect();
458            players.push(PlayerDto {
459                id: player_id_str(pid),
460                name: ps.name.clone(),
461                status: if ps.has_conceded {
462                    PlayerStatus::Conceded
463                } else if ps.has_lost {
464                    PlayerStatus::Lost
465                } else {
466                    PlayerStatus::Playing
467                },
468                is_human: pid == human_player,
469                life: ps.life,
470                poison: ps.poison_counters,
471                hand: zone_cards(ZoneType::Hand, "hand"),
472                graveyard: zone_cards(ZoneType::Graveyard, "graveyard"),
473                exile: zone_cards(ZoneType::Exile, "exile"),
474                command_zone,
475                library_count: game.cards_in_zone(ZoneType::Library, pid).len(),
476                mana_pool: mana_pool_to_map(&pool),
477                commander_damage,
478                energy_counters: ps.energy_counters,
479                radiation_counters: ps.radiation_counters,
480                has_city_blessing: ps.has_city_blessing,
481                ring_level: ps.ring_level,
482                speed: ps.speed,
483                experience_counters: 0,
484                ticket_counters: 0,
485            });
486        }
487
488        // Battlefield -- all players
489        let mut battlefield = Vec::new();
490        for &pid in &game.player_order {
491            for &cid in game.cards_in_zone(ZoneType::Battlefield, pid) {
492                battlefield.push(card_to_dto(game, cid, "battlefield"));
493            }
494        }
495
496        // Stack
497        let stack: Vec<StackObjectDto> = game
498            .stack
499            .iter()
500            .map(|entry| {
501                let source_card = entry.spell_ability.source.map(|cid| game.card(cid));
502                let identity = CardIdentity {
503                    name: source_card
504                        .map(|c| c.card_name.clone())
505                        .unwrap_or_else(|| "Ability".to_string()),
506                    set_code: source_card
507                        .and_then(|c| c.set_code.clone())
508                        .unwrap_or_default(),
509                    card_number: source_card
510                        .and_then(|c| c.card_number.clone())
511                        .unwrap_or_default(),
512                    is_token: source_card.map(|c| c.is_token).unwrap_or(false),
513                };
514                StackObjectDto {
515                    id: format!("stack-{}", entry.id),
516                    source_id: entry
517                        .spell_ability
518                        .source
519                        .map(card_id_str)
520                        .unwrap_or_default(),
521                    controller_id: player_id_str(entry.spell_ability.activating_player),
522                    identity,
523                    text: entry.spell_ability.ability_text.clone(),
524                    is_permanent_spell: entry.is_creature_spell || entry.is_permanent_spell,
525                    is_casting: entry.is_pending_cast,
526                    targets: collect_stack_targets(&entry.spell_ability),
527                }
528            })
529            .collect();
530
531        GameViewDto {
532            game_id: game_id.to_string(),
533            turn: game.turn.turn_number,
534            step: phase_to_step(game.turn.phase).to_string(),
535            combat_assignments: game
536                .turn
537                .combat_block_assignments
538                .iter()
539                .map(|(blocker, attacker)| CombatAssignmentDto {
540                    blocker_id: card_id_str(*blocker),
541                    attacker_id: card_id_str(*attacker),
542                })
543                .collect(),
544            active_player_id: player_id_str(game.active_player()),
545            priority_player_id: player_id_str(game.turn.priority_player),
546            players,
547            battlefield,
548            stack,
549            game_over: game.game_over,
550            winner_id: game.winner.map(player_id_str),
551            monarch_id: game.monarch.map(player_id_str),
552            initiative_holder_id: game.initiative_holder.map(player_id_str),
553        }
554    }
555}