Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
147 changes: 147 additions & 0 deletions crates/engine/src/database/synthesis.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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<AbilityDefinition> = 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.
Expand Down Expand Up @@ -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);
Expand Down Expand Up @@ -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);
}
}
19 changes: 18 additions & 1 deletion crates/engine/src/parser/oracle_keyword.rs
Original file line number Diff line number Diff line change
Expand Up @@ -886,6 +886,23 @@ pub(crate) fn parse_keyword_from_oracle(text: &str) -> Option<Keyword> {
}
}

// 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()) {

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Support X-valued reinforce counts

This only accepts fixed numeric counts, so printed cards with Reinforce X—... (for example Swell of Courage and Wren's Run Hydra in the repo data) still fail keyword extraction even though they are part of the reinforce mechanic being implemented. The count also needs to stay linked to the chosen X for the mana cost at activation/resolution; model it with a quantity/X-capable value rather than u32 and parse with the existing X-aware quantity primitive.

Useful? React with 👍 / 👎.

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).
Expand Down Expand Up @@ -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`,
Expand Down
30 changes: 30 additions & 0 deletions crates/engine/src/types/keywords.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -1062,6 +1068,7 @@ impl Keyword {
| Keyword::Ravenous
| Keyword::ReadAhead
| Keyword::Rebound
| Keyword::Reinforce { .. }
| Keyword::Ripple
| Keyword::Saddle(_)
| Keyword::Scavenge(_)
Expand Down Expand Up @@ -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::<u32>().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::<u32>() {
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))),
Expand Down Expand Up @@ -2147,6 +2169,14 @@ fn keyword_from_tagged(variant: &str, data: &serde_json::Value) -> Result<Keywor
"Echo" => 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)?)),
Expand Down
Loading