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
15pub 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
63fn classify_change_zone(sa: &SpellAbility) -> TargetingIntent {
65 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
90fn 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
128pub 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 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 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 },
539 color,
540 mana_cost: mana_cost_str,
541 cmc,
542 types,
543 subtypes,
544 supertypes,
545 power,
546 toughness,
547 base_power,
548 base_toughness,
549 final_chapter: visible_battlefield_saga_final_chapter(card),
550 class_level: visible_battlefield_class_level(card),
551 class_levels,
552 saga_chapters,
553 text,
554 controller_id: player_id_str(card.controller),
555 owner_id: player_id_str(card.owner),
556 tapped: card.tapped,
557 is_crewed: card.is_crewed,
558 is_attacking: card.attacking_player.is_some(),
559 attacking_player_id: card.attacking_player.map(player_id_str),
560 attack_target_id: None,
561 keywords: {
564 let mut all_kw = card.keywords.as_string_list();
565 for k in card
566 .granted_keywords
567 .iter_strings()
568 .chain(card.pump_keywords.iter_strings())
569 {
570 if !all_kw.iter().any(|e| e.eq_ignore_ascii_case(k)) {
571 all_kw.push(k.to_string());
572 }
573 }
574 all_kw
575 },
576 counters,
577 damage: card.damage,
578 summoning_sick: card.summoning_sick && !card.has_haste(),
579 is_copy: card.copied_permanent.is_some(),
580 is_double_faced: card.other_part.is_some(),
581 flashback_cost: card.get_flashback_cost(),
582 kicker_cost: card.get_kicker_cost(),
583 is_transformed: card.is_transformed,
584 is_face_down: card.face_down,
585 is_bestowed: card.is_bestowed,
586 attached_to: card.attached_to.map(card_id_str),
587 attachment_ids: card
588 .attachments
589 .iter()
590 .map(|&aid| card_id_str(aid))
591 .collect(),
592 merged_card_ids: card
593 .melded_with
594 .iter()
595 .map(|&mid| card_id_str(mid))
596 .collect(),
597 phased_out: card.phased_out,
598 exerted: card.exerted,
599 is_ring_bearer: game.player(card.controller).ring_bearer == Some(cid),
600 effective_mana_cost: {
601 let is_command_zone_commander =
602 card.zone == ZoneType::Command && game.player_is_commander(card.controller, cid);
603 if is_command_zone_commander && !card.is_land() {
604 let cost_adj = manabrew_engine::staticability::static_ability_cost_change::compute_cost_adjustment(
605 game, card, card.controller, card.zone,
606 );
607 let mut adjusted = if !cost_adj.is_empty() {
608 cost_adj.apply(&card.mana_cost)
609 } else {
610 card.mana_cost.clone()
611 };
612
613 if is_command_zone_commander {
614 let commander_tax = game.player_commander_tax(card.controller, cid);
615 if commander_tax > 0 {
616 adjusted =
617 adjusted.add(&forge_foundation::ManaCost::generic(commander_tax));
618 }
619 }
620
621 let adjusted_str = adjusted.to_string();
622 if adjusted_str != card.mana_cost.to_string() {
623 Some(adjusted_str)
624 } else {
625 None
626 }
627 } else {
628 None
629 }
630 },
631 madness_cost: card.get_madness_cost(),
632 is_madness_exiled: card.zone == forge_foundation::ZoneType::Exile
633 && card.get_madness_cost().is_some(),
634 is_plotted: card
635 .keywords
636 .iter_strings()
637 .chain(card.granted_keywords.iter_strings())
638 .any(|kw| kw.starts_with(manabrew_engine::card::KEYWORD_PLOTTED_PREFIX)),
639 is_warp_exiled: card.has_keyword(manabrew_engine::card::KEYWORD_WARP_EXILED),
640 foil: card.paper_foil,
641 would_die_in_combat: false,
644 }
645}
646
647pub trait GameViewDtoExt {
648 fn from_engine(
649 game: &GameState,
650 mana_pools: &[ManaPool],
651 human_player: PlayerId,
652 game_id: &str,
653 ) -> Self;
654
655 fn all_zone_cards(&self) -> impl Iterator<Item = &CardDto>;
656}
657
658impl GameViewDtoExt for GameViewDto {
659 fn from_engine(
660 game: &GameState,
661 mana_pools: &[ManaPool],
662 human_player: PlayerId,
663 game_id: &str,
664 ) -> Self {
665 let mut players = Vec::new();
666 let mut zones: Vec<ZoneDto> = Vec::new();
667 let visible_zone = |zone: ZoneType, kind: ZoneKind, pid: PlayerId| -> ZoneDto {
668 let cards: Vec<CardView> = game
669 .cards_in_zone(zone, pid)
670 .iter()
671 .map(|&cid| CardView::Visible(card_to_dto(game, cid)))
672 .collect();
673 let count = cards.len();
674 ZoneDto {
675 zone: kind,
676 owner_id: player_id_str(pid),
677 cards,
678 count,
679 }
680 };
681 for &pid in &game.player_order {
682 let ps = game.player(pid);
683 let pool = mana_pools.get(pid.index()).cloned().unwrap_or_default();
684 let commander_damage: HashMap<String, i32> = ps
685 .commander_damage_received
686 .iter()
687 .map(|(&card_raw_id, &dmg)| (card_id_str(CardId(card_raw_id)), dmg))
688 .collect();
689
690 zones.push(visible_zone(ZoneType::Hand, ZoneKind::Hand, pid));
691 zones.push(visible_zone(ZoneType::Graveyard, ZoneKind::Graveyard, pid));
692 zones.push(visible_zone(ZoneType::Exile, ZoneKind::Exile, pid));
693 let command_cards: Vec<CardView> = game
694 .cards_in_zone(ZoneType::Command, pid)
695 .iter()
696 .copied()
697 .filter(|&cid| should_show_command_zone_card(game, cid))
698 .map(|cid| CardView::Visible(card_to_dto(game, cid)))
699 .collect();
700 zones.push(ZoneDto {
701 zone: ZoneKind::Command,
702 owner_id: player_id_str(pid),
703 count: command_cards.len(),
704 cards: command_cards,
705 });
706 zones.push(ZoneDto {
708 zone: ZoneKind::Library,
709 owner_id: player_id_str(pid),
710 cards: Vec::new(),
711 count: game.cards_in_zone(ZoneType::Library, pid).len(),
712 });
713
714 let mut counters = BTreeMap::new();
715 for (kind, value) in [
716 (PlayerCounterKind::Poison, ps.poison_counters),
717 (PlayerCounterKind::Energy, ps.energy_counters),
718 (PlayerCounterKind::Radiation, ps.radiation_counters),
719 ] {
720 if value > 0 {
721 counters.insert(kind, value as u32);
722 }
723 }
724
725 players.push(PlayerDto {
726 id: player_id_str(pid),
727 name: ps.name.clone(),
728 status: if ps.has_conceded {
729 PlayerStatus::Conceded
730 } else if ps.has_lost {
731 PlayerStatus::Lost
732 } else {
733 PlayerStatus::Playing
734 },
735 is_human: pid == human_player,
736 life: ps.life,
737 counters,
738 mana_pool: mana_pool_to_map(&pool),
739 commander_damage,
740 has_city_blessing: ps.has_city_blessing,
741 ring_level: ps.ring_level,
742 speed: ps.speed,
743 });
744 }
745
746 let mut battlefield_by_controller: HashMap<String, Vec<CardView>> = HashMap::new();
748 for &owner in &game.player_order {
749 for &cid in game.cards_in_zone(ZoneType::Battlefield, owner) {
750 let controller_id = player_id_str(game.card(cid).controller);
751 battlefield_by_controller
752 .entry(controller_id)
753 .or_default()
754 .push(CardView::Visible(card_to_dto(game, cid)));
755 }
756 }
757 for &pid in &game.player_order {
758 let owner_id = player_id_str(pid);
759 let cards = battlefield_by_controller
760 .remove(&owner_id)
761 .unwrap_or_default();
762 zones.push(ZoneDto {
763 zone: ZoneKind::Battlefield,
764 owner_id,
765 count: cards.len(),
766 cards,
767 });
768 }
769
770 let stack: Vec<StackObjectDto> = game
772 .stack
773 .iter()
774 .map(|entry| {
775 let source_card = entry.spell_ability.source.map(|cid| game.card(cid));
776 let identity = CardIdentity {
777 name: source_card
778 .map(|c| c.card_name.clone())
779 .unwrap_or_else(|| "Ability".to_string()),
780 set_code: source_card
781 .and_then(|c| c.set_code.clone())
782 .unwrap_or_default(),
783 card_number: source_card
784 .and_then(|c| c.card_number.clone())
785 .unwrap_or_default(),
786 is_token: source_card.map(|c| c.is_token).unwrap_or(false),
787 };
788 StackObjectDto {
789 id: format!("stack-{}", entry.id),
790 source_id: entry
791 .spell_ability
792 .source
793 .map(card_id_str)
794 .unwrap_or_default(),
795 controller_id: player_id_str(entry.spell_ability.activating_player),
796 owner_id: source_card
797 .map(|c| player_id_str(c.owner))
798 .unwrap_or_default(),
799 identity,
800 text: entry.spell_ability.ability_text.clone(),
801 is_permanent_spell: entry.is_creature_spell || entry.is_permanent_spell,
802 is_casting: entry.is_pending_cast,
803 is_double_faced: source_card.map(|c| c.is_double_faced()).unwrap_or(false),
804 face_index: source_card
805 .map(|c| u8::from(c.is_transformed))
806 .unwrap_or_default(),
807 targets: collect_stack_targets(&entry.spell_ability),
808 }
809 })
810 .collect();
811
812 GameViewDto {
813 game_id: game_id.to_string(),
814 turn: game.turn.turn_number,
815 step: phase_to_step(game.turn.phase),
816 combat_assignments: game
817 .turn
818 .combat_block_assignments
819 .iter()
820 .map(|(blocker, attacker)| CombatAssignmentDto {
821 blocker_id: card_id_str(*blocker),
822 attacker_id: card_id_str(*attacker),
823 })
824 .collect(),
825 active_player_id: player_id_str(game.active_player()),
826 priority_player_id: player_id_str(game.turn.priority_player),
827 players,
828 zones,
829 stack,
830 game_over: game.game_over,
831 winner_id: game.winner.map(player_id_str),
832 monarch_id: game.monarch.map(player_id_str),
833 initiative_holder_id: game.initiative_holder.map(player_id_str),
834 day_time: day_time_of(game),
835 }
836 }
837
838 fn all_zone_cards(&self) -> impl Iterator<Item = &CardDto> {
839 self.zones.iter().flat_map(|zone| {
840 zone.cards.iter().filter_map(|card| match card {
841 CardView::Visible(dto) => Some(dto),
842 CardView::Hidden { .. } => None,
843 })
844 })
845 }
846}