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