Skip to content
Merged
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
269 changes: 266 additions & 3 deletions crates/engine/src/game/casting_tests.rs
Original file line number Diff line number Diff line change
Expand Up @@ -9027,16 +9027,279 @@ fn jhoira_exile_cost_activation_suspends_the_exiled_card() {
);
}

/// Negative sibling of `jhoira_exile_cost_activation_suspends_the_exiled_card`
/// and the runtime proof for the COST-PAID binding class of the keyword anaphor.
///
/// CR 608.2k: "if it doesn't have suspend" back-references the object introduced
/// by the ability's COST, so the parser lowers Jhoira's gate to
/// `AbilityCondition::CostPaidObjectMatchesFilter` (clause-context re-anchoring
/// in `rewrite_keyword_anaphor_for_cost_paid_parent`) — `TargetFilter::
/// CostPaidObject` is never written into `ResolvedAbility.targets`, and an
/// activated ability has no trigger event, so the target-scoped reading would
/// find no subject at all.
///
/// When the exiled card ALREADY has printed `Suspend 4—{U}` the gate must be
/// FALSE and no grant may fire. Revert-fail: with the old `SourceLacksKeyword`
/// lowering the gate reads Jhoira (never suspended), the redundant grant fires,
/// and `off_zone_characteristics::upsert_keyword_contribution` replaces the
/// printed contribution with `Suspend { count: 0, cost: {} }` — so the effective
/// suspend cost collapses from `{U}` to `{0}`.
#[test]
fn jhoira_does_not_regrant_suspend_to_a_natively_suspended_card() {
use super::super::engine::apply_as_current;
use crate::types::counter::CounterType;
use crate::types::keywords::Keyword;
use crate::types::mana::ManaCostShard;

let blue = ManaCost::Cost {
shards: vec![ManaCostShard::Blue],
generic: 0,
};

let mut state = setup_game_at_main_phase();
let jhoira = create_object(
&mut state,
CardId(983),
PlayerId(0),
"Jhoira of the Ghitu".to_string(),
Zone::Battlefield,
);
// The eligible exile-cost card, printed with `Suspend 4—{U}`.
let nonland = create_object(
&mut state,
CardId(984),
PlayerId(0),
"Already Suspended Sorcery".to_string(),
Zone::Hand,
);
{
let obj = state.objects.get_mut(&jhoira).unwrap();
obj.card_types.core_types.push(CoreType::Creature);
let parsed = crate::parser::oracle::parse_oracle_text(
"{2}, Exile a nonland card from your hand: Put four time \
counters on the exiled card. If it doesn't have suspend, it \
gains suspend.",
"Jhoira of the Ghitu",
&[],
&[String::from("Creature")],
&[],
);
Arc::make_mut(&mut obj.abilities).extend(parsed.abilities);
}
{
let nl = state.objects.get_mut(&nonland).unwrap();
nl.card_types.core_types.push(CoreType::Sorcery);
nl.base_card_types = nl.card_types.clone();
let printed = Keyword::Suspend {
count: 4,
cost: blue.clone(),
};
// Both lists: the cost-payment LKI snapshot reads `keywords`, and the
// post-exile off-zone read starts from `base_keywords`.
nl.keywords.push(printed.clone());
nl.base_keywords.push(printed);
}
add_mana(&mut state, PlayerId(0), ManaType::Colorless, 2);

apply_as_current(
&mut state,
GameAction::ActivateAbility {
source_id: jhoira,
ability_index: 0,
},
)
.expect("Jhoira's exile-cost ability must enter the activation pipeline");
apply_as_current(
&mut state,
GameAction::SelectCards {
cards: vec![nonland],
},
)
.expect("paying the exile-from-hand cost must succeed");

let mut events = Vec::new();
stack::resolve_top(&mut state, &mut events);

// Reach-guard: the chain really ran — CR 122.1, four time counters landed.
assert_eq!(
state.objects[&nonland]
.counters
.get(&CounterType::Time)
.copied(),
Some(4),
"the exiled card must still carry four time counters"
);
// CR 702.62a: "Suspend N—[cost]". The printed cost must survive untouched.
assert_eq!(
crate::game::keywords::effective_suspend_cost(&state, nonland),
Some(blue),
"the cost-paid anaphor must read the EXILED CARD: it already has suspend, \
so no redundant grant may clobber its printed Suspend 4—{{U}} to {{0}}"
);
}

