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$ {} | 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    /// Generate activated abilities from keywords (e.g. Cycling → AB$ Draw).
89    /// Mirrors Java's `CardFactoryUtil.setupKeywordedAbilities()`.
90    pub(super) fn generate_keyword_abilities(&mut self) {
91        // Cycling: K:Cycling:{cost} → AB$ Draw | Cost$ {cost} Discard<1/CARDNAME> | ActivationZone$ Hand
92        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        // TypeCycling: K:TypeCycling:{type}:{cost} → AB$ ChangeZone | Cost$ {cost} Discard<1/CARDNAME> | ActivationZone$ Hand
104        // Mirrors Java CardFactoryUtil lines 3852-3864.
105        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(); // e.g., "Swamp"
114                    let mana_cost = parts[1].trim(); // e.g., "1"
115                                                     // getTitleWithoutCost() = capitalize(descType) + "cycling"
116                    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        // Equip: K:Equip:{cost}[...]
138        // Forge keyword payload can include optional suffix data; we only need
139        // the activation cost + default target filter to mirror Java baseline.
140        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        // Adapt: K:Adapt:N:cost → AB$ PutCounter with Adapt$ True gate.
167        // Mirrors Java CardFactoryUtil lines 2665-2684.
168        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        // Crew: K:Crew:N → AB$ Animate (tap creatures with total power ≥N).
191        // Mirrors Java CardFactoryUtil lines 3820-3835.
192        // Uses tapXType<Any/Creature.Other+withTotalPowerGE{N}> matching Java's format.
193        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        // Station: K:Station:N → AB$ PutCounter (tap another creature to add charge counters).
212        // Mirrors Java CardFactoryUtil lines 3587-3595.
213        // The ability is sorcery-speed and puts charge counters equal to the tapped
214        // creature's power onto this Spacecraft/Planet.
215        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        // Embalm: K:Embalm:cost → AB$ CopyPermanent from graveyard.
233        // Mirrors Java CardFactoryUtil lines 2879-2891.
234        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        // Eternalize: K:Eternalize:cost → AB$ CopyPermanent from graveyard as 4/4.
253        // Mirrors Java CardFactoryUtil lines 3023-3052.
254        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        // Enlist: K:Enlist -> intrinsic optional attack cost static ability.
273        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        // Morph / Megamorph: mark card as castable face-down for {3}.
289        // The actual casting logic is in game_action_util (playable check + cost handling).
290        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        // Plot: K:Plot:{cost} → AB$ Plot | Cost$ {cost} | ActivationZone$ Hand | SorcerySpeed$ True
300        // Mirrors Java CardFactoryUtil lines 3398-3449.
301        // Exiles the card from hand; plotted cards can later be cast for free.
302        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        // Class: K:Class:{level}:{cost}:{params} → AB$ ClassLevelUp.
314        // Mirrors Java CardFactoryUtil lines 2789-2799.
315        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    /// Generate triggered abilities from keywords (e.g. Prowess, Bushido, Annihilator, etc.).
435    /// Mirrors Java's `CardFactoryUtil.setupKeywordedTriggers()`.
436    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}