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
14pub 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
62fn classify_change_zone(sa: &SpellAbility) -> TargetingIntent {
64 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
84fn 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 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
188pub 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 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 let text = card
250 .abilities
251 .iter()
252 .filter_map(|a| {
253 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 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 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 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 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 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}