Skip to content
Open
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
50 changes: 50 additions & 0 deletions crates/engine/src/game/ability_rw.rs
Original file line number Diff line number Diff line change
Expand Up @@ -8230,4 +8230,54 @@ mod tests {
assert_eq!(p.reads_event_live, twin.reads_event_live);
assert_eq!(p.legacy_batch_prompt(), twin.legacy_batch_prompt());
}

/// CR 400.7d: the Adamant *rider* cards (Slaying Fire, Cauldron's Gift, …)
/// re-route from `AbilityCondition::ManaColorSpent` — an inert,
/// reads-nothing arm — to the generic
/// `QuantityCheck { ManaSpentToCast { OfColor } }`, which resolves through
/// `legacy_ref()`. Pin BOTH sides so the delta is explicit rather than
/// incidental: the flip is toward the MORE conservative classification
/// (batch-prompt rather than silent auto-order), which is fail-safe, and a
/// future retirement of `ManaColorSpent` cannot silently change it.
///
/// Companion to `adamant_rider_generic_shape_reads_all_scan_axes` in
/// `ability_scan.rs`, which pins the corresponding scan-axis delta. Without
/// this test the `ability_rw` half of that re-routing is unpinned.
#[test]
fn adamant_rider_generic_shape_is_more_conservative_than_legacy() {
use crate::types::ability::{CastManaObjectScope, CastManaSpentMetric};
use crate::types::mana::ManaColor;

let generic = AbilityCondition::QuantityCheck {
lhs: qref(QuantityRef::ManaSpentToCast {
scope: CastManaObjectScope::SelfObject,
metric: CastManaSpentMetric::OfColor {
color: ManaColor::Red,
},
}),
comparator: Comparator::GE,
rhs: qfix(3),
};
let legacy = AbilityCondition::ManaColorSpent {
color: ManaColor::Red,
minimum: 3,
};

// SCOPE OF THIS TEST: it pins the rw CLASSIFICATION of both shapes. It
// constructs the conditions directly and never invokes the parser, so it
// does NOT detect a revert of the `OfColor` lowering — that is guarded by
// `leading_word_mana_spent_condition_parses_adamant` in
// `parser/oracle_effect/conditions.rs`. What reverting the lowering WOULD
// do is make this test's `generic` shape unreachable in production while
// leaving it green; the pin below is what keeps the two classifications
// visibly different so such a change cannot pass unnoticed.
assert!(
rw_ability_condition(&generic).legacy_batch_prompt(),
"the generic ManaSpentToCast shape must carry the legacy_ref batch-prompt marker"
);
assert!(
!rw_ability_condition(&legacy).legacy_batch_prompt(),
"the legacy ManaColorSpent arm reads nothing; pinned so the delta stays visible"
);
}
}
60 changes: 60 additions & 0 deletions crates/engine/src/game/ability_scan.rs
Original file line number Diff line number Diff line change
Expand Up @@ -7929,6 +7929,66 @@ mod tests {
)));
}

