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