Skip to content
Merged
2 changes: 2 additions & 0 deletions crates/engine/src/analysis/ability_graph.rs
Original file line number Diff line number Diff line change
Expand Up @@ -561,6 +561,7 @@ fn project_mana_production(p: &ManaProduction) -> (Vec<(usize, i64)>, AxisMagnit
(vec![(slot, a)], mag)
}
ManaProduction::ChosenColor { count, .. }
| ManaProduction::NotedType { count, .. }
| ManaProduction::OpponentLandColors { count, .. }
| ManaProduction::AnyTypeProduceableBy { count, .. }
| ManaProduction::AnyInCommandersColorIdentity { count, .. }
Expand Down Expand Up @@ -969,6 +970,7 @@ fn effect_projection(effect: &Effect) -> Projection {
| Effect::GrantCastingPermission { .. }
| Effect::ChooseFromZone { .. }
| Effect::RememberCard { .. }
| Effect::NoteManaSpent
| Effect::ForEachCategory { .. }
| Effect::ChooseObjectsIntoTrackedSet { .. }
| Effect::ChooseAndSacrificeRest { .. }
Expand Down
3 changes: 3 additions & 0 deletions crates/engine/src/game/ability_rw.rs
Original file line number Diff line number Diff line change
Expand Up @@ -3409,6 +3409,7 @@ fn legacy_effect(x: &Effect) -> bool {
| Effect::RegisterBending { .. }
| Effect::Cleanup { .. }
| Effect::Learn
| Effect::NoteManaSpent
| Effect::Forage
| Effect::Harness
| Effect::CollectEvidence { .. }
Expand Down Expand Up @@ -3743,6 +3744,7 @@ fn walk_ability(
distribution: _,
chosen_x: _,
cost_paid_object: _,
noted_mana_payment: _, // concrete captured payment snapshot, no read/write effect
cost_paid_object_ids: _,
effect_context_object: _,
amassed_army_object: _,
Expand Down Expand Up @@ -5654,6 +5656,7 @@ fn rw_effect(
| Effect::Harness
| Effect::ChooseAndSacrificeRest { .. }
| Effect::RememberCard { .. }
| Effect::NoteManaSpent
| Effect::ForEachCategory { .. }
| Effect::VentureInto { .. }
| Effect::TakeTheInitiative
Expand Down
30 changes: 30 additions & 0 deletions crates/engine/src/game/ability_scan.rs
Original file line number Diff line number Diff line change
Expand Up @@ -229,6 +229,7 @@ fn resolved_ability_axes(a: &ResolvedAbility, mode: ScanMode) -> Axes {
targets: _, // concrete announced target refs (already resolved)
source_id: _, // object id
source_incarnation: _, // self-transform epoch latch, no dynamic read
noted_mana_payment: _, // concrete activation-payment snapshot, no dynamic read
trigger_source: _, // exact triggered-source authority, no dynamic read
trigger_definition_ref: _, // exact trigger occurrence, no dynamic read
force_block_attacker: _, // exact force-block referent, no dynamic read
Expand Down Expand Up @@ -798,6 +799,8 @@ fn scan_effect(x: &Effect, mode: ScanMode) -> Axes {
Effect::TimeTravel => Axes::NONE,
Effect::BecomeMonarch => Axes::NONE,
Effect::NoOp => Axes::NONE,
// Captured at activation time; no resolution-time dynamic read.
Effect::NoteManaSpent => Axes::NONE,
Effect::Proliferate => Axes::NONE,
Effect::ProliferateTarget { target } => {
let mut acc = Axes::NONE;
Expand Down Expand Up @@ -4999,6 +5002,14 @@ fn scan_mana_production(p: &ManaProduction, mode: ScanMode) -> Axes {
| ManaProduction::AnyInCommandersColorIdentity { count, .. } => {
scan_quantity_expr(count, mode)
}
// `NotedManaSpent` is mutable per-object state written by a companion
// `Effect::NoteManaSpent`, so sibling activations can affect its value.
ManaProduction::NotedType { count } => Axes {
event: false,
sibling: true,
projected: false,
}
.or(scan_quantity_expr(count, mode)),
// SCOPED-OBJECT (Omnath, Locus of All): a SINGLE scoped object's colors,
// NOT a board aggregate — the scope's own read surface is the sole sibling
// source (CR 202.2c). NO own sibling literal.
Expand Down Expand Up @@ -5363,6 +5374,7 @@ fn effect_target_ctx(e: &Effect, mode: ScanMode) -> FilterReadContext {
| Effect::TimeTravel
| Effect::BecomeMonarch
| Effect::NoOp
| Effect::NoteManaSpent
| Effect::Proliferate
| Effect::ProliferateTarget { .. }
| Effect::Populate
Expand Down Expand Up @@ -5767,6 +5779,7 @@ fn effect_census_role(e: &Effect) -> CensusRole {
| Effect::TimeTravel
| Effect::BecomeMonarch
| Effect::NoOp
| Effect::NoteManaSpent
| Effect::Proliferate
| Effect::ProliferateTarget { .. }
| Effect::Populate
Expand Down Expand Up @@ -6000,6 +6013,7 @@ pub(crate) fn effect_is_randomness_bearing(e: &Effect) -> bool {
| Effect::TimeTravel
| Effect::BecomeMonarch
| Effect::NoOp
| Effect::NoteManaSpent
| Effect::Proliferate
| Effect::ProliferateTarget { .. }
| Effect::Populate
Expand Down Expand Up @@ -7360,6 +7374,22 @@ mod tests {
assert!(effect_is_randomness_bearing(&Effect::Clash));
}

#[test]
fn noted_mana_effect_is_read_free_and_deterministic() {
let effect = Effect::NoteManaSpent;
let axes = scan_effect(&effect, ScanMode::LoopFirewall);
assert!(!axes.event && !axes.sibling && !axes.projected);
assert_eq!(
effect_target_ctx(&effect, ScanMode::LoopFirewall),
FilterReadContext::SnapshotOrEvent
);
assert_eq!(
effect_census_role(&effect),
CensusRole::Relax(RelaxReason::BoundedOrNoPopulation)
);
assert!(!effect_is_randomness_bearing(&effect));
}

#[test]
fn spell_ability_randomness_ability_level_and_tree() {
use crate::types::ability::{AbilityKind, TargetSelectionMode};
Expand Down
18 changes: 18 additions & 0 deletions crates/engine/src/game/casting.rs
Original file line number Diff line number Diff line change
Expand Up @@ -15577,6 +15577,24 @@ fn pay_ability_mana_cost_with_choices_excluding_and_parent(
parent,
)?;

// CR 106.1b + CR 602.2b (issue #6504): stamp the mana type(s) just spent
// onto this ability's own source, mirroring `colors_spent_to_cast`'s
// cast-side stamp-then-read idiom. This is the single authority where an
// activated ability's mana sub-cost is paid (both the direct-activation
// and interactive/PendingCast routes funnel through here). PURELY A
// BRIDGE: `push_ability_entry` drains this field synchronously into
// THIS activation's own `ResolvedAbility::noted_mana_payment` snapshot
// moments later, before any later activation of the same permanent
// could occur — see `GameObject::mana_spent_to_activate` for why a
// companion "note the type of mana spent to pay this activation cost"
// effect (Jeweled Amulet) never reads this field directly.
let spent_units = match &payment {
ManaCostPayment::Paid(units) | ManaCostPayment::Paused { value: units, .. } => units,
};
if let Some(obj) = state.objects.get_mut(&source_id) {
obj.mana_spent_to_activate = spent_units.iter().map(|unit| unit.color).collect();
}

Ok(match payment {
ManaCostPayment::Paid(_) => ManaCostPayment::Paid(()),
ManaCostPayment::Paused {
Expand Down
33 changes: 31 additions & 2 deletions crates/engine/src/game/casting_costs.rs
Original file line number Diff line number Diff line change
Expand Up @@ -5,8 +5,8 @@ use crate::types::ability::{
is_chosen_remove_counter_cost_count, AbilityCondition, AbilityCost, AbilityDefinition,
AbilityKind, AdditionalCost, AdditionalCostInstance, AdditionalCostOrigin, AggregateFunction,
BeholdCostAction, CastTimingPermission, Comparator, CostPaidObjectSnapshot,
CounterCostSelection, Effect, KickerVariant, ObjectProperty, QuantityExpr, QuantityRef,
ReplacementDefinition, ResolvedAbility, SacrificeCost, SacrificeRequirement,
CounterCostSelection, Effect, KickerVariant, NotedManaPayment, ObjectProperty, QuantityExpr,
QuantityRef, ReplacementDefinition, ResolvedAbility, SacrificeCost, SacrificeRequirement,
SpellCastingOptionKind, SpellContext, SpellStackToGraveyardReplacement, StaticCondition,
TapCreaturesAggregate, TargetFilter, ThisWayCause, TypeFilter, TypedFilter, EXILE_COST_X,
};
Expand Down Expand Up @@ -5406,6 +5406,30 @@ pub(super) fn push_ability_entry(
// also clears any stale value.
state.announced_source_x = resolved.chosen_x.map(|x| (source_id, x));

// CR 106.1b + CR 400.7 + CR 602.2b (issue #6504): consume the source's
// transient mana-spent-to-activate latch into THIS activation's own
// `ResolvedAbility` snapshot (and every sub/else branch — `Effect::
// NoteManaSpent` is typically a `sub_ability`, which resolves as its own
// separate node, so the stamp must recurse; see
// `set_noted_mana_payment_recursive`), paired with the source's
// incarnation at this exact moment. Since `push_ability_entry` is the
// single authority where an activated ability reaches the stack, this
// capture happens synchronously, immediately after cost payment
// completed and before any later activation of the same permanent could
// occur — so a permanent untapped and reactivated while this ability
// still sits unresolved on the stack cannot corrupt what THIS instance
// observed. The latch is cleared immediately after, so it never appears
// to hold a stale value between activations.
if let Some(obj) = state.objects.get_mut(&source_id) {
if !obj.mana_spent_to_activate.is_empty() {
let payment = NotedManaPayment {
types: std::mem::take(&mut obj.mana_spent_to_activate),
source_incarnation: obj.incarnation,
};
resolved.set_noted_mana_payment_recursive(payment);
}
}

// CR 603.4: Stamp the printed-ability index for per-turn resolution tracking.
resolved.ability_index = Some(ability_index);
stack::push_to_stack(
Expand Down Expand Up @@ -11400,6 +11424,11 @@ pub(crate) fn production_override_for_option(
}
| crate::types::ability::ManaProduction::ChoiceAmongCombinations { .. }
| crate::types::ability::ManaProduction::DistinctColorsAmongPermanents { .. }
// CR 106.1b + CR 106.5: like `ChosenColor { fixed_alternative: None }`,
// the produced type is fixed by engine-set state read at production
// time (`noted_mana_type_for`), not chosen per auto-tap option — no
// override needed, and CR 106.5 governs the no-noted-type case.
| crate::types::ability::ManaProduction::NotedType { .. }
| crate::types::ability::ManaProduction::TriggerEventManaType => None,
}
}
Expand Down
4 changes: 4 additions & 0 deletions crates/engine/src/game/coverage.rs
Original file line number Diff line number Diff line change
Expand Up @@ -1976,6 +1976,9 @@ fn fmt_mana_production(mp: &ManaProduction) -> String {
ManaProduction::ChosenColor { count, .. } => {
format!("{} of chosen color", fmt_quantity(count))
}
ManaProduction::NotedType { count } => {
format!("{} of noted type", fmt_quantity(count))
}
ManaProduction::OpponentLandColors { count } => {
format!("{} of opponent land colors", fmt_quantity(count))
}
Expand Down Expand Up @@ -3692,6 +3695,7 @@ fn effect_details(effect: &Effect) -> Vec<(String, String)> {
| Effect::Forage
| Effect::Harness
| Effect::Learn
| Effect::NoteManaSpent
| Effect::SwitchPT { .. }
| Effect::Myriad
| Effect::Encore
Expand Down
1 change: 1 addition & 0 deletions crates/engine/src/game/effects/additional_phase.rs
Original file line number Diff line number Diff line change
Expand Up @@ -290,6 +290,7 @@ mod tests {
starting_with: None,
chosen_x: None,
cost_paid_object: None,
noted_mana_payment: None,
cost_paid_object_ids: Vec::new(),
effect_context_object: None,
amassed_army_object: None,
Expand Down
11 changes: 11 additions & 0 deletions crates/engine/src/game/effects/copy_spell.rs
Original file line number Diff line number Diff line change
Expand Up @@ -827,9 +827,20 @@ pub(crate) fn set_resolved_source_recursive(ability: &mut ResolvedAbility, sourc
}
}

/// CR 707.10 + CR 707.10b: Normalize a copied activated/triggered ability.
/// The copy keeps the original ability's source (unlike a spell copy, which
/// sources itself), so this only re-stamps `source_id` uniformly through the
/// chain — but it must ALSO clear `noted_mana_payment` (issue #6504):
/// CR 707.10 says a copy of an activated ability is not itself activated, so
/// it never paid a mana cost. A naive struct clone otherwise carries the
/// ORIGINAL activation's payment snapshot along for the ride, and
/// `Effect::NoteManaSpent` resolving on the copy (Jeweled Amulet's first
/// ability copied via Rings of Brighthearth or similar) would falsely note
/// mana colors the copy never paid.
fn preserve_ability_copy_source_recursive(ability: &mut ResolvedAbility) {
let source_id = ability.source_id;
set_resolved_source_recursive(ability, source_id);
ability.clear_noted_mana_payment_recursive();
}

/// CR 707.10d: Replace every object target on a copied spell with `new_target`.
Expand Down
1 change: 1 addition & 0 deletions crates/engine/src/game/effects/double.rs
Original file line number Diff line number Diff line change
Expand Up @@ -342,6 +342,7 @@ mod tests {
starting_with: None,
chosen_x: None,
cost_paid_object: None,
noted_mana_payment: None,
cost_paid_object_ids: Vec::new(),
effect_context_object: None,
amassed_army_object: None,
Expand Down
1 change: 1 addition & 0 deletions crates/engine/src/game/effects/extra_turn.rs
Original file line number Diff line number Diff line change
Expand Up @@ -95,6 +95,7 @@ mod tests {
starting_with: None,
chosen_x: None,
cost_paid_object: None,
noted_mana_payment: None,
cost_paid_object_ids: Vec::new(),
effect_context_object: None,
amassed_army_object: None,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -113,6 +113,7 @@ mod tests {
starting_with: None,
chosen_x: None,
cost_paid_object: None,
noted_mana_payment: None,
cost_paid_object_ids: Vec::new(),
effect_context_object: None,
amassed_army_object: None,
Expand Down
32 changes: 32 additions & 0 deletions crates/engine/src/game/effects/mana.rs
Original file line number Diff line number Diff line change
Expand Up @@ -707,6 +707,23 @@ fn resolve_mana_types_impl(
(None, None) => Vec::new(),
}
}
// CR 106.1b + CR 106.5: Jeweled Amulet — "Add one mana of this
// artifact's last noted type." Unlike `ChosenColor` (a
// player-prompted `ManaColor`), the noted value is engine-set
// (`Effect::NoteManaSpent`) and `ManaType`-valued (colorless is a
// real noted type per the card's ruling). A card in this class always
// notes exactly one type — its cost's own generic mana is spent as a
// single unit-worth of one type — so, mirroring `AnyOneColor`'s
// repeat-by-count idiom, the first noted type repeats `count` times.
// No noted type (never activated the noting ability, or a fresh
// incarnation after a zone change) produces no mana.
ManaProduction::NotedType { count } => {
let amount = resolve_count(count, state, ability, controller, source_id);
match noted_mana_type_for(state, source_id) {
Some(mana_type) => vec![mana_type; amount],
None => Vec::new(),
}
}
Comment thread
coderabbitai[bot] marked this conversation as resolved.
// CR 106.7: Produce mana of any color that a land an opponent controls could produce.
// Delegates to mana_sources::opponent_land_color_options for the shared computation.
ManaProduction::OpponentLandColors { count } => {
Expand Down Expand Up @@ -998,6 +1015,21 @@ pub(crate) fn chosen_color_for_mana(
})
}

/// CR 106.1b: The first mana type noted by a past `Effect::NoteManaSpent`
/// resolution on `source_id` ("this artifact's last noted type" — Jeweled
/// Amulet). Unlike `chosen_color_for_mana`, this is never player-prompted —
/// engine-set state only, with no `last_named_choice` fallback.
pub(crate) fn noted_mana_type_for(
state: &GameState,
source_id: crate::types::identifiers::ObjectId,
) -> Option<ManaType> {
state
.objects
.get(&source_id)
.and_then(|obj| obj.noted_mana_spent())
.and_then(|types| types.first().copied())
}

/// Convert a ManaColor to the runtime ManaType.
/// CR 106.1a: There are five colors of mana: white, blue, black, red, and green.
/// CR 106.1b: There are six types of mana: white, blue, black, red, green, and colorless.
Expand Down
2 changes: 2 additions & 0 deletions crates/engine/src/game/effects/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -157,6 +157,7 @@ pub mod manifest_dread;
pub mod mill;
pub mod monstrosity;
pub mod myriad;
pub mod note_mana_spent;
pub mod opponent_guess;
pub mod overload;
pub mod pair_with;
Expand Down Expand Up @@ -4410,6 +4411,7 @@ pub fn resolve_effect(
Effect::GrantCastingPermission { .. } => grant_permission::resolve(state, ability, events),
Effect::ChooseFromZone { .. } => choose_from_zone::resolve(state, ability, events),
Effect::RememberCard { .. } => remember_card::resolve(state, ability, events),
Effect::NoteManaSpent => note_mana_spent::resolve(state, ability, events),
Effect::ForEachCategory { .. } => {
choose_from_zone::resolve_for_each_category(state, ability, events)
}
Expand Down
Loading
Loading