/// CR 400.7d + CR 601.2h: the Adamant ability rider ("if at least three red
/// mana was spent to cast this spell, it deals 4 damage instead") now parses
/// to the generic `QuantityCheck { ManaSpentToCast { .., OfColor } }` shape
/// instead of the legacy `AbilityCondition::ManaColorSpent`. That moves it
/// from the `Axes::NONE` arm (`:2572`) onto the `Axes::CONSERVATIVE` arm
/// (`:2452`, reached via `QuantityCheck` → `scan_quantity_expr`), flipping
/// ALL THREE scan axes false→true for the 11 affected cards.
///
/// RULING — the flip is accepted, and is the intended direction:
/// - `event`: CORRECT, not merely conservative. CR 400.7d makes the paid-mana
/// record a cost-paid-object characteristic, which is precisely what the
/// `event` axis names; the legacy `Axes::NONE` under-read it.
/// - `sibling` / `projected`: over-inclusive but fail-SAFE. The payment record
/// is stamped once at CR 601.2h and is neither sibling-mutable nor a
/// player-level projected resource, so `true` can only make the analysis
/// reject an auto-order cover (prompt) — never auto-resolve something it
/// should have prompted on.
///
/// Neither `ordering_parity_sweep` (which imports `ability_rw` only) nor
/// `coverage-data.json` observes this axis, hence this direct assertion.
#[test]
fn adamant_rider_generic_shape_reads_all_scan_axes() {
let generic = AbilityCondition::QuantityCheck {
lhs: QuantityExpr::Ref {
qty: QuantityRef::ManaSpentToCast {
scope: CastManaObjectScope::SelfObject,
metric: CastManaSpentMetric::OfColor {
color: ManaColor::Red,
},
},
},
comparator: Comparator::GE,
rhs: QuantityExpr::Fixed { value: 3 },
};
// SCOPE OF THIS TEST: it pins the scan-axis CLASSIFICATION of both
// shapes. It constructs the conditions directly and never invokes the
// parser, so it does NOT detect a revert of the `OfColor` lowering —
// that is guarded by `leading_word_mana_spent_condition_parses_adamant`
// in `parser/oracle_effect/conditions.rs`. The legacy pin below is what
// keeps the delta explicit.
let generic_axes = scan_ability_condition(&generic, ScanMode::Conservative);
assert!(generic_axes.event);
assert!(generic_axes.sibling);
assert!(generic_axes.projected);
// The public projection consumed by `analysis::resource` agrees.
assert!(ability_condition_reads_projected_resource(&generic));

// Pin the legacy shape's classification so the delta is explicit and a
// future retirement of `ManaColorSpent` cannot silently change it.
let legacy = AbilityCondition::ManaColorSpent {
color: ManaColor::Red,
minimum: 3,
};
let legacy_axes = scan_ability_condition(&legacy, ScanMode::Conservative);
assert!(!legacy_axes.event);
assert!(!legacy_axes.sibling);
assert!(!legacy_axes.projected);
assert!(!ability_condition_reads_projected_resource(&legacy));
}

