1use crate::ability::activated::parse_activated_ability;
8use crate::card::svar_cache::ParsedSVarKind;
9use crate::parsing::keys;
10use crate::parsing::Params;
11use crate::staticability::parse_static_ability;
12use crate::trigger::parse_trigger;
13
14use super::Card;
15
16fn roman_chapter(mut chapter: usize) -> String {
17 let mut result = String::new();
18 for (value, numeral) in [
19 (1000, "M"),
20 (900, "CM"),
21 (500, "D"),
22 (400, "CD"),
23 (100, "C"),
24 (90, "XC"),
25 (50, "L"),
26 (40, "XL"),
27 (10, "X"),
28 (9, "IX"),
29 (5, "V"),
30 (4, "IV"),
31 (1, "I"),
32 ] {
33 while chapter >= value {
34 result.push_str(numeral);
35 chapter -= value;
36 }
37 }
38 result
39}
40
41impl Card {
42 fn parsed_svar_params(&mut self, name: &str) -> Option<Params> {
43 match self.parsed_s_var(name)?.kind {
44 ParsedSVarKind::Ability { params, .. } | ParsedSVarKind::ParamRecord { params } => {
45 Some(params)
46 }
47 ParsedSVarKind::Number { .. }
48 | ParsedSVarKind::Count { .. }
49 | ParsedSVarKind::NumericExpression { .. }
50 | ParsedSVarKind::Raw { .. } => None,
51 }
52 }
53
54 pub(crate) fn generate_basic_land_mana_abilities(&mut self) {
57 const SUBTYPE_MANA: &[(&str, &str, &str)] = &[
58 ("Plains", "W", "Add {W}."),
59 ("Island", "U", "Add {U}."),
60 ("Swamp", "B", "Add {B}."),
61 ("Mountain", "R", "Add {R}."),
62 ("Forest", "G", "Add {G}."),
63 ];
64 for &(subtype, letter, desc) in SUBTYPE_MANA {
65 if self.type_line.has_subtype(subtype) {
66 let already_produces = self.activated_abilities.iter().any(|ab| {
67 ab.is_mana_ability
68 && ab
69 .produced_ir
70 .as_ref()
71 .is_some_and(|ir| ir.as_script_text() == letter)
72 });
73 if !already_produces {
74 let raw = format!(
75 "AB$ Mana | Cost$ T | Produced$ {letter} | SpellDescription$ {desc}"
76 );
77 let idx = self.abilities.len();
78 self.abilities.push(raw.clone());
79 if let Some(ab) = parse_activated_ability(&raw, idx) {
80 self.activated_abilities.push(ab);
81 }
82 }
83 }
84 }
85 }
86
87 pub(super) fn generate_keyword_abilities(&mut self) {
90 if let Some(cycling_cost) = self.get_keyword_cost("Cycling") {
92 let ab_text = format!(
93 "AB$ Draw | Cost$ {cycling_cost} Discard<1/CARDNAME> | ActivationZone$ Hand | NumCards$ 1 | Defined$ You"
94 );
95 let next_idx = self.activated_abilities.len();
96 if let Some(ab) = parse_activated_ability(&ab_text, next_idx) {
97 self.activated_abilities.push(ab);
98 }
99 }
100
101 for kw in self
104 .keywords
105 .iter_strings()
106 .chain(self.granted_keywords.iter_strings())
107 {
108 if let Some(rest) = kw.strip_prefix("TypeCycling:") {
109 let parts: Vec<&str> = rest.splitn(2, ':').collect();
110 if parts.len() == 2 {
111 let cycle_type = parts[0].trim(); let mana_cost = parts[1].trim(); let precost_desc = format!(
115 "{}cycling",
116 cycle_type
117 .chars()
118 .next()
119 .map(|c| c.to_uppercase().to_string())
120 .unwrap_or_default()
121 + &cycle_type[1..]
122 );
123 let ab_text = format!(
124 "AB$ ChangeZone | Cost$ {mana_cost} Discard<1/CARDNAME> | ActivationZone$ Hand | PrecostDesc$ {precost_desc} | Origin$ Library | Destination$ Hand | ChangeType$ {cycle_type}"
125 );
126 let next_idx = self.activated_abilities.len();
127 if let Some(ab) = parse_activated_ability(&ab_text, next_idx) {
128 self.activated_abilities.push(ab);
129 }
130 }
131 }
132 }
133
134 for equip_raw in self
138 .keywords
139 .iter_strings()
140 .chain(self.granted_keywords.iter_strings())
141 .filter_map(|kw| crate::keyword::extract_keyword_cost_str(kw, "Equip"))
142 {
143 let payload = equip_raw.split(":::").next().unwrap_or(equip_raw).trim();
144 let mut parts = payload.split(':');
145 let equip_cost = parts.next().unwrap_or(payload).trim();
146 let target_filter = parts
147 .next()
148 .map(str::trim)
149 .filter(|s| !s.is_empty())
150 .unwrap_or("Creature.YouCtrl");
151 if !equip_cost.is_empty() {
152 let ab_text = format!(
153 "AB$ Attach | Cost$ {equip_cost} | ValidTgts$ {target_filter} | SorcerySpeed$ True | SpellDescription$ Equip {equip_cost}"
154 );
155 let next_idx = self.activated_abilities.len();
156 if let Some(ab) = parse_activated_ability(&ab_text, next_idx) {
157 self.activated_abilities.push(ab);
158 }
159 }
160 }
161
162 for kw in self
165 .keywords
166 .iter_strings()
167 .chain(self.granted_keywords.iter_strings())
168 {
169 if let Some(rest) = crate::keyword::extract_keyword_cost_str(kw, "Adapt") {
170 let parts: Vec<&str> = rest.splitn(2, ':').collect();
171 if parts.len() == 2 {
172 let magnitude = parts[0].trim();
173 let mana_cost = parts[1].trim();
174 let ab_text = format!(
175 "AB$ PutCounter | Cost$ {mana_cost} | Adapt$ True | CounterNum$ {magnitude} | CounterType$ P1P1 | StackDescription$ SpellDescription | SpellDescription$ Adapt {magnitude}"
176 );
177 let next_idx = self.activated_abilities.len();
178 if let Some(ab) = parse_activated_ability(&ab_text, next_idx) {
179 self.activated_abilities.push(ab);
180 }
181 }
182 }
183 }
184
185 for kw in self
189 .keywords
190 .iter_strings()
191 .chain(self.granted_keywords.iter_strings())
192 {
193 if let Some(n_str) = crate::keyword::extract_keyword_cost_str(kw, "Crew") {
194 let n = n_str.trim();
195 let ab_text = format!(
196 "AB$ Animate | Cost$ tapXType<Any/Creature.Other+withTotalPowerGE{{{n}}}> | Defined$ Self | Types$ Artifact,Creature | Secondary$ True | SpellDescription$ Crew {n}"
197 );
198 let next_idx = self.activated_abilities.len();
199 if let Some(ab) = parse_activated_ability(&ab_text, next_idx) {
200 self.activated_abilities.push(ab);
201 }
202 }
203 }
204
205 for kw in self
210 .keywords
211 .iter_strings()
212 .chain(self.granted_keywords.iter_strings())
213 {
214 if let Some(_n_str) = crate::keyword::extract_keyword_cost_str(kw, "Station") {
215 let ab_text = "AB$ PutCounter | Cost$ tapXType<1/Creature.Other> | Defined$ Self | CounterType$ CHARGE | CounterNum$ StationX | SorcerySpeed$ True | CostDesc$ | SpellDescription$ Station";
216 let next_idx = self.activated_abilities.len();
217 if let Some(ab) = parse_activated_ability(ab_text, next_idx) {
218 self.activated_abilities.push(ab);
219 }
220 self.svars
221 .entry("StationX".to_string())
222 .or_insert_with(|| "TappedCards$TapPowerValue".to_string());
223 }
224 }
225
226 for kw in self
229 .keywords
230 .iter_strings()
231 .chain(self.granted_keywords.iter_strings())
232 {
233 if let Some(cost_str) = crate::keyword::extract_keyword_cost_str(kw, "Embalm") {
234 let cost = cost_str.trim();
235 let ab_text = format!(
236 "AB$ CopyPermanent | Cost$ {cost} ExileFromGrave<1/CARDNAME> | ActivationZone$ Graveyard | SorcerySpeed$ True | Defined$ Self | SetColor$ White | AddTypes$ Zombie | SpellDescription$ Embalm"
237 );
238 let next_idx = self.activated_abilities.len();
239 if let Some(ab) = parse_activated_ability(&ab_text, next_idx) {
240 self.activated_abilities.push(ab);
241 }
242 }
243 }
244
245 for kw in self
248 .keywords
249 .iter_strings()
250 .chain(self.granted_keywords.iter_strings())
251 {
252 if let Some(cost_str) = crate::keyword::extract_keyword_cost_str(kw, "Eternalize") {
253 let cost = cost_str.trim();
254 let ab_text = format!(
255 "AB$ CopyPermanent | Cost$ {cost} ExileFromGrave<1/CARDNAME> | ActivationZone$ Graveyard | SorcerySpeed$ True | Defined$ Self | SetColor$ Black | SetPower$ 4 | SetToughness$ 4 | AddTypes$ Zombie | SpellDescription$ Eternalize"
256 );
257 let next_idx = self.activated_abilities.len();
258 if let Some(ab) = parse_activated_ability(&ab_text, next_idx) {
259 self.activated_abilities.push(ab);
260 }
261 }
262 }
263
264 if self
266 .keywords
267 .iter_strings()
268 .chain(self.granted_keywords.iter_strings())
269 .any(|k| k.eq_ignore_ascii_case("Enlist"))
270 {
271 let raw = "S:Mode$ OptionalAttackCost | ValidCard$ Card.Self | Cost$ Enlist<1/CARDNAME/creature> | Secondary$ True | Trigger$ TrigEnlist";
272 if let Some(sa) = parse_static_ability(raw) {
273 self.add_static_ability(sa);
274 }
275 self.svars.entry("TrigEnlist".to_string()).or_insert_with(|| {
276 "DB$ Pump | NumAtt$ TriggerRemembered$CardPower | SpellDescription$ When you do, add its power to this creature's until end of turn.".to_string()
277 });
278 }
279
280 if self
283 .keywords
284 .iter_strings()
285 .chain(self.granted_keywords.iter_strings())
286 .any(|k| k.starts_with("Morph:") || k.starts_with("Megamorph:"))
287 {
288 self.has_morph = true;
289 }
290
291 if let Some(plot_cost) = self.get_keyword_cost("Plot") {
295 let ab_text = format!(
296 "AB$ Plot | Cost$ {plot_cost} | ActivationZone$ Hand | SorcerySpeed$ True | Secondary$ True | SpellDescription$ Plot"
297 );
298 let next_idx = self.activated_abilities.len();
299 if let Some(ab) = parse_activated_ability(&ab_text, next_idx) {
300 self.activated_abilities.push(ab);
301 }
302 }
303
304 let class_keywords: Vec<String> = self
307 .keywords
308 .iter_strings()
309 .chain(self.granted_keywords.iter_strings())
310 .filter(|kw| kw.starts_with("Class:"))
311 .map(|kw| kw.to_string())
312 .collect();
313 for kw in class_keywords {
314 if let Some(rest) = kw.strip_prefix("Class:") {
315 let mut parts = rest.splitn(3, ':');
316 let level = parts.next().unwrap_or_default().trim();
317 let cost = parts.next().unwrap_or_default().trim();
318 let params = parts.next().unwrap_or_default().trim();
319
320 let Ok(level_num) = level.parse::<i32>() else {
321 continue;
322 };
323 if cost.is_empty() {
324 continue;
325 }
326
327 let ab_text = format!(
328 "AB$ ClassLevelUp | Cost$ {} | ClassLevel$ EQ{} | SorcerySpeed$ True | StackDescription$ SpellDescription | SpellDescription$ Level {}",
329 cost,
330 level_num - 1,
331 level_num
332 );
333 let next_idx = self.activated_abilities.len();
334 if let Some(ab) = parse_activated_ability(&ab_text, next_idx) {
335 self.activated_abilities.push(ab);
336 }
337
338 if !params.is_empty() {
339 let parsed = Params::from_raw(params);
340 let mut desc_parts: Vec<String> = Vec::new();
341
342 if let Some(add_trigger) = parsed.get("AddTrigger") {
343 for svar_name in add_trigger
344 .split(" & ")
345 .map(str::trim)
346 .filter(|s| !s.is_empty())
347 {
348 if let Some(svar_params) = self.parsed_svar_params(svar_name) {
349 if let Some(desc) = svar_params.get(keys::TRIGGER_DESCRIPTION) {
350 desc_parts.push(desc.to_string());
351 }
352 }
353 }
354 }
355
356 if let Some(add_static) = parsed.get("AddStaticAbility") {
357 for svar_name in add_static
358 .split(" & ")
359 .map(str::trim)
360 .filter(|s| !s.is_empty())
361 {
362 if let Some(svar_params) = self.parsed_svar_params(svar_name) {
363 if let Some(desc) = svar_params.get(keys::DESCRIPTION) {
364 desc_parts.push(desc.to_string());
365 }
366 }
367 }
368 }
369
370 if let Some(add_replacement) = parsed.get("AddReplacementEffect") {
371 for svar_name in add_replacement
372 .split(" & ")
373 .map(str::trim)
374 .filter(|s| !s.is_empty())
375 {
376 if let Some(svar_params) = self.parsed_svar_params(svar_name) {
377 if let Some(desc) = svar_params.get(keys::DESCRIPTION) {
378 desc_parts.push(desc.to_string());
379 }
380 }
381 }
382 }
383
384 let mut effect = format!(
385 "Mode$ Continuous | Affected$ Card.Self | ClassLevel$ {level_num} | {params}"
386 );
387 if !desc_parts.is_empty() {
388 effect.push_str(" | Description$ ");
389 effect.push_str(&desc_parts.join("\r\n"));
390 }
391 if let Some(st) = parse_static_ability(&effect) {
392 self.add_static_ability(st);
393 }
394 }
395 }
396 }
397 }
398
399 pub fn ensure_crew_activated_ability(&mut self) {
400 if self.activated_abilities.iter().any(|ab| {
401 ab.spell_description
402 .as_deref()
403 .is_some_and(|desc| desc.starts_with("Crew"))
404 }) {
405 return;
406 }
407 for kw in self.keywords.iter_strings() {
408 if let Some(n_str) = crate::keyword::extract_keyword_cost_str(kw, "Crew") {
409 let n = n_str.trim();
410 let ab_text = format!(
411 "AB$ Animate | Cost$ tapXType<Any/Creature.Other+withTotalPowerGE{{{n}}}> | Defined$ Self | Types$ Artifact,Creature | Secondary$ True | SpellDescription$ Crew {n}"
412 );
413 let next_idx = self.activated_abilities.len();
414 if let Some(ab) = parse_activated_ability(&ab_text, next_idx) {
415 self.activated_abilities.push(ab);
416 self.base_ability_count = self.activated_abilities.len();
417 }
418 return;
419 }
420 }
421 }
422
423 pub fn generate_keyword_triggers(&mut self) {
426 let mut next_id = self.triggers.len() as u32;
427
428 for kw in self.keywords.as_string_list() {
429 self.generate_keyword_trigger_combat(&kw, &mut next_id);
430 self.generate_keyword_trigger_zone(&kw, &mut next_id);
431 self.generate_keyword_trigger_misc(&kw, &mut next_id);
432 }
433 }
434
435 fn generate_keyword_trigger_combat(&mut self, kw: &str, next_id: &mut u32) {
436 if kw == "Prowess" {
437 let raw = "Mode$ SpellCast | ValidCard$ Card.nonCreature | ValidActivatingPlayer$ You | Execute$ TrigProwess | TriggerZones$ Battlefield | TriggerDescription$ Prowess";
438 if let Some(mut trig) = parse_trigger(raw, next_id) {
439 trig.execute = "TrigProwess".to_string();
440 self.add_trigger(trig);
441 }
442 self.svars
443 .entry("TrigProwess".to_string())
444 .or_insert_with(|| "DB$ Pump | Defined$ Self | NumAtt$ 1 | NumDef$ 1".to_string());
445 }
446
447 if let Some(n_str) = crate::keyword::extract_keyword_cost_str(kw, "Bushido") {
448 if n_str.parse::<i32>().is_ok() {
449 let raw1 = format!(
450 "Mode$ Blocks | ValidCard$ Card.Self | Execute$ TrigBushido | TriggerZones$ Battlefield | TriggerDescription$ Bushido {n_str}"
451 );
452 if let Some(mut trig) = parse_trigger(&raw1, next_id) {
453 trig.execute = "TrigBushido".to_string();
454 self.add_trigger(trig);
455 }
456 let raw2 = format!(
457 "Mode$ AttackerBlocked | ValidCard$ Card.Self | Execute$ TrigBushido | TriggerZones$ Battlefield | TriggerDescription$ Bushido {n_str}"
458 );
459 if let Some(mut trig) = parse_trigger(&raw2, next_id) {
460 trig.execute = "TrigBushido".to_string();
461 self.add_trigger(trig);
462 }
463 self.svars
464 .entry("TrigBushido".to_string())
465 .or_insert_with(|| {
466 format!("DB$ Pump | Defined$ Self | NumAtt$ {n_str} | NumDef$ {n_str}")
467 });
468 }
469 }
470
471 if let Some(n_str) = crate::keyword::extract_keyword_cost_str(kw, "Annihilator") {
472 if n_str.parse::<i32>().is_ok() {
473 let raw = format!(
474 "Mode$ Attacks | ValidCard$ Card.Self | Execute$ TrigAnnihilator | TriggerZones$ Battlefield | TriggerDescription$ Annihilator {n_str}"
475 );
476 if let Some(mut trig) = parse_trigger(&raw, next_id) {
477 trig.execute = "TrigAnnihilator".to_string();
478 self.add_trigger(trig);
479 }
480 self.svars
481 .entry("TrigAnnihilator".to_string())
482 .or_insert_with(|| {
483 format!("DB$ Sacrifice | Defined$ TriggeredDefendingPlayer | SacValid$ Permanent | Amount$ {n_str}")
484 });
485 }
486 }
487
488 if let Some(n_str) = crate::keyword::extract_keyword_cost_str(kw, "Afflict") {
489 if n_str.parse::<i32>().is_ok() {
490 let raw = format!(
491 "Mode$ AttackerBlocked | ValidCard$ Card.Self | TriggerZones$ Battlefield | Secondary$ True | Execute$ TrigAfflict | TriggerDescription$ Afflict {n_str}"
492 );
493 if let Some(mut trig) = parse_trigger(&raw, next_id) {
494 trig.execute = "TrigAfflict".to_string();
495 self.add_trigger(trig);
496 }
497 self.svars
498 .entry("TrigAfflict".to_string())
499 .or_insert_with(|| {
500 format!("DB$ LoseLife | Defined$ TriggeredDefendingPlayer | LifeAmount$ {n_str}")
501 });
502 }
503 }
504
505 if kw == "Exalted" {
506 let raw = "Mode$ Attacks | ValidCard$ Creature.YouCtrl | Alone$ True | Execute$ TrigExalted | TriggerZones$ Battlefield | TriggerDescription$ Exalted";
507 if let Some(mut trig) = parse_trigger(raw, next_id) {
508 trig.execute = "TrigExalted".to_string();
509 self.add_trigger(trig);
510 }
511 self.svars
512 .entry("TrigExalted".to_string())
513 .or_insert_with(|| {
514 "DB$ Pump | Defined$ TriggeredAttacker | NumAtt$ +1 | NumDef$ +1".to_string()
515 });
516 }
517
518 if let Some(n_str) = crate::keyword::extract_keyword_cost_str(kw, "Renown") {
519 if n_str.parse::<i32>().is_ok() {
520 let raw = format!(
521 "Mode$ DamageDone | ValidSource$ Card.Self | ValidTarget$ Player | CombatDamage$ True | Execute$ TrigRenown | TriggerZones$ Battlefield | TriggerDescription$ Renown {n_str}"
522 );
523 if let Some(mut trig) = parse_trigger(&raw, next_id) {
524 trig.execute = "TrigRenown".to_string();
525 self.add_trigger(trig);
526 }
527 self.svars
528 .entry("TrigRenown".to_string())
529 .or_insert_with(|| {
530 format!("DB$ PutCounter | Defined$ Self | CounterType$ P1P1 | CounterNum$ {n_str} | Renown$ True")
531 });
532 }
533 }
534
535 if kw == "Flanking" {
536 let raw = "Mode$ AttackerBlockedByCreature | ValidBlocked$ Card.Self | ValidCard$ Creature.withoutFlanking | Execute$ TrigFlanking | TriggerZones$ Battlefield | TriggerDescription$ Flanking";
537 if let Some(mut trig) = parse_trigger(raw, next_id) {
538 trig.execute = "TrigFlanking".to_string();
539 self.add_trigger(trig);
540 }
541 self.svars
542 .entry("TrigFlanking".to_string())
543 .or_insert_with(|| {
544 "DB$ Pump | Defined$ TriggeredBlocker | NumAtt$ -1 | NumDef$ -1".to_string()
545 });
546 }
547
548 if kw == "Extort" {
549 let raw = "Mode$ SpellCast | ValidActivatingPlayer$ You | Execute$ TrigExtort | TriggerZones$ Battlefield | TriggerDescription$ Extort";
550 if let Some(mut trig) = parse_trigger(raw, next_id) {
551 trig.execute = "TrigExtort".to_string();
552 trig.optional = true;
553 self.add_trigger(trig);
554 }
555 self.svars.entry("TrigExtort".to_string()).or_insert_with(|| {
556 "DB$ LoseLife | Defined$ Player.Opponent | LifeAmount$ 1 | SubAbility$ ExtortGain"
557 .to_string()
558 });
559 self.svars
560 .entry("ExtortGain".to_string())
561 .or_insert_with(|| "DB$ GainLife | Defined$ You | LifeAmount$ 1".to_string());
562 }
563 }
564
565 fn generate_keyword_trigger_zone(&mut self, kw: &str, next_id: &mut u32) {
566 self.generate_keyword_trigger_zone_graveyard(kw, next_id);
567 self.generate_keyword_trigger_zone_battlefield(kw, next_id);
568 }
569
570 fn generate_keyword_trigger_zone_graveyard(&mut self, kw: &str, next_id: &mut u32) {
571 if kw == "Undying" {
572 let raw = "Mode$ ChangesZone | Origin$ Battlefield | Destination$ Graveyard | ValidCard$ Card.Self+counters_EQ0_P1P1 | TriggerZones$ Battlefield | Execute$ TrigUndying | TriggerDescription$ Undying";
573 if let Some(mut trig) = parse_trigger(raw, next_id) {
574 trig.execute = "TrigUndying".to_string();
575 self.add_trigger(trig);
576 }
577 self.svars.entry("TrigUndying".to_string()).or_insert_with(|| {
578 "DB$ ChangeZone | Defined$ TriggeredNewCardLKICopy | Origin$ Graveyard | Destination$ Battlefield | WithCountersType$ P1P1".to_string()
579 });
580 }
581
582 if kw == "Persist" {
583 let raw = "Mode$ ChangesZone | Origin$ Battlefield | Destination$ Graveyard | ValidCard$ Card.Self+counters_EQ0_M1M1 | TriggerZones$ Battlefield | Execute$ TrigPersist | TriggerDescription$ Persist";
584 if let Some(mut trig) = parse_trigger(raw, next_id) {
585 trig.execute = "TrigPersist".to_string();
586 self.add_trigger(trig);
587 }
588 self.svars.entry("TrigPersist".to_string()).or_insert_with(|| {
589 "DB$ ChangeZone | Defined$ TriggeredNewCardLKICopy | Origin$ Graveyard | Destination$ Battlefield | WithCountersType$ M1M1".to_string()
590 });
591 }
592
593 if let Some(n_str) = crate::keyword::extract_keyword_cost_str(kw, "Afterlife") {
594 if n_str.parse::<i32>().is_ok() {
595 let raw = format!(
596 "Mode$ ChangesZone | Origin$ Battlefield | Destination$ Graveyard | ValidCard$ Card.Self | TriggerZones$ Battlefield | Execute$ TrigAfterlife | TriggerDescription$ Afterlife {n_str}"
597 );
598 if let Some(mut trig) = parse_trigger(&raw, next_id) {
599 trig.execute = "TrigAfterlife".to_string();
600 self.add_trigger(trig);
601 }
602 self.svars
603 .entry("TrigAfterlife".to_string())
604 .or_insert_with(|| {
605 format!(
606 "DB$ Token | TokenAmount$ {n_str} | TokenScript$ wb_1_1_spirit_flying"
607 )
608 });
609 }
610 }
611
612 if let Some(n_str) = crate::keyword::extract_keyword_cost_str(kw, "Modular") {
613 if let Ok(n) = n_str.parse::<i32>() {
614 self.etb_counters_p1p1 += n;
615
616 let raw = format!(
617 "Mode$ ChangesZone | Origin$ Battlefield | Destination$ Graveyard | ValidCard$ Card.Self | TriggerZones$ Battlefield | Execute$ TrigModular | TriggerDescription$ Modular {n_str}"
618 );
619 if let Some(mut trig) = parse_trigger(&raw, next_id) {
620 trig.execute = "TrigModular".to_string();
621 trig.optional = true;
622 self.add_trigger(trig);
623 }
624 self.svars
625 .entry("TrigModular".to_string())
626 .or_insert_with(|| "SP$ Charm | Choices$ ModularMove".to_string());
627 self.svars
628 .entry("ModularMove".to_string())
629 .or_insert_with(|| {
630 format!("DB$ PutCounter | Defined$ Targeted | CounterType$ P1P1 | CounterNum$ {n_str} | Modular$ true | ValidTgts$ Creature.Artifact | SpellDescription$ Put +1/+1 counter(s) on target artifact creature")
631 });
632 }
633 }
634 }
635
636 fn generate_keyword_trigger_zone_battlefield(&mut self, kw: &str, next_id: &mut u32) {
637 if kw == "Exploit" {
638 let raw = "Mode$ ChangesZone | Destination$ Battlefield | ValidCard$ Card.Self | Execute$ TrigExploit | TriggerDescription$ Exploit";
639 if let Some(mut trig) = parse_trigger(raw, next_id) {
640 trig.execute = "TrigExploit".to_string();
641 self.add_trigger(trig);
642 }
643 self.svars
644 .entry("TrigExploit".to_string())
645 .or_insert_with(|| {
646 "DB$ Sacrifice | SacValid$ Creature | Optional$ True | Exploit$ True"
647 .to_string()
648 });
649 }
650
651 if let Some(n_str) = crate::keyword::extract_keyword_cost_str(kw, "Fabricate") {
652 if n_str.parse::<i32>().is_ok() {
653 let raw = format!(
654 "Mode$ ChangesZone | Destination$ Battlefield | ValidCard$ Card.Self | Execute$ TrigFabricate | Secondary$ True | TriggerDescription$ Fabricate {n_str}"
655 );
656 if let Some(mut trig) = parse_trigger(&raw, next_id) {
657 trig.execute = "TrigFabricate".to_string();
658 self.add_trigger(trig);
659 }
660 self.svars
661 .entry("TrigFabricate".to_string())
662 .or_insert_with(|| {
663 format!(
664 "DB$ Token | TokenAmount$ {n_str} | TokenScript$ c_1_1_a_servo \
665 | UnlessCost$ AddCounter<{n_str}/P1P1> | UnlessPayer$ You \
666 | SpellDescription$ Fabricate {n_str}"
667 )
668 });
669 }
670 }
671
672 if kw == "Living Weapon" {
673 let raw = "Mode$ ChangesZone | Destination$ Battlefield | ValidCard$ Card.Self | Secondary$ True | TriggerDescription$ Living Weapon";
674 if let Some(mut trig) = parse_trigger(raw, next_id) {
675 trig.execute = "TrigLivingWeapon".to_string();
676 self.add_trigger(trig);
677 }
678 self.svars
679 .entry("TrigLivingWeapon".to_string())
680 .or_insert_with(|| {
681 "DB$ Token | TokenScript$ b_0_0_phyrexian_germ | TokenOwner$ You | RememberTokens$ True | SubAbility$ DBLivingWeaponAttach".to_string()
682 });
683 self.svars
684 .entry("DBLivingWeaponAttach".to_string())
685 .or_insert_with(|| {
686 "DB$ Attach | Defined$ Remembered | SubAbility$ DBLivingWeaponCleanup"
687 .to_string()
688 });
689 self.svars
690 .entry("DBLivingWeaponCleanup".to_string())
691 .or_insert_with(|| "DB$ Cleanup | ClearRemembered$ True".to_string());
692 }
693
694 if let Some(n_str) = crate::keyword::extract_keyword_cost_str(kw, "Bloodthirst") {
695 if n_str.parse::<i32>().is_ok() {
696 let raw = format!(
697 "Mode$ ChangesZone | Destination$ Battlefield | ValidCard$ Card.Self | Execute$ TrigBloodthirst | TriggerDescription$ Bloodthirst {n_str}"
698 );
699 if let Some(mut trig) = parse_trigger(&raw, next_id) {
700 trig.execute = "TrigBloodthirst".to_string();
701 self.add_trigger(trig);
702 }
703 self.svars
704 .entry("TrigBloodthirst".to_string())
705 .or_insert_with(|| {
706 format!("DB$ PutCounter | Defined$ Self | CounterType$ P1P1 | CounterNum$ {n_str} | Bloodthirst$ True")
707 });
708 }
709 }
710
711 if kw == "Riot" {
712 let raw = "Mode$ ChangesZone | Destination$ Battlefield | ValidCard$ Card.Self | Execute$ TrigRiot | TriggerDescription$ Riot";
713 if let Some(mut trig) = parse_trigger(raw, next_id) {
714 trig.execute = "TrigRiot".to_string();
715 self.add_trigger(trig);
716 }
717 self.svars
718 .entry("TrigRiot".to_string())
719 .or_insert_with(|| "SP$ Charm | Choices$ RiotCounter,RiotHaste".to_string());
720 self.svars
721 .entry("RiotCounter".to_string())
722 .or_insert_with(|| {
723 "DB$ PutCounter | Defined$ Self | CounterType$ P1P1 | CounterNum$ 1 | SpellDescription$ Put a +1/+1 counter on this creature".to_string()
724 });
725 self.svars
726 .entry("RiotHaste".to_string())
727 .or_insert_with(|| {
728 "DB$ Pump | Defined$ Self | KW$ Haste | SpellDescription$ This creature gains haste".to_string()
729 });
730 }
731
732 if kw == "Unleash" {
733 let raw = "Mode$ ChangesZone | Destination$ Battlefield | ValidCard$ Card.Self | Execute$ TrigUnleash | TriggerDescription$ Unleash";
734 if let Some(mut trig) = parse_trigger(raw, next_id) {
735 trig.execute = "TrigUnleash".to_string();
736 self.add_trigger(trig);
737 }
738 self.svars
739 .entry("TrigUnleash".to_string())
740 .or_insert_with(|| {
741 "DB$ PutCounter | Defined$ Self | CounterType$ P1P1 | CounterNum$ 1".to_string()
742 });
743 }
744 }
745
746 pub(crate) fn generate_keyword_chapter_triggers(&mut self) {
747 if !self.has_subtype("Saga") {
748 return;
749 }
750
751 let mut next_id = self.triggers.len() as u32;
752 for kw in self.keywords.as_string_list() {
753 if !kw.starts_with("Chapter") {
754 continue;
755 }
756 let Some((count, svars)) = kw
757 .strip_prefix("Chapter:")
758 .and_then(|value| value.split_once(':'))
759 else {
760 panic!("invalid Chapter keyword: {kw}");
761 };
762 let count = count
763 .parse::<usize>()
764 .unwrap_or_else(|_| panic!("invalid Chapter count: {count}"));
765 let svars: Vec<&str> = svars.split(',').collect();
766 assert!(
767 !svars.iter().any(|svar| svar.is_empty()),
768 "Chapter ability list must not contain empty SVars"
769 );
770 assert_eq!(svars.len(), count, "Saga max differ from Ability amount");
771
772 let mut groups: Vec<(String, Vec<usize>)> = Vec::new();
773 for (chapter, svar) in svars.iter().enumerate() {
774 let chapter = chapter + 1;
775 if let Some((_, chapters)) = groups
776 .iter_mut()
777 .find(|(existing_svar, _)| existing_svar == svar)
778 {
779 chapters.push(chapter);
780 } else {
781 groups.push(((*svar).to_string(), vec![chapter]));
782 }
783 }
784
785 for (svar, chapters) in groups {
786 let description = self
787 .get_s_var(&svar)
788 .and_then(|raw| {
789 raw.split('|').find_map(|param| {
790 param
791 .trim()
792 .strip_prefix("SpellDescription$")
793 .map(str::trim)
794 })
795 })
796 .map(str::to_string)
797 .unwrap_or_default();
798 let grouped_chapters = chapters
799 .iter()
800 .map(|chapter| roman_chapter(*chapter))
801 .collect::<Vec<_>>()
802 .join(", ");
803
804 for (index, chapter) in chapters.iter().enumerate() {
805 let mut trigger = format!(
806 "Mode$ CounterAdded | ValidCard$ Card.Self | TriggerZones$ Battlefield | Chapter$ {chapter} | CounterType$ LORE | CounterAmount$ EQ{chapter} | Execute$ {svar}"
807 );
808 if index > 0 {
809 trigger.push_str(" | Secondary$ True");
810 }
811 trigger.push_str(&format!(
812 " | TriggerDescription$ {grouped_chapters} — {description}"
813 ));
814 let Some(trigger) = parse_trigger(&trigger, &mut next_id) else {
815 panic!("invalid Chapter trigger");
816 };
817 self.add_trigger(trigger);
818 }
819 }
820 }
821 }
822
823 fn generate_keyword_trigger_misc(&mut self, kw: &str, next_id: &mut u32) {
824 if let Some(cost_str) = crate::keyword::extract_keyword_cost_str(kw, "Ward") {
825 let raw = "Mode$ BecomesTarget | ValidSource$ SpellAbility.OppCtrl | ValidTarget$ Card.Self | Secondary$ True | TriggerZones$ Battlefield | TriggerDescription$ Ward";
826 if let Some(mut trig) = parse_trigger(raw, next_id) {
827 trig.execute = "TrigWard".to_string();
828 self.add_trigger(trig);
829 }
830 self.svars.entry("TrigWard".to_string()).or_insert_with(|| {
831 format!("DB$ Counter | Defined$ TriggeredSourceSA | UnlessCost$ {cost_str}")
832 });
833 }
834
835 if let Some(rest) = kw.strip_prefix("Cumulative upkeep:") {
836 let cost_spec = rest.split(':').next().unwrap_or(rest);
837 let raw = "Mode$ Phase | Phase$ Upkeep | ValidPlayer$ You | TriggerZones$ Battlefield | TriggerDescription$ Cumulative upkeep";
838 if let Some(mut trig) = parse_trigger(raw, next_id) {
839 trig.execute = "TrigCumulativeUpkeep".to_string();
840 self.add_trigger(trig);
841 }
842 self.svars
843 .entry("TrigCumulativeUpkeep".to_string())
844 .or_insert_with(|| {
845 format!("DB$ Sacrifice | SacValid$ Self | CumulativeUpkeep$ {cost_spec}")
846 });
847 }
848
849 if let Some(cost_str) = crate::keyword::extract_keyword_cost_str(kw, "Echo") {
850 let cost_spec = cost_str.split(':').next().unwrap_or(cost_str);
851 let raw = "Mode$ Phase | Phase$ Upkeep | ValidPlayer$ You | TriggerZones$ Battlefield | IsPresent$ Card.Self+cameUnderControlSinceLastUpkeep | Secondary$ True | TriggerDescription$ Echo";
852 if let Some(mut trig) = parse_trigger(raw, next_id) {
853 trig.execute = "TrigEcho".to_string();
854 self.add_trigger(trig);
855 }
856 self.svars
857 .entry("TrigEcho".to_string())
858 .or_insert_with(|| format!("DB$ Sacrifice | SacValid$ Self | Echo$ {cost_spec}"));
859 }
860
861 if let Some(madness_cost) = crate::keyword::extract_keyword_cost_str(kw, "Madness") {
862 let raw = "Mode$ ChangesZone | Origin$ Hand | Destination$ Exile | ValidCard$ Card.Self | Secondary$ True | TriggerZones$ Exile | TriggerDescription$ You may cast this card for its madness cost.";
863 if let Some(mut trig) = parse_trigger(raw, next_id) {
864 trig.execute = "TrigMadnessPlay".to_string();
865 self.add_trigger(trig);
866 }
867 self.svars
868 .entry("TrigMadnessPlay".to_string())
869 .or_insert_with(|| {
870 format!(
871 "DB$ Play | Defined$ Self | ValidSA$ Spell | PlayCost$ {madness_cost} | Optional$ True | RememberPlayed$ True | SubAbility$ MadnessMoveToYard"
872 )
873 });
874 self.svars
875 .entry("MadnessMoveToYard".to_string())
876 .or_insert_with(|| {
877 "DB$ ChangeZone | Defined$ Self | Origin$ Exile | Destination$ Graveyard | TrackDiscarded$ True | ConditionDefined$ Remembered | ConditionPresent$ Card | ConditionCompare$ EQ0 | SubAbility$ MadnessCleanup".to_string()
878 });
879 self.svars
880 .entry("MadnessCleanup".to_string())
881 .or_insert_with(|| "DB$ Cleanup | ClearRemembered$ True".to_string());
882 }
883
884 if let Some(partner_name) = kw.strip_prefix("Partner with:") {
885 let mut partner_name = partner_name.trim().to_string();
886 let raw = format!(
887 "Mode$ ChangesZone | Destination$ Battlefield | ValidCard$ Card.Self | Secondary$ True | TriggerDescription$ Partner with {partner_name}"
888 );
889 if let Some(mut trig) = parse_trigger(&raw, next_id) {
890 trig.execute = "TrigPartnerWith".to_string();
891 self.add_trigger(trig);
892 }
893 partner_name = partner_name.replace(',', ";");
894 self.svars
895 .entry("TrigPartnerWith".to_string())
896 .or_insert_with(|| {
897 format!(
898 "DB$ ChangeZone | ValidTgts$ Player | Origin$ Library | Destination$ Hand | ChangeType$ Card.named{partner_name} | Hidden$ True | Chooser$ Targeted | Optional$ True"
899 )
900 });
901 }
902 }
903}