/// CR 608.2h + CR 608.2k + CR 613.1f: the cost-paid referent must be read at
/// RESOLUTION, not from the payment-time snapshot.
///
/// The card enters the cost with NO suspend at all, so the payment-time
/// `LKISnapshot` honestly records "no suspend". Only after the cost is paid —
/// while the card is sitting in EXILE, and before the ability resolves — does a
/// Layer-6 continuous effect grant it `Suspend 4—{U}`. CR 608.2k keeps the
/// ability pointing at that object, and CR 608.2h says a reference to an object
/// still in the public zone it was expected to be in reads that object's CURRENT
/// information. So by the time the gate is evaluated the card DOES have suspend
/// and no grant may fire.
///
/// This is the case a snapshot read cannot get right in principle: the kind-level
/// keyword props exist to consult the off-zone keyword ledger, and an off-zone
/// grant is applied to the live object, never captured in an LKI snapshot.
///
/// Revert-fail: evaluate the filter through `matches_target_filter_on_lki_snapshot`
/// instead of `matches_target_filter_on_cost_paid_reference` and the gate reads
/// the payment-time keyword list (empty), fires the redundant grant, and
/// `upsert_keyword_contribution` replaces the granted `Suspend 4—{U}` contribution
/// with `Suspend { count: 0, cost: {} }` — so this reads `Some({0})`.
#[test]
fn jhoira_reads_a_suspend_granted_in_exile_after_the_cost_was_paid() {
use super::super::engine::apply_as_current;
use crate::types::ability::{ContinuousModification, Duration, TargetFilter};
use crate::types::counter::CounterType;
use crate::types::keywords::Keyword;
use crate::types::mana::ManaCostShard;

let blue = ManaCost::Cost {
shards: vec![ManaCostShard::Blue],
generic: 0,
};

let mut state = setup_game_at_main_phase();
let jhoira = create_object(
&mut state,
CardId(985),
PlayerId(0),
"Jhoira of the Ghitu".to_string(),
Zone::Battlefield,
);
// Deliberately NO printed suspend, on either list: the payment-time snapshot
// must honestly say "no suspend" so the grant below is the only source.
let nonland = create_object(
&mut state,
CardId(986),
PlayerId(0),
"Plain Sorcery".to_string(),
Zone::Hand,
);
{
let obj = state.objects.get_mut(&jhoira).unwrap();
obj.card_types.core_types.push(CoreType::Creature);
let parsed = crate::parser::oracle::parse_oracle_text(
"{2}, Exile a nonland card from your hand: Put four time \
counters on the exiled card. If it doesn't have suspend, it \
gains suspend.",
"Jhoira of the Ghitu",
&[],
&[String::from("Creature")],
&[],
);
Arc::make_mut(&mut obj.abilities).extend(parsed.abilities);
}
{
let nl = state.objects.get_mut(&nonland).unwrap();
nl.card_types.core_types.push(CoreType::Sorcery);
nl.base_card_types = nl.card_types.clone();
}
add_mana(&mut state, PlayerId(0), ManaType::Colorless, 2);

apply_as_current(
&mut state,
GameAction::ActivateAbility {
source_id: jhoira,
ability_index: 0,
},
)
.expect("Jhoira's exile-cost ability must enter the activation pipeline");
apply_as_current(
&mut state,
GameAction::SelectCards {
cards: vec![nonland],
},
)
.expect("paying the exile-from-hand cost must succeed");

// Reach-guard: the cost really moved the card to exile, so the grant below is
// an EXILE-zone characteristic change and the snapshot is already stale.
assert_eq!(
state.objects[&nonland].zone,
Zone::Exile,
"reach-guard: paying the cost must have exiled the card"
);
assert!(
!crate::game::keywords::object_has_effective_keyword_kind(
&state,
nonland,
crate::types::keywords::KeywordKind::Suspend,
),
"reach-guard: the card must have no suspend at payment time, or the \
grant below proves nothing"
);

// The characteristic change, AFTER payment and BEFORE resolution.
state.add_transient_continuous_effect(
jhoira,
PlayerId(0),
Duration::Permanent,
TargetFilter::SpecificObject { id: nonland },
vec![ContinuousModification::AddKeyword {
keyword: Keyword::Suspend {
count: 4,
cost: blue.clone(),
},
}],
None,
);
assert!(
crate::game::keywords::object_has_effective_keyword_kind(
&state,
nonland,
crate::types::keywords::KeywordKind::Suspend,
),
"reach-guard: the off-zone grant must be visible before resolution, or \
the gate below is not being asked the question this test intends"
);

let mut events = Vec::new();
stack::resolve_top(&mut state, &mut events);

// Reach-guard: the chain really ran — CR 122.1, four time counters landed.
assert_eq!(
state.objects[&nonland]
.counters
.get(&CounterType::Time)
.copied(),
Some(4),
"the exiled card must carry four time counters"
);
assert_eq!(
crate::game::keywords::effective_suspend_cost(&state, nonland),
Some(blue),
"CR 608.2h: the gate must read the card's state AT RESOLUTION, where the \
exile-zone grant already gave it Suspend 4—{{U}} — no redundant grant may \
clobber that to {{0}}"
);
}

