Skip to main content

manabrew_engine/card/
keyword_gen.rs

1//! Keyword-based ability and trigger generation for Card.
2//!
3//! These functions translate keywords like "Cycling", "Prowess", "Bushido", etc. into
4//! concrete activated abilities and triggered abilities. They're called during card
5//! initialization in `Card::from_rules()`.
6
7use 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    /// Generate intrinsic mana abilities for basic land subtypes (Plains → {W}, etc.).
55    /// Mirrors Java's `CardFactoryUtil.addIntrinsicAbilities()`.
56    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    /// Generate activated abilities from keywords (e.g. Cycling → AB$ Draw).
88    /// Mirrors Java's `CardFactoryUtil.setupKeywordedAbilities()`.
89    pub(super) fn generate_keyword_abilities(&mut self) {
90        // Cycling: K:Cycling:{cost} → AB$ Draw | Cost$ {cost} Discard<1/CARDNAME> | ActivationZone$ Hand
91        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        // TypeCycling: K:TypeCycling:{type}:{cost} → AB$ ChangeZone | Cost$ {cost} Discard<1/CARDNAME> | ActivationZone$ Hand
102        // Mirrors Java CardFactoryUtil lines 3852-3864.
103        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(); // e.g., "Swamp"
112                    let mana_cost = parts[1].trim(); // e.g., "1"
113                                                     // getTitleWithoutCost() = capitalize(descType) + "cycling"
114                    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        // Equip: K:Equip:{cost}[...]
135        // Forge keyword payload can include optional suffix data; we only need
136        // the activation cost + default target filter to mirror Java baseline.
137        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        // Adapt: K:Adapt:N:cost → AB$ PutCounter with Adapt$ True gate.
163        // Mirrors Java CardFactoryUtil lines 2665-2684.
164        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        // Crew: K:Crew:N → AB$ Animate (tap creatures with total power ≥N).
186        // Mirrors Java CardFactoryUtil lines 3820-3835.
187        // Uses tapXType<Any/Creature.Other+withTotalPowerGE{N}> matching Java's format.
188        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        // Station: K:Station:N → AB$ PutCounter (tap another creature to add charge counters).
206        // Mirrors Java CardFactoryUtil lines 3587-3595.
207        // The ability is sorcery-speed and puts charge counters equal to the tapped
208        // creature's power onto this Spacecraft/Planet.
209        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        // Embalm: K:Embalm:cost → AB$ CopyPermanent from graveyard.
227        // Mirrors Java CardFactoryUtil lines 2879-2891.
228        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        // Eternalize: K:Eternalize:cost → AB$ CopyPermanent from graveyard as 4/4.
246        // Mirrors Java CardFactoryUtil lines 3023-3052.
247        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        // Enlist: K:Enlist -> intrinsic optional attack cost static ability.
265        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        // Morph / Megamorph: mark card as castable face-down for {3}.
281        // The actual casting logic is in game_action_util (playable check + cost handling).
282        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        // Plot: K:Plot:{cost} → AB$ Plot | Cost$ {cost} | ActivationZone$ Hand | SorcerySpeed$ True
292        // Mirrors Java CardFactoryUtil lines 3398-3449.
293        // Exiles the card from hand; plotted cards can later be cast for free.
294        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        // Class: K:Class:{level}:{cost}:{params} → AB$ ClassLevelUp.
305        // Mirrors Java CardFactoryUtil lines 2789-2799.
306        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    /// Generate triggered abilities from keywords (e.g. Prowess, Bushido, Annihilator, etc.).
424    /// Mirrors Java's `CardFactoryUtil.setupKeywordedTriggers()`.
425    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}