// ---- BLOCKER 1 regression: multi_target bounds are traversed ----
#[test]
fn multi_target_bound_event_read_classifies() {
Expand Down
4 changes: 3 additions & 1 deletion crates/engine/src/game/ability_utils.rs
Original file line number Diff line number Diff line change
Expand Up @@ -4204,7 +4204,9 @@ fn quantity_ref_target_slot_spec(qty: &QuantityRef) -> Option<TargetFilter> {
CastManaSpentMetric::FromSource { source_filter } => {
filter_target_slot_filter(source_filter)
}
CastManaSpentMetric::Total | CastManaSpentMetric::DistinctColors => None,
CastManaSpentMetric::Total
| CastManaSpentMetric::DistinctColors
| CastManaSpentMetric::OfColor { .. } => None,
},
QuantityRef::PlayerCount {
filter: crate::types::ability::PlayerFilter::ControlsCount { filter, .. },
Expand Down
17 changes: 14 additions & 3 deletions crates/engine/src/game/coverage.rs
Original file line number Diff line number Diff line change
Expand Up @@ -1753,9 +1753,20 @@ fn fmt_quantity_ref(qty: &QuantityRef) -> String {
QuantityRef::TimesCostPaidThisResolution => {
"times the repeated optional cost was paid this resolution".into()
}
QuantityRef::ManaSpentToCast { scope, metric } => {
format!("mana spent to cast ({scope:?}, {metric:?})")
}
QuantityRef::ManaSpentToCast { scope, metric } => match metric {
// CR 106.3: the per-color leaf names a concrete color, so render it
// in words. The other three metrics keep their existing `{metric:?}`
// rendering byte-identically.
crate::types::ability::CastManaSpentMetric::OfColor { color } => format!(
"mana spent to cast ({scope:?}, {} mana)",
fmt_mana_color_full(color)
),
crate::types::ability::CastManaSpentMetric::Total
| crate::types::ability::CastManaSpentMetric::DistinctColors
| crate::types::ability::CastManaSpentMetric::FromSource { .. } => {
format!("mana spent to cast ({scope:?}, {metric:?})")
}
},
QuantityRef::EventContextSourceCostX => "X of triggering spell".into(),
QuantityRef::EventContextSourceModesChosen => {
"modes chosen for the triggering spell".into()
Expand Down
4 changes: 3 additions & 1 deletion crates/engine/src/game/layers.rs
Original file line number Diff line number Diff line change
Expand Up @@ -2845,7 +2845,9 @@ fn quantity_ref_reads_life(qty: &QuantityRef) -> bool {
CastManaSpentMetric::FromSource { source_filter } => {
target_filter_reads_life_total(source_filter)
}
CastManaSpentMetric::Total | CastManaSpentMetric::DistinctColors => false,
CastManaSpentMetric::Total
| CastManaSpentMetric::DistinctColors
| CastManaSpentMetric::OfColor { .. } => false,
},

// CR 603.2c: the trigger-event player set filters candidates through a
Expand Down
3 changes: 3 additions & 0 deletions crates/engine/src/game/quantity.rs
Original file line number Diff line number Diff line change
Expand Up @@ -1640,6 +1640,9 @@ pub(crate) fn resolve_mana_spent_to_cast_metric(
CastManaSpentMetric::DistinctColors => {
usize_to_i32_saturating(spent_colors.distinct_colors())
}
// CR 106.3 + CR 601.2h: how much mana of exactly this color paid the
// cost, read off the same per-color payment tally.
CastManaSpentMetric::OfColor { color } => u32_to_i32_saturating(spent_colors.get(*color)),
CastManaSpentMetric::FromSource { source_filter } => usize_to_i32_saturating(
source_snapshots
.iter()
Expand Down
4 changes: 3 additions & 1 deletion crates/engine/src/game/triggers.rs
Original file line number Diff line number Diff line change
Expand Up @@ -10080,7 +10080,9 @@ fn quantity_ref_refs_cost_paid_object(qty: &QuantityRef) -> bool {
CastManaSpentMetric::FromSource { source_filter } => {
source_filter.references_cost_paid_object()
}
CastManaSpentMetric::Total | CastManaSpentMetric::DistinctColors => false,
CastManaSpentMetric::Total
| CastManaSpentMetric::DistinctColors
| CastManaSpentMetric::OfColor { .. } => false,
},

// Non-object, non-filter refs never read the cost-paid object. Listed
Expand Down
101 changes: 98 additions & 3 deletions crates/engine/src/parser/oracle_effect/conditions.rs
Original file line number Diff line number Diff line change
Expand Up @@ -3870,6 +3870,23 @@ fn parse_symbolic_mana_color_spent_condition(
Ok((rest, condition))
}

/// CR 106.3 + CR 601.2h: legacy leading-word form, "at least N <color> mana was
/// spent to cast <self>" → `AbilityCondition::ManaColorSpent`.
///
/// SHADOWED, kept as a fallback only. `parse_mana_color_spent_condition_text`
/// has exactly one caller (`parse_condition_text`), and every non-test caller of
/// that reaches it only through `.or_else(..)` AFTER
/// `try_nom_condition_as_ability_condition`, whose `parse_inner_condition`
/// fall-through now recognizes this exact phrase via
/// `parse_color_mana_spent_threshold` and bridges it to the canonical
/// `AbilityCondition::QuantityCheck { ManaSpentToCast { scope, OfColor } }`.
/// So on the production path this arm no longer fires for any real input; the
/// generic form carries an explicit `CastManaObjectScope` (CR 400.7d) that
/// `ManaColorSpent` cannot express.
///
/// `parse_symbolic_mana_color_spent_condition` above is NOT shadowed — the
/// `{W}{W}` symbolic form has no `parse_inner_condition` grammar and stays live
/// for 22 cards.
fn parse_word_mana_color_spent_condition(
input: &str,
) -> super::super::oracle_nom::error::OracleResult<'_, AbilityCondition> {
Expand Down Expand Up @@ -8839,6 +8856,13 @@ mod tests {
);
}

/// CR 106.3 + CR 601.2h + CR 400.7d: the leading-word Adamant rider now
/// bridges through `parse_inner_condition` →
/// `parse_color_mana_spent_threshold` to the canonical generic shape, which
/// (unlike the legacy `ManaColorSpent`) records the subject anaphora as an
/// explicit `CastManaObjectScope`. Runtime reading is unchanged:
/// `QuantityCheck` resolves `ManaSpentToCast { SelfObject, .. }` against the
/// ability's own source object, exactly as the `ManaColorSpent` arm did.
#[test]
fn leading_word_mana_spent_condition_parses_adamant() {
let (condition, body) = strip_leading_general_conditional(
Expand All @@ -8848,13 +8872,84 @@ mod tests {
assert_eq!(body, "it deals 4 damage instead.");
assert_eq!(
condition,
Some(AbilityCondition::ManaColorSpent {
color: ManaColor::Red,
minimum: 3,
Some(AbilityCondition::QuantityCheck {
lhs: QuantityExpr::Ref {
qty: QuantityRef::ManaSpentToCast {
scope: CastManaObjectScope::SelfObject,
metric: CastManaSpentMetric::OfColor {
color: ManaColor::Red
},
},
},
comparator: Comparator::GE,
rhs: QuantityExpr::Fixed { value: 3 },
})
);
}

/// CR 400.7d: the "that spell" anaphora on the PRODUCTION path.
///
/// `parse_condition_text`'s leading-word arm
/// (`parse_word_mana_color_spent_condition`) only ever accepted the
/// `{this spell, it, them, ~}` subject set and flattened the anaphora away
/// into a scope-less `ManaColorSpent`. That arm is now shadowed: every
/// non-test caller reaches `try_nom_condition_as_ability_condition` first,
/// and its `parse_inner_condition` fall-through recognizes "that spell" and
/// records it as `CastManaObjectScope::TriggeringSpell`.
///
/// RULING — this is acceptable and strictly more faithful. CR 400.7d
/// distinguishes reading the payment record of the object the ability is on
/// from reading that of a referenced spell; the generic shape carries the
/// distinction, the legacy shape could not. Zero live cards use "that
/// spell" in this rider today (all 11 leading-word Adamant cards say "this
/// spell"), so this is a latent capability, not a behavior change. The
/// runtime `QuantityCheck` reader resolves `TriggeringSpell` through the
/// trigger event context rather than `ability.source_id`, which is the
/// correct reading for that phrasing.
#[test]
fn leading_word_mana_spent_condition_records_that_spell_anaphora() {
let condition = try_nom_condition_as_ability_condition(
"at least three white mana was spent to cast that spell",
&mut ParseContext::default(),
)
.expect("the generic grammar must recognize the 'that spell' anaphora");
assert_eq!(
condition,
AbilityCondition::QuantityCheck {
lhs: QuantityExpr::Ref {
qty: QuantityRef::ManaSpentToCast {
scope: CastManaObjectScope::TriggeringSpell,
metric: CastManaSpentMetric::OfColor {
color: ManaColor::White
},
},
},
comparator: Comparator::GE,
rhs: QuantityExpr::Fixed { value: 3 },
}
);

// Reach-guard + contrast: "this spell" stays `SelfObject`, so the scope
// axis is genuinely threaded rather than hardcoded.
let self_scoped = try_nom_condition_as_ability_condition(
"at least three white mana was spent to cast this spell",
&mut ParseContext::default(),
)
.expect("the 'this spell' anaphora must still parse");
assert!(matches!(
self_scoped,
AbilityCondition::QuantityCheck {
lhs: QuantityExpr::Ref {
qty: QuantityRef::ManaSpentToCast {
scope: CastManaObjectScope::SelfObject,
..
},
},
..
}
));
}

/// CR 122.1 + CR 608.2c: "there are no counters on ~" round-trips through
/// the bridge to a `QuantityCheck` against `AnyCountersOnSelf`. Previously
/// the bridge returned `None` for `CounterMatch::Any`, which silently
Expand Down
Loading
Loading