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
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::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
89fn 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
127pub 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 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 let text = card
327 .abilities
328 .iter()
329 .filter_map(|a| {
330 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 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 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 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 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 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 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}