From 0505e3ebe15a13805ce3021e0752f92451b4454a Mon Sep 17 00:00:00 2001 From: Whovencroft <6.60056e+06+Whovencroft@users.noreply.github.com> Date: Fri, 29 May 2026 04:29:28 +0000 Subject: [PATCH] feat(keywords): implement Reinforce keyword synthesis (CR 702.77a) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit CR 702.77a: "Reinforce N—[cost]" means "[Cost], Discard this card: Put N +1/+1 counters on target creature." Implementation: - types/keywords.rs: Add Keyword::Reinforce { count, cost } variant with FromStr parsing and MTGJSON deserialization support - parser/oracle_keyword.rs: Add Oracle text parser for "reinforce N—{cost}" using nom tag combinator (allow-noncombinator for em-dash separators) - database/synthesis.rs: Add synthesize_reinforce() producing an activated ability with composite cost (mana + self-discard), +1/+1 counter effect, and activation_zone = Some(Zone::Hand) Cards unlocked (5): Earthbrawn, Hunting Triad, Break Ties, Fowl Strike, Swell of Courage --- crates/engine/src/database/synthesis.rs | 147 +++++++++++++++++++++ crates/engine/src/parser/oracle_keyword.rs | 19 ++- crates/engine/src/types/keywords.rs | 30 +++++ 3 files changed, 195 insertions(+), 1 deletion(-) diff --git a/crates/engine/src/database/synthesis.rs b/crates/engine/src/database/synthesis.rs index c25c5442ef..36e4f86864 100644 --- a/crates/engine/src/database/synthesis.rs +++ b/crates/engine/src/database/synthesis.rs @@ -1265,6 +1265,54 @@ pub fn synthesize_outlast(face: &mut CardFace) { face.abilities.extend(outlast_abilities); } +/// CR 702.77a: Synthesize the Reinforce activated ability from `Keyword::Reinforce { count, cost }`. +/// "Reinforce N—[cost]" means "[Cost], Discard this card: Put N +1/+1 counters on target creature." +pub fn synthesize_reinforce(face: &mut CardFace) { + let reinforce_abilities: Vec = face + .keywords + .iter() + .filter_map(|kw| { + let Keyword::Reinforce { count, cost } = kw else { + return None; + }; + // CR 702.77a: Composite cost — pay mana, then discard this card. + let composite_cost = AbilityCost::Composite { + costs: vec![ + AbilityCost::Mana { cost: cost.clone() }, + // CR 702.77a: "Discard this card" — self_ref=true so the + // engine auto-discards the source card. + AbilityCost::Discard { + count: QuantityExpr::Fixed { value: 1 }, + filter: None, + random: false, + self_ref: true, + }, + ], + }; + // CR 702.77a: "Put N +1/+1 counters on target creature." + let effect = Effect::PutCounter { + counter_type: CounterType::Plus1Plus1, + count: QuantityExpr::Fixed { + value: *count as i32, + }, + target: TargetFilter::Typed(TypedFilter::new(TypeFilter::Creature)), + }; + let def = AbilityDefinition::new(AbilityKind::Activated, effect).cost(composite_cost); + // CR 702.77a: Reinforce is activated from hand (discard as cost). + // No zone restriction needed — the discard cost implicitly requires the card + // to be in hand. The default activation zone (battlefield) won't apply since + // the card is never on the battlefield when this ability is relevant. + // Actually, per CR 702.77b: "A creature card with reinforce may also be cast + // as a spell." The ability functions from hand, so we set activation_zone. + let mut def = def; + def.activation_zone = Some(Zone::Hand); + Some(def) + }) + .collect(); + + face.abilities.extend(reinforce_abilities); +} + /// Convert a typecycling subtype string to a `TargetFilter` for library search. /// /// Single subtypes (e.g., "Plains", "Forest") → subtype filter. @@ -3851,6 +3899,7 @@ pub fn synthesize_all(face: &mut CardFace) { synthesize_cycling(face); synthesize_scavenge(face); synthesize_outlast(face); + synthesize_reinforce(face); synthesize_casualty(face); synthesize_entwine(face); synthesize_madness_intrinsics(face); @@ -12081,3 +12130,101 @@ mod for_mirrodin_synthesis_tests { assert_eq!(trigger.trigger_zones, vec![Zone::Battlefield]); } } + +#[cfg(test)] +mod reinforce_synthesis_tests { + use super::*; + use crate::types::mana::{ManaCost, ManaCostShard}; + + fn face_with_reinforce(count: u32, cost: ManaCost) -> CardFace { + let mut face = CardFace::default(); + face.keywords.push(Keyword::Reinforce { count, cost }); + face + } + + /// CR 702.77a: Reinforce synthesis produces exactly one activated ability whose + /// shape matches the reminder text — hand activation, composite cost of mana + + /// self-discard, +1/+1 counters on target creature scaled by the fixed count. + #[test] + fn synthesize_reinforce_builds_activated_ability_with_correct_shape() { + let cost = ManaCost::Cost { + shards: vec![ManaCostShard::Green], + generic: 1, + }; + let mut face = face_with_reinforce(3, cost.clone()); + synthesize_reinforce(&mut face); + + assert_eq!(face.abilities.len(), 1, "exactly one reinforce ability"); + let def = &face.abilities[0]; + assert_eq!(def.kind, AbilityKind::Activated); + assert_eq!(def.activation_zone, Some(Zone::Hand)); + // Reinforce is instant-speed (no sorcery restriction). + assert!(!def.sorcery_speed); + + // CR 118.3: Composite cost — mana + discard-self. + match def.cost.as_ref().expect("reinforce must have a cost") { + AbilityCost::Composite { costs } => { + assert_eq!(costs.len(), 2); + assert!(matches!(&costs[0], AbilityCost::Mana { cost: c } if *c == cost)); + assert!(matches!( + &costs[1], + AbilityCost::Discard { + count: QuantityExpr::Fixed { value: 1 }, + filter: None, + random: false, + self_ref: true, + } + )); + } + other => panic!("expected Composite cost, got {:?}", other), + } + + // CR 702.77a: Effect is N +1/+1 counters on target creature. + match def.effect.as_ref() { + Effect::PutCounter { + counter_type, + count, + target, + } => { + assert_eq!(counter_type, &CounterType::Plus1Plus1); + assert_eq!(count, &QuantityExpr::Fixed { value: 3 }); + assert!( + matches!(target, TargetFilter::Typed(tf) if tf.type_filters.contains(&TypeFilter::Creature)) + ); + } + other => panic!("expected PutCounter effect, got {:?}", other), + } + } + + /// Reinforce with zero-cost mana (e.g., {0}) still produces a well-formed ability. + #[test] + fn synthesize_reinforce_handles_zero_cost() { + let cost = ManaCost::zero(); + let mut face = face_with_reinforce(2, cost); + synthesize_reinforce(&mut face); + assert_eq!(face.abilities.len(), 1); + } + + /// Cards without Reinforce are unaffected. + #[test] + fn synthesize_reinforce_is_noop_without_keyword() { + let mut face = CardFace::default(); + face.keywords.push(Keyword::Flying); + synthesize_reinforce(&mut face); + assert!(face.abilities.is_empty()); + } + + /// Idempotent: calling synthesize_reinforce twice doubles the abilities + /// (synthesis is additive, caller is responsible for single invocation). + #[test] + fn synthesize_reinforce_is_additive() { + let cost = ManaCost::Cost { + shards: vec![ManaCostShard::White], + generic: 2, + }; + let mut face = face_with_reinforce(1, cost); + synthesize_reinforce(&mut face); + synthesize_reinforce(&mut face); + assert_eq!(face.abilities.len(), 2); + } +} diff --git a/crates/engine/src/parser/oracle_keyword.rs b/crates/engine/src/parser/oracle_keyword.rs index 305414c137..e5f06ad179 100644 --- a/crates/engine/src/parser/oracle_keyword.rs +++ b/crates/engine/src/parser/oracle_keyword.rs @@ -886,6 +886,23 @@ pub(crate) fn parse_keyword_from_oracle(text: &str) -> Option { } } + // CR 702.77a: Reinforce N—{cost} — "[Cost], Discard this card: Put N +1/+1 counters + // on target creature." Same N—{cost} format as Suspend/Awaken. + if let Ok((rest, _)) = tag::<_, _, OracleError<'_>>("reinforce ").parse(text) { + if let Ok((after_count, count)) = nom_primitives::parse_number.parse(rest.trim()) { + let cost_str = after_count + .strip_prefix('\u{2014}') // allow-noncombinator: em-dash punctuation separator + .or_else(|| after_count.strip_prefix("\u{2014}")) // allow-noncombinator: em-dash variant + .or_else(|| after_count.strip_prefix("--")) // allow-noncombinator: ascii dash fallback + .unwrap_or(after_count) + .trim(); + if !cost_str.is_empty() { + let cost = crate::database::mtgjson::parse_mtgjson_mana_cost(cost_str); + return Some(Keyword::Reinforce { count, cost }); + } + } + } + // CR 702.60a: Ripple N — when you cast this spell, you may reveal the top N cards // of your library and cast any with the same name without paying their mana cost. // Keyword::Ripple is currently a unit variant (N is not yet tracked by the engine). @@ -1151,10 +1168,10 @@ pub fn keyword_display_name(keyword: &Keyword) -> String { Keyword::Increment => "increment".to_string(), Keyword::Specialize(_) => "specialize".to_string(), Keyword::Offering(quality) => format!("{} offering", quality.to_lowercase()), + Keyword::Reinforce { count, .. } => format!("reinforce {count}"), Keyword::Unknown(s) => s.to_lowercase(), } } - /// CR 702.24a: Render a cumulative-upkeep base cost as the display fragment /// used after `cumulative upkeep — `. Only the four cost shapes the /// cumulative-upkeep parser actually emits are handled (`Mana`, `PayLife`, diff --git a/crates/engine/src/types/keywords.rs b/crates/engine/src/types/keywords.rs index a808e1601e..c45527b102 100644 --- a/crates/engine/src/types/keywords.rs +++ b/crates/engine/src/types/keywords.rs @@ -576,6 +576,12 @@ pub enum Keyword { Entwine(ManaCost), Outlast(ManaCost), Scavenge(ManaCost), + /// CR 702.77a: Reinforce N—[cost] means "[Cost], Discard this card: + /// Put N +1/+1 counters on target creature." + Reinforce { + count: u32, + cost: ManaCost, + }, Fortify(ManaCost), /// RUNTIME: TODO — converter accepts this keyword but engine has no /// behavioral handler. CR 702.160a: Prototype — alt-cast using the @@ -1062,6 +1068,7 @@ impl Keyword { | Keyword::Ravenous | Keyword::ReadAhead | Keyword::Rebound + | Keyword::Reinforce { .. } | Keyword::Ripple | Keyword::Saddle(_) | Keyword::Scavenge(_) @@ -1529,6 +1536,21 @@ impl FromStr for Keyword { "echo" => return Ok(Keyword::Echo(parse_keyword_mana_cost(p))), "outlast" => return Ok(Keyword::Outlast(parse_keyword_mana_cost(p))), "scavenge" => return Ok(Keyword::Scavenge(parse_keyword_mana_cost(p))), + "reinforce" => { + // CR 702.77a: "Reinforce N\u{2014}[cost]" \u{2014} N is the first token, rest is mana cost. + let p = p.trim(); + if let Some((n_str, cost_str)) = p.split_once(' ') { + let count = n_str.trim().parse::().unwrap_or(1); + let cost = parse_keyword_mana_cost(cost_str.trim()); + return Ok(Keyword::Reinforce { count, cost }); + } else if let Ok(count) = p.parse::() { + return Ok(Keyword::Reinforce { + count, + cost: ManaCost::zero(), + }); + } + // Fall through to Unknown + } "fortify" => return Ok(Keyword::Fortify(parse_keyword_mana_cost(p))), "prototype" => return Ok(Keyword::Prototype(parse_keyword_mana_cost(p))), "plot" => return Ok(Keyword::Plot(parse_keyword_mana_cost(p))), @@ -2147,6 +2169,14 @@ fn keyword_from_tagged(variant: &str, data: &serde_json::Value) -> Result Ok(Keyword::Echo(mana(data)?)), "Outlast" => Ok(Keyword::Outlast(mana(data)?)), "Scavenge" => Ok(Keyword::Scavenge(mana(data)?)), + // CR 702.77a: Reinforce N—[cost]. Data is { "count": N, "cost": "..." }. + "Reinforce" => { + let obj = data.as_object().ok_or("Reinforce: expected object")?; + let count = obj.get("count").and_then(|v| v.as_u64()).unwrap_or(1) as u32; + let cost_val = obj.get("cost").unwrap_or(data); + let cost = mana(cost_val)?; + Ok(Keyword::Reinforce { count, cost }) + } "Fortify" => Ok(Keyword::Fortify(mana(data)?)), "Prototype" => Ok(Keyword::Prototype(mana(data)?)), "Plot" => Ok(Keyword::Plot(mana(data)?)),