/// The Wedding of River Song (WHO) — runtime regression for the core chain.
/// Drives the real cast pipeline (CastSpell → resolution) and asserts:
/// (a) the controller draws two cards; (b) the controller's nonland card is
/// exiled with time counters = its mana value (CR 122.1).
///
/// The "Cards exiled this way that don't have suspend gain suspend" clause
/// (Defect C) is a *documented strict-failure* — the "that don't have <kw>"
/// restrictive clause strict-fails to `Unimplemented` because the correct
/// per-card object-scoped condition (applying `SourceLacksKeyword` per exiled
/// card, not per spell source) does not yet exist in the engine. The exiled
/// restrictive clause strict-fails to `Unimplemented` because it needs a
/// PER-MEMBER predicate over the exiled tracked set. The singular anaphor's two
/// lowerings (`TargetMatchesFilter` / `CostPaidObjectMatchesFilter`) each test
/// exactly one subject, and `ZoneChangedThisWay` is a set existential, so none
/// of them expresses "exclude each member that already has suspend". The exiled
/// card therefore does NOT gain suspend at runtime — this is expected, not a
/// regression. See `try_parse_exiled_this_way_keyword_grant` for details.
///
Expand Down
7 changes: 6 additions & 1 deletion crates/engine/src/game/effects/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -13175,9 +13175,14 @@ pub(crate) fn evaluate_condition(
.iter()
.any(|&id| crate::game::filter::matches_target_filter(state, id, filter, &ctx))
}
// CR 608.2k + CR 608.2h: the cost-paid object is a persistent untargeted
// reference, so it reads CURRENT information while it is still in a
// public zone — not the payment-time snapshot. See
// `matches_target_filter_on_cost_paid_reference` for why only the
// keyword set is refreshed.
AbilityCondition::CostPaidObjectMatchesFilter { filter } => {
if let Some(snapshot) = &ability.cost_paid_object {
crate::game::filter::matches_target_filter_on_lki_snapshot(
crate::game::filter::matches_target_filter_on_cost_paid_reference(
state,
snapshot.object_id,
&snapshot.lki,
Expand Down
67 changes: 67 additions & 0 deletions crates/engine/src/game/filter.rs
Original file line number Diff line number Diff line change
Expand Up @@ -2239,6 +2239,16 @@ pub fn context_free_prop_matches_face(face: &CardFace, prop: &FilterProp) -> Opt
FilterProp::WithKeyword { value } => Some(face.keywords.contains(value)),
// allow-raw-authority: bare CardFace has no object, so no keyword grant can exist to miss
FilterProp::WithoutKeyword { value } => Some(!face.keywords.contains(value)),
// The kind-level siblings (`HasKeywordKind` / `WithoutKeywordKind`) are
// intentionally ABSENT and fall to the `None` arm below. They exist to
// consult off-zone Layer-6 grants, which a bare face by definition cannot
// have, so a face reading would answer a strictly narrower question than
// the prop asks. Every production caller of this function evaluates an
// effect target filter or a static's `spell_filter` — never an
// `AbilityCondition` filter, which is the only place those props appear
// today — so the `None` default is unreachable rather than lossy. A future
// caller that needs them must add explicit arms here instead of relying on
// the fail-closed default.
// CR 111.1 + CR 108.2: a bare face is a card definition, never a token.
FilterProp::Token => Some(false),
FilterProp::NonToken | FilterProp::RepresentedByCard => Some(true),
Expand Down Expand Up @@ -2759,6 +2769,63 @@ pub fn matches_target_filter_on_lki_snapshot(
matches_target_filter_on_zone_change_record(state, &record, filter, ctx)
}

/// CR 608.2k + CR 608.2h: Evaluate a target filter against an ability's
/// PERSISTENT untargeted reference — today, its cost-paid object.
///
/// This is NOT the same question as [`matches_target_filter_on_lki_snapshot`].
/// A zone-change subject is gone, so its snapshot IS the answer. A cost-paid
/// referent is a live reference the ability keeps pointing at (CR 608.2k), and
/// CR 608.2h says such a reference reads the object's CURRENT information while
/// it is in the public zone it was expected to be in — only a departed or
/// hidden-zone object falls back to last known information.
///
/// The refresh is deliberately scoped to `keywords`. That is the one field the
/// payment-time snapshot cannot answer honestly: the kind-level keyword props
/// exist to consult Layer-6 grants recorded in the off-zone ledger
/// (CR 613.1f), which by construction are applied to the LIVE object and are
/// absent from any snapshot. A card discarded to Jhoira of the Ghitu's cost and
/// then granted (or stripped of) suspend in the graveyard or in exile before the
/// ability resolves must be read as it is at resolution, or the gate answers a
/// question about a game state that no longer exists. Every other LKI field stays
/// on the snapshot: type, name, P/T, colors and controller are look-back facts
/// about the payment itself. (A filter that pairs a kind-level prop with an
/// object-level `WithKeyword`/`WithoutKeyword` would see the refreshed list for
/// both, since they read the same field — no card does that today, and CR 608.2h
/// makes the live reading the correct one either way.)
///
/// Guarded by `TargetFilter::queries_keyword_kind` so the common cost-paid filter
/// — a plain type/name look-back with no keyword question — costs one recursive
/// predicate walk and skips both the ledger recomputation
/// (`effective_off_zone_keywords` collects every applicable continuous effect)
/// and the snapshot clone.
pub fn matches_target_filter_on_cost_paid_reference(
state: &GameState,
object_id: ObjectId,
lki: &LKISnapshot,
filter: &TargetFilter,
ctx: &FilterContext<'_>,
) -> bool {
let refreshed = filter
.queries_keyword_kind()
.then(|| state.objects.get(&object_id))
.flatten()
.filter(|object| object.zone.is_public())
.map(|_| {
crate::game::off_zone_characteristics::effective_off_zone_keywords(state, object_id)
});

match refreshed {
Some(keywords) => {
let mut lki = lki.clone();
lki.keywords = keywords;
matches_target_filter_on_lki_snapshot(state, object_id, &lki, filter, ctx)
}
// Gone, or moved to a hidden zone: CR 608.2h mandates last known
// information, which is exactly what the payment snapshot holds.
None => matches_target_filter_on_lki_snapshot(state, object_id, lki, filter, ctx),
}
}

/// CR 400.7 + CR 603.10a: Match an event subject from its captured facts,
/// never by re-reading the live object at the same storage id. Connive uses
/// this for its exact completion snapshot after a replacement-ordering pause.
Expand Down
5 changes: 1 addition & 4 deletions crates/engine/src/game/meld.rs
Original file line number Diff line number Diff line change
Expand Up @@ -208,10 +208,7 @@ pub fn is_canonical_physical_meld_pair(state: &GameState, context: &MeldSelectio
/// CR 400.2: graveyard, battlefield, stack, exile, and command are public
/// zones. Hand and library remain hidden even when their cards are revealed.
fn is_public_zone(zone: Zone) -> bool {
match zone {
Zone::Battlefield | Zone::Graveyard | Zone::Stack | Zone::Exile | Zone::Command => true,
Zone::Library | Zone::Hand => false,
}
zone.is_public()
}

/// Commit the projected result entry, then atomically make the second card a
Expand Down
Loading
Loading