diff --git a/crates/engine/src/ai_support/mod.rs b/crates/engine/src/ai_support/mod.rs
index a3da8f14c1..9172d2d0a8 100644
--- a/crates/engine/src/ai_support/mod.rs
+++ b/crates/engine/src/ai_support/mod.rs
@@ -71,7 +71,11 @@ pub use swarm::{
};
#[cfg(feature = "test-support")]
pub use swarm::{adversarial_swarm_witness_with_counters, SwarmWitnessCounters};
-pub use targeted_exchange::{targeted_exchange_verdict, TargetedExchangeVerdict};
+pub use targeted_exchange::{
+ root_may_yield_adverse_exchange, targeted_exchange_verdict, TargetedExchangeVerdict,
+};
+#[cfg(feature = "test-support")]
+pub use targeted_exchange::{targeted_exchange_verdict_with_budget, TargetedExchangeBudget};
/// Filter `candidate_actions` down to the actions that are actually legal now.
///
diff --git a/crates/engine/src/ai_support/targeted_exchange.rs b/crates/engine/src/ai_support/targeted_exchange.rs
index 110e91569c..5eb4e04e57 100644
--- a/crates/engine/src/ai_support/targeted_exchange.rs
+++ b/crates/engine/src/ai_support/targeted_exchange.rs
@@ -11,12 +11,14 @@ use crate::game::engine::apply_interaction_for_simulation;
use crate::game::layers::flush_layers;
use crate::game::sba::check_state_based_actions;
use crate::types::ability::{DamageSource, Effect, ResolvedAbility, TargetFilter, TargetRef};
+use crate::types::ability_visit::visit_ability_def;
use crate::types::actions::GameAction;
use crate::types::card_type::CoreType;
use crate::types::game_state::{GameState, PendingCast, StackEntryKind, WaitingFor};
use crate::types::identifiers::{ObjectId, ObjectIncarnationRef};
use crate::types::player::PlayerId;
use crate::types::zones::Zone;
+use std::ops::ControlFlow;
/// Root-cast tactical result. `Indeterminate` deliberately leaves the root
/// candidate available; the preview is a safety veto, not a second rules engine.
@@ -58,9 +60,36 @@ impl RootBinding {
}
}
+ /// The object whose ability trees the announced spell or activated ability
+ /// will bind. CR 601.2a: an announced spell "has all the characteristics of
+ /// the card"; CR 602.2b: an activated ability's announcement is identical, so
+ /// the same object supplies both.
+ const fn source_object_id(self) -> ObjectId {
+ match self {
+ Self::Cast { object_id } => object_id,
+ Self::Activation { source_id, .. } => source_id,
+ }
+ }
+
fn matches_pending(self, pending: &PendingCast) -> bool {
match self {
- Self::Cast { object_id } => pending.object_id == object_id,
+ // CR 601.2a vs CR 602.2b: a cast root must not authenticate against an
+ // ACTIVATION-sourced pending for the same object. `PendingCast` is built
+ // for activations too (the `PendingCast::new` sites in `casting`,
+ // `engine_modes`, and `planeswalker`), and the `Activation` arm already checks
+ // both fields; this arm checked only the object, so a cast root could be
+ // judged against an activated ability's spine — a different tree, and one
+ // that may carry runtime-synthesized content clause (b2)(i) rails the
+ // guard against. This suppresses no legitimate `Reject`: any that fired
+ // through the untightened arm was computed from an activated ability's
+ // spine under a cast binding — the wrong tree by construction. Nor is
+ // the effect merely fail-open: on a non-match `bound_root_ability` falls
+ // through to the stack scan below it and can still bind the ANNOUNCED
+ // spell entry (`StackEntryKind::Spell { ability: Some(..) }`), which is
+ // the correct authority for a cast root.
+ Self::Cast { object_id } => {
+ pending.object_id == object_id && pending.activation_ability_index.is_none()
+ }
Self::Activation {
source_id,
ability_index,
@@ -72,6 +101,323 @@ impl RootBinding {
}
}
+/// Clone-free precondition for [`targeted_exchange_verdict`]. `false` PROVES the
+/// verdict cannot be [`TargetedExchangeVerdict::Reject`], so the caller can skip
+/// both the candidate enumeration and the bounded reducer replay.
+///
+/// `Reject` is returned from exactly two sites — `preview_target_sourced_self_damage`
+/// and `preview_fight_exchange` — and both are reached only through
+/// `preview_bound_exchange`, which first requires `is_target_sourced_self_damage`
+/// or `find_fight_leaf` to match the BOUND ability. A root whose source carries
+/// neither shape anywhere in the ability lists below therefore cannot be rejected
+/// — SO LONG AS the bound ability is composed only of those stored lists. It is
+/// not, in general: the binder composes STORED ⊕ SYNTHESIZED, and clause (b2)
+/// below enumerates the three seams that build a definition instead of storing
+/// one, with the disposition of each. That clause is part of this contract, not a
+/// caveat to it; read it before treating a `false` here as a proof.
+///
+/// Completeness comes from [`crate::types::ability_visit::visit_ability_def`],
+/// the engine's single wildcard-free `AbilityDefinition`/`Effect` traversal: it
+/// reaches every nested carrier, including `Effect::ChooseOneOf` branches and
+/// `AbilityCost::EffectCost` under `cost`/`unless_pay`. This is deliberately an
+/// OVER-approximation of the two bound tests, which walk only the `.effect` +
+/// `.sub_ability` spine. Looser is the safety property; tighter would silently
+/// shrink the rejection set.
+///
+/// FALSIFIER (a SEAM CLASS, not one function): a new site that writes
+/// `obj.abilities` or `obj.base_abilities` on LIVE state — by assignment OR via
+/// `Arc::make_mut(&mut obj.abilities).push/extend(..)`, which is the idiom the
+/// layer system itself uses (see the note at `game/layers.rs:2028`) — where the
+/// installed content is NOT already inside the four fields entered below, is NOT
+/// a shrink (`clear()` / empty `Vec`), is NOT a write to a local rather than a
+/// `GameObject`, is NOT a layer write railed by the `layers_dirty` gate above,
+/// and does NOT target a freshly created object (token/emblem). Also falsified by a
+/// new production site that constructs one of the two adverse shapes outside
+/// Oracle lowering. **AND** falsified by (c) below. **AND** falsified by (b2).
+///
+/// (b2) THE BASIS MISMATCH — this is the load-bearing correction to the sentence
+/// above, and it applies to BOTH branches, not just activations. The guard's basis
+/// is the object's STORED ability lists; the binder composes STORED ⊕ SYNTHESIZED.
+/// The falsifier paragraph audits INSTALLATION, so a definition that is built
+/// rather than stored reaches the bind unseen by it AND by its re-audit grep,
+/// which matches only assignment and `&mut` borrow of an ability field. Three
+/// confirmed seams, each disposed differently and deliberately:
+///
+/// (i) SYNTHESIZED ON READ, DATA-DRIVEN (Activation) —
+/// `casting::activated_ability_definitions` (casting.rs:458) hands the AI indices
+/// `printed_len + offset` whose definitions are rebuilt per call from
+/// `effective_off_zone_keywords` + `database::synthesis` and stored in no field.
+/// RAILED at runtime by the `RootBinding::Activation` index gate below. A rail is
+/// required here rather than a note, because the payload is card-data-driven
+/// across `database/synthesis.rs` (~26k lines): it cannot be discharged by reading
+/// one function, so the gate must hold regardless of what those families come to
+/// synthesize.
+///
+/// (ii) SYNTHESIZED FROM A SCALAR, FIXED PAYLOAD (Cast) —
+/// the Awaken branch of `casting::prepare_spell_cast_with_variant_override_inner`
+/// appends `awaken::build_awaken_rider(count: u32)` to the bound spine. NOT railed,
+/// for two independent reasons, and BOTH are needed: (1) unreachable today —
+/// Awaken is elected by a separate `AlternativeCastDecision` GameAction
+/// (`handle_awaken_cost_choice_with_payment_mode`, casting.rs:8933), not inline
+/// during the root `CastSpell`, and `explore_target_children` follows only
+/// `GameAction::ChooseTarget { target: Some(_) }`; (2) the payload is CLOSED — a
+/// fixed `PutCounter` → `Animate` built by one 20-line function that can be read
+/// in full. Note what is deliberately NOT the argument: cost. A Cast-branch rail
+/// could discriminate on `Keyword::Awaken` rather than falling open on every
+/// cast, so it would be near-free, and "a rail is expensive" would be FALSE here.
+/// The reason is epistemic, not economic — reading `build_awaken_rider` in full
+/// discharges the question, which is precisely what cannot be done for (i).
+/// Discharged by a back-reference AT that function — the site where the payload
+/// would change — which is the direction (c) below records this comment as
+/// failing to provide.
+///
+/// READ (ii) AND (iii) TOGETHER: both go live under the SAME widening of
+/// `explore_target_children` past target selection, for DIFFERENT reasons — (ii)
+/// because its election is a separate GameAction, (iii) because its pending parks
+/// in `WaitingFor::SpliceOffer`. Anyone widening that exploration must rail BOTH.
+/// (ii) additionally needs a rail only if its payload stops being fixed; (iii)
+/// needs one unconditionally, because its payload is arbitrary card text.
+///
+/// (iii) MERGED FROM ANOTHER OBJECT (Cast) — `splice::append_to_sub_chain`
+/// (splice.rs:145) mutates the bound `PendingCast.ability` with
+/// `combined_spell_ability_def` read off a DIFFERENT object (the splice card in
+/// hand), whose payload is arbitrary card text and so COULD carry an adverse
+/// shape. Unreachable today: `target_selection_owner` returns `None` for
+/// `WaitingFor::SpliceOffer` and `explore_target_children` follows only
+/// `GameAction::ChooseTarget`. This one becomes live — and needs a rail, not a
+/// note — the moment that exploration widens past target selection.
+///
+/// The fourth append site in that same function is NOT a seam: its Fuse branch
+/// merges `obj.back_face.abilities`, which the `back_face`
+/// arm below already chains, so it composes STORED content and is covered. It is
+/// listed here because an audit of the append sites will find it and needs the
+/// verdict, not because it falsifies anything.
+///
+/// So the falsifier is: a new site that makes a definition REACHABLE TO THE BIND
+/// without writing it to a `GameObject` field — synthesized, merged, or displaced
+/// (`GameObject::cleave_form`, a `CleaveFormState`, holds the displaced printed
+/// list; `GameObject::specialize_faces` installs faces through the same
+/// `printed_cards::apply_back_face_to_object` the `back_face` arm exists to cover,
+/// and `specialize::specialize_permanent` gates it on the battlefield so it
+/// resolves after this window).
+/// AUDIT INSTRUMENT — deliberately NOT "what do
+/// `combined_spell_ability_def` / `activation_ability_definition` RETURN". That
+/// question cannot find (ii): the `combined_spell_ability_def` call sits near the
+/// TOP of `prepare_spell_cast_with_variant_override_inner` and the
+/// `awaken::append_awaken_rider` call ~950 lines LATER in that same function,
+/// after that return has already happened. A return-site instrument
+/// is blind to every post-return append by construction — the same shape of
+/// error as the sticker grep dissected above, which is why it is called out here
+/// rather than left as a footnote.
+/// Ask instead: WHAT DOES THE ABILITY LOOK LIKE AT THE POINT `PendingCast` IS
+/// CONSTRUCTED? That is the def the judges actually walk, it is downstream of
+/// every append, and it is position-independent. Concretely: read
+/// `prepare_spell_cast_with_variant_override_inner` from its
+/// `combined_spell_ability_def` call to its `PreparedSpellCast` construction and
+/// account for every mutation of `ability_def` on the way. At the time of writing
+/// that is eight sites — the initial read, one rebind, the Overload transform
+/// (`overload::transform_effect_in_place` DROPS `damage_source` on its
+/// `DealDamage` -> `DamageAll` arm, so it can only remove the shape),
+/// the Awaken append (ii), and the four-line Fuse merge (stored, covered).
+/// PAYLOAD-DISMISSAL RULE — (i) and (ii) sit on OPPOSITE sides of this, so it is
+/// stated as a criterion rather than left for the reader to infer; without it the
+/// two clauses read as contradictory and a maintainer will pick whichever suits.
+/// Dismissing a seam on the grounds that its payload carries no adverse shape is
+/// ADMISSIBLE only when that payload is CLOSED — bounded by reading one
+/// non-data-driven function in full, as in (ii) (`build_awaken_rider`, 20 lines,
+/// fixed shape). It is NOT admissible over a data-driven surface: "no synthesized
+/// ability carries Fight today" across `database/synthesis.rs` (~26k lines) is a
+/// grep-level negative, not a closure over helper composition — which is exactly
+/// why (i) gets a runtime rail and (ii) does not. This guard's contract is that
+/// `false` PROVES non-rejection, and a grep is not a proof.
+///
+/// Note what is deliberately NOT on that list: "an installer the cast path
+/// provably never calls." That clause was carried for three rounds with an
+/// instrument — a grep for LEAF installer names over three files — that could
+/// not have detected its own falsity: `stickers.rs:490` is reached through a
+/// WRAPPER (`zones.rs:511 rebuild_public_zone_stickers`) named in a file the
+/// grep already covered, so the grep returned zero while the route existed.
+/// (The route is in fact dead on a cast — `zones.rs:511` is inside
+/// `if from == Zone::Battlefield {` at `zones.rs:482` — but an instrument that
+/// is right by luck is not an instrument.) Dispose of a new site by reading ONE
+/// function: what does it write, and where does the content come from. If a
+/// negative reach claim is genuinely needed, discharge it with a BOUNDED
+/// TRANSITIVE CALLER CLOSURE — enumerate every caller of the installer by exact
+/// name, then every caller of each wrapper, until each terminating site's gate
+/// has been read in source, quoting the ENCLOSING CONDITIONAL of every line.
+///
+/// (c) CR 123.5 DEPENDENCY — recorded, not hypothetical. This note is
+/// ONE-DIRECTIONAL: it is discoverable from the guard's side only, and
+/// nothing in `game/zones.rs`, where such a fix would be made, points back
+/// to it. CR 123.5
+/// says stickers "are retained as that object moves to a public zone and
+/// continue to apply to the new object it becomes in that zone." The engine
+/// implements only half of that: `zones.rs:468-471` clears on a move to a
+/// hidden zone, and `zones.rs:508-513` re-applies ONLY on a battlefield exit
+/// (`from == Zone::Battlefield`, `zones.rs:482`). A card carrying a sticker in
+/// hand or library therefore keeps `obj.stickers` while `obj.abilities` never
+/// reflects it, so this predicate and the bind (`casting::combined_spell_ability_def`)
+/// read the same list and agree. IF THAT GAP IS EVER CLOSED — by un-gating
+/// `zones.rs:508-513`, by widening `zones.rs:482`, or by adding an entry-side
+/// install (`zone_pipeline.rs` today contains zero occurrences of "sticker") —
+/// then an ability sticker's granted abilities are installed into `abilities`
+/// during the cast's own move to the stack, i.e. AFTER this predicate has read
+/// the object, from a payload (`obj.stickers`, Oracle TEXT) that no traversal
+/// arm can reach without re-running the parser. This predicate is then UNSOUND
+/// and needs a fall-open rail immediately below the `layers_dirty` one:
+/// if crate::game::stickers::object_has_sticker_kind(
+/// source, crate::types::stickers::StickerKind::Ability) { return true; }
+/// `object_has_sticker_kind` (`game/stickers.rs:101`, unconditionally `pub`) is
+/// `obj.stickers.iter().any(|s| s.kind() == kind)` — no allocation, no clone.
+/// `StickerKind::Ability` is the correct discriminant: `stickers.rs:483` is the
+/// sole gate on the path to the `abilities` write at `:490`.
+///
+/// Deliberately stated WITHOUT a reachability qualifier: deciding which of those
+/// dispositions applies needs one function read, whereas deciding "is this
+/// reachable from the cast reducer" needs a call-chain trace, and that trace was
+/// got wrong in four consecutive reviews of this guard.
+///
+/// To re-audit, sweep the CHANGE, not the tree — the tree census is 516 hits:
+/// git diff ..HEAD -U0 -- crates/engine/src \
+/// | rg '^\+.*(\.(base_)?abilities\s*=([^=]|$)|&mut\s+[A-Za-z_0-9.:&()\[\] ]*\.(base_)?abilities\b)'
+/// The second alternation deliberately matches ANY `&mut .abilities`, not
+/// just `Arc::make_mut(&mut obj.abilities)`: the engine writes this field
+/// through a complex receiver 84 times
+/// (`Arc::make_mut(&mut state.objects.get_mut(&id).unwrap().abilities)`) and
+/// takes a `&mut` binding to it (`let a = &mut obj.abilities;`) once, and a
+/// `make_mut(&mut .abilities)` pattern sees neither. A `&mut` borrow of
+/// this field taken to a local is itself a site worth surfacing.
+/// A binding whose mutation happens on a LATER line is invisible to any
+/// single-line regex; the first line of the pair matches, so read the whole
+/// diff hunk when it fires.
+/// Do NOT audit `casting::prepare_casting_variant` alone: the live-face swap at
+/// `casting.rs:9860` (`handle_cast_spell_with_payment_mode:10902` → `:12013` →
+/// `continue_cast_from_prepared:9827` → the Disturb branch at `:9835` →
+/// `continue_cast_with_alternative_spell_face:9849`) never enters it, and the
+/// flip-revert install at `flip.rs:345`/`:346` is reached through the zone
+/// pipeline, not through `casting.rs` at all.
+///
+/// CR 601.2a + CR 602.2b: the bound ability is an ability of the root's source
+/// object, so that object's ability trees bound the reachable effect shapes.
+pub fn root_may_yield_adverse_exchange(state: &GameState, action: &GameAction) -> bool {
+ let Some(root) = RootBinding::from_action(action) else {
+ // Not a cast or an activation: `targeted_exchange_verdict` is
+ // Indeterminate by construction (see `RootBinding::from_action`).
+ return false;
+ };
+ let Some(source) = state.objects.get(&root.source_object_id()) else {
+ return true;
+ };
+ // CR 613.1f: layer 6 ability-adding and ability-removing effects rewrite
+ // `abilities`, and `layers::evaluate_layers` re-derives that list from
+ // `base_abilities` on each pass. A pending flush can therefore leave the
+ // live list out of step with what the reducer will bind, which this
+ // predicate must never guess at. CR 704.3's state-based-action loop
+ // (`game::sba::check_state_based_actions`) flushes before every priority
+ // window, which is the only place this gate runs.
+ if state.layers_dirty.is_dirty() {
+ return true;
+ }
+ // CR 602.2b: an activated ability's announcement binds a definition chosen by
+ // `ability_index`, and `casting::activation_ability_definition` (casting.rs:497)
+ // resolves an index at or past `obj.abilities.len()` from four families that are
+ // SYNTHESIZED ON READ and written into no field entered below:
+ // `runtime_granted_cycling_abilities` (CR 702.29a),
+ // `runtime_granted_graveyard_activated_abilities`,
+ // `runtime_granted_top_of_library_plot_abilities` (CR 702.170f), and
+ // `runtime_granted_equip_abilities` (CR 702.6). The index is the ONLY thing that
+ // distinguishes them, so answering from the lists below would be answering about
+ // a definition they provably cannot contain. Fall open instead.
+ //
+ // This rail is load-bearing by CONSTRUCTION, not by payload: it holds no matter
+ // what those four families come to synthesize. Today none of them carries an
+ // adverse shape (cycling draws, embalm/eternalize/encore copy, plot exiles, equip
+ // attaches), so deleting this rail keeps every current fixture green — which is
+ // exactly why `activation_beyond_printed_abilities_falls_open` pins the index
+ // boundary directly rather than pinning a card.
+ if let RootBinding::Activation { ability_index, .. } = root {
+ if ability_index >= source.abilities.len() {
+ return true;
+ }
+ }
+ // CR 613.1: the union of the printed and post-layer lists is a superset of
+ // either, so a live removal cannot hide a shape the printed list carries.
+ source
+ .base_abilities
+ .iter()
+ .chain(source.abilities.iter())
+ // CR 712.11b / CR 715.3a / CR 720.3a: a cast-time face election replaces
+ // `abilities` AND `base_abilities` with the alternative face's list
+ // (`casting::swap_to_alternative_spell_face` ->
+ // `printed_cards::apply_back_face_to_object`) while the reducer is still
+ // applying the root `CastSpell` — `casting.rs:11028-11039` elects a
+ // single surviving variant inline, with no second `GameAction`. The
+ // pre-swap source list is therefore also reachable input to the bind.
+ .chain(
+ source
+ .back_face
+ .iter()
+ .flat_map(|back| back.abilities.iter()),
+ )
+ // CR 702.148b + CR 612: cleave's second ability is a text-changing
+ // effect; `casting::apply_cleave_text_change` replaces both `abilities`
+ // and `base_abilities` with the bracket-removed variant on the same
+ // inline-election path, so that list is reachable input too.
+ .chain(
+ source
+ .cleave_variant
+ .iter()
+ .flat_map(|variant| variant.abilities.iter()),
+ )
+ .any(|def| {
+ visit_ability_def(def, &mut |effect| {
+ if effect_may_yield_adverse_exchange(effect) {
+ ControlFlow::Break(())
+ } else {
+ ControlFlow::Continue(())
+ }
+ })
+ .is_break()
+ })
+}
+
+/// The leaf shape test. The `_ => false` arm is correct BECAUSE this predicate is
+/// an over-approximation whose default answer is "carries no adverse-exchange
+/// shape" — it is not a missed-arm hazard on `Effect`. The wildcard-free part of
+/// the guard is the traversal above it, not this leaf. A NEW `Effect` variant
+/// cannot create a new `Reject`, because both judges hard-match named variants
+/// (`find_fight_leaf` → `Effect::Fight`; `is_target_sourced_self_damage` →
+/// `Effect::DealDamage`), so the reject set is closed under enum growth.
+///
+/// THE COUPLING THAT IS REAL RUNS THE OTHER WAY. This arm set must remain a
+/// SUPERSET of every shape those two judges can reject on. Widening either judge
+/// without widening this leaf silently narrows the guard below the reject set —
+/// no compile error, no failing test, and nothing in the FALSIFIER above fires,
+/// because that list audits ability INSTALLATION, not judge shape. The same
+/// invariant is restated at both judges; all three must move together.
+///
+/// CR 701.14a: a Fight instruction makes two creatures deal damage to each other.
+/// CR 120.1: `damage_source: Some(DamageSource::Target)` attributes the damage to
+/// the ability's first object target rather than to its source, which is the
+/// wording class `is_target_sourced_self_damage` gates. `DamageAll` shares that
+/// one axis with `DealDamage` (see
+/// `ability_utils::one_sided_fight_source_supplies_quantity_creature`), so it is
+/// admitted here to keep the guard no tighter than the class.
+fn effect_may_yield_adverse_exchange(effect: &Effect) -> bool {
+ matches!(
+ effect,
+ Effect::Fight { .. }
+ | Effect::DealDamage {
+ damage_source: Some(DamageSource::Target),
+ ..
+ }
+ | Effect::DamageAll {
+ damage_source: Some(DamageSource::Target),
+ ..
+ }
+ )
+}
+
/// Preview whether every complete, supported target declaration for `root` is
/// the strictly bad exchange where the selected friendly creature dies and its
/// exact recipient survives.
@@ -84,30 +430,65 @@ pub fn targeted_exchange_verdict(
state: &GameState,
root: &CandidateAction,
) -> TargetedExchangeVerdict {
+ targeted_exchange_verdict_inner(state, root).0
+}
+
+/// Test-support view of the same computation: the bounded-witness budget records
+/// exactly how much replay work the verdict cost. Mirrors
+/// [`crate::ai_support::adversarial_swarm_witness_with_counters`].
+#[cfg(feature = "test-support")]
+pub fn targeted_exchange_verdict_with_budget(
+ state: &GameState,
+ root: &CandidateAction,
+) -> (TargetedExchangeVerdict, TargetedExchangeBudget) {
+ targeted_exchange_verdict_inner(state, root)
+}
+
+fn targeted_exchange_verdict_inner(
+ state: &GameState,
+ root: &CandidateAction,
+) -> (TargetedExchangeVerdict, TargetedExchangeBudget) {
+ let mut budget = TargetedExchangeBudget::default();
let Some(root_binding) = RootBinding::from_action(&root.action) else {
- return TargetedExchangeVerdict::Indeterminate;
+ return (TargetedExchangeVerdict::Indeterminate, budget);
};
let Some(semantic_owner) = root.metadata.semantic_owner else {
- return TargetedExchangeVerdict::Indeterminate;
+ return (TargetedExchangeVerdict::Indeterminate, budget);
};
- let Some(mut next) = replay_exact_candidate(state, root) else {
- return TargetedExchangeVerdict::Indeterminate;
+ // Clone-free precondition: a root that cannot be rejected must not pay the
+ // candidate enumeration or the reducer replay below.
+ if !root_may_yield_adverse_exchange(state, &root.action) {
+ return (TargetedExchangeVerdict::Indeterminate, budget);
+ }
+ let Some(mut next) = replay_exact_candidate(state, root, &mut budget) else {
+ return (TargetedExchangeVerdict::Indeterminate, budget);
};
- let mut budget = WitnessBudget::default();
- inspect_successor(&mut next, root_binding, semantic_owner, &mut budget)
+ let verdict = inspect_successor(&mut next, root_binding, semantic_owner, &mut budget);
+ (verdict, budget)
}
-#[derive(Default)]
-struct WitnessBudget {
- nodes: usize,
- branches: usize,
+/// Bounded-witness budget for one `targeted_exchange_verdict` call: the caps the
+/// preview enforces, plus what the preview actually spent. The spend fields are
+/// maintained unconditionally (three `usize` increments against a `GameState`
+/// clone is not measurable) and are read only through
+/// `targeted_exchange_verdict_with_budget`.
+#[derive(Debug, Default, Clone, Copy, PartialEq, Eq)]
+pub struct TargetedExchangeBudget {
+ pub nodes: usize,
+ pub branches: usize,
+ /// `replay_exact_candidate` clone-and-applies performed (root + target children).
+ pub replay_clone_applies: usize,
+ /// `preview_*` state clones taken to resolve a bound exchange.
+ pub preview_clone_resolves: usize,
+ /// Full `validated_candidate_actions_for_semantic_owner` passes this call ran.
+ pub candidate_enumerations: usize,
}
fn inspect_successor(
state: &mut GameState,
root: RootBinding,
semantic_owner: PlayerId,
- budget: &mut WitnessBudget,
+ budget: &mut TargetedExchangeBudget,
) -> TargetedExchangeVerdict {
if budget.nodes >= MAX_WITNESS_NODES {
return TargetedExchangeVerdict::Indeterminate;
@@ -119,7 +500,7 @@ fn inspect_successor(
// either the matching PendingCast (manual payment) or the exact announced
// Spell stack entry (automatic payment), before prompt classification.
if let Some(ability) = bound_root_ability(state, root) {
- if let Some(verdict) = preview_bound_exchange(state, ability, semantic_owner) {
+ if let Some(verdict) = preview_bound_exchange(state, ability, semantic_owner, budget) {
return verdict;
}
}
@@ -176,12 +557,13 @@ fn explore_target_children(
state: &GameState,
root: RootBinding,
semantic_owner: PlayerId,
- budget: &mut WitnessBudget,
+ budget: &mut TargetedExchangeBudget,
) -> TargetedExchangeVerdict {
let owner = target_selection_owner(&state.waiting_for);
let Some(owner) = owner else {
return TargetedExchangeVerdict::Indeterminate;
};
+ budget.candidate_enumerations += 1;
let candidates = validated_candidate_actions_for_semantic_owner(state, owner);
if candidates
.iter()
@@ -209,7 +591,7 @@ fn explore_target_children(
return TargetedExchangeVerdict::Indeterminate;
}
budget.branches += 1;
- let Some(mut next) = replay_exact_candidate(state, &child) else {
+ let Some(mut next) = replay_exact_candidate(state, &child, budget) else {
return TargetedExchangeVerdict::Indeterminate;
};
match inspect_successor(&mut next, root, semantic_owner, budget) {
@@ -235,9 +617,14 @@ fn target_selection_owner(waiting_for: &WaitingFor) -> Option {
}
}
-fn replay_exact_candidate(state: &GameState, wanted: &CandidateAction) -> Option {
+fn replay_exact_candidate(
+ state: &GameState,
+ wanted: &CandidateAction,
+ budget: &mut TargetedExchangeBudget,
+) -> Option {
let semantic_owner = wanted.metadata.semantic_owner?;
let actor = wanted.metadata.actor?;
+ budget.candidate_enumerations += 1;
let current = validated_candidate_actions_for_semantic_owner(state, semantic_owner);
current
.iter()
@@ -248,6 +635,7 @@ fn replay_exact_candidate(state: &GameState, wanted: &CandidateAction) -> Option
&& candidate.metadata.tactical_class == wanted.metadata.tactical_class
})
.then(|| {
+ budget.replay_clone_applies += 1;
let mut next = state.clone();
apply_interaction_for_simulation(
&mut next,
@@ -264,20 +652,23 @@ fn preview_bound_exchange(
state: &GameState,
ability: &ResolvedAbility,
semantic_owner: PlayerId,
+ budget: &mut TargetedExchangeBudget,
) -> Option {
if is_target_sourced_self_damage(ability) {
- return preview_target_sourced_self_damage(state, ability, semantic_owner);
+ return preview_target_sourced_self_damage(state, ability, semantic_owner, budget);
}
let fight = find_fight_leaf(ability)?;
- preview_fight_exchange(state, ability, fight, semantic_owner)
+ preview_fight_exchange(state, ability, fight, semantic_owner, budget)
}
fn preview_target_sourced_self_damage(
state: &GameState,
ability: &ResolvedAbility,
semantic_owner: PlayerId,
+ budget: &mut TargetedExchangeBudget,
) -> Option {
let (source, recipient) = exchange_participants(state, ability, semantic_owner)?;
+ budget.preview_clone_resolves += 1;
let mut preview = state.clone();
flush_layers(&mut preview);
let source_ref = ObjectIncarnationRef::from_object(preview.objects.get(&source)?);
@@ -300,6 +691,14 @@ fn preview_target_sourced_self_damage(
})
}
+/// INVARIANT (shared with `is_target_sourced_self_damage` and
+/// `effect_may_yield_adverse_exchange`): widening the shapes this judge can
+/// reject on REQUIRES widening `effect_may_yield_adverse_exchange` in the same
+/// change. That leaf is the clone-free precondition
+/// `root_may_yield_adverse_exchange` answers from, and it must stay a superset of
+/// this judge; if it narrows below, `search::root_action_is_allowed` returns early
+/// and this judge never runs, silently dropping the `Reject`. Nothing enforces
+/// this at compile time.
fn find_fight_leaf(ability: &ResolvedAbility) -> Option<&ResolvedAbility> {
if matches!(&ability.effect, Effect::Fight { .. }) {
return Some(ability);
@@ -312,6 +711,7 @@ fn preview_fight_exchange(
ability: &ResolvedAbility,
fight: &ResolvedAbility,
semantic_owner: PlayerId,
+ budget: &mut TargetedExchangeBudget,
) -> Option {
let (first, second) =
crate::game::effects::fight::resolve_fight_fighters(state, fight).ok()??;
@@ -332,6 +732,7 @@ fn preview_fight_exchange(
return None;
}
+ budget.preview_clone_resolves += 1;
let mut preview = state.clone();
flush_layers(&mut preview);
let ai_ref = ObjectIncarnationRef::from_object(preview.objects.get(&ai_fighter)?);
@@ -387,6 +788,13 @@ impl ExchangeRecipient {
}
}
+/// INVARIANT (shared with `find_fight_leaf` and
+/// `effect_may_yield_adverse_exchange`): widening the shapes this judge can reject
+/// on REQUIRES widening `effect_may_yield_adverse_exchange` in the same change.
+/// That leaf is the clone-free precondition `root_may_yield_adverse_exchange`
+/// answers from, and it must stay a superset of this judge; if it narrows below,
+/// `search::root_action_is_allowed` returns early and this judge never runs,
+/// silently dropping the `Reject`. Nothing enforces this at compile time.
fn is_target_sourced_self_damage(ability: &ResolvedAbility) -> bool {
let ability = match &ability.effect {
// CR 601.2c: target-subject wording declares its damage-source target
@@ -499,9 +907,22 @@ mod tests {
use super::*;
use crate::game::zones::create_object;
use crate::parser::oracle::parse_oracle_text;
+ use crate::types::ability::{
+ AbilityCost, AbilityDefinition, AbilityKind, ContinuousModification, ControllerRef,
+ CopiableValues, CounterSourceRider, DelayedTriggerCondition, DieResultBranch, Duration,
+ PileSource, PlayerFilter, PlayerScope, PtValue, QuantityExpr, ReplacementDefinition,
+ ReplacementMode, StaticDefinition, TriggerDefinition, TypeFilter, TypedFilter,
+ UnlessPayModifier, VoteSubject, VoteTally, VoteVisibility, VoterScope,
+ };
+ use crate::types::card::CleaveVariant;
use crate::types::card_type::CoreType;
+ use crate::types::counter::CounterType;
+ use crate::types::game_state::CastPaymentMode;
use crate::types::identifiers::CardId;
use crate::types::phase::Phase;
+ use crate::types::replacements::ReplacementEvent;
+ use crate::types::statics::StaticMode;
+ use crate::types::triggers::TriggerMode;
use std::sync::Arc;
fn add_creature(state: &mut GameState, owner: PlayerId) -> ObjectId {
@@ -610,4 +1031,1016 @@ mod tests {
"semantic-owner-aware root replay must not veto a favorable opposing-source branch"
);
}
+
+ // ---------------------------------------------------------------------
+ // `root_may_yield_adverse_exchange` — the clone-free precondition.
+ //
+ // Every fixture below flushes the layer lattice and asserts it is `Clean`
+ // before reading the guard's answer. That reach guard is load-bearing in
+ // both directions: the guard's first rail is `layers_dirty.is_dirty() =>
+ // return true`, so a dirty lattice makes every `true` assertion vacuous and
+ // every `false` assertion fail for the wrong reason. `a_pending_layer_grant_
+ // falls_open` is the one test that deliberately sits on the other side of
+ // that rail.
+ // ---------------------------------------------------------------------
+
+ fn fight_effect() -> Effect {
+ Effect::Fight {
+ target: TargetFilter::Any,
+ subject: TargetFilter::SelfRef,
+ }
+ }
+
+ fn fight_def() -> AbilityDefinition {
+ AbilityDefinition::new(AbilityKind::Spell, fight_effect())
+ }
+
+ fn benign_def() -> AbilityDefinition {
+ AbilityDefinition::new(AbilityKind::Spell, Effect::Investigate)
+ }
+
+ fn fight_cost() -> AbilityCost {
+ AbilityCost::EffectCost {
+ effect: Box::new(fight_effect()),
+ }
+ }
+
+ fn grant_fight_static() -> StaticDefinition {
+ StaticDefinition::new(StaticMode::Continuous)
+ .affected(TargetFilter::Typed(TypedFilter::new(TypeFilter::Creature)))
+ .modifications(vec![ContinuousModification::GrantAbility {
+ definition: Box::new(fight_def()),
+ }])
+ }
+
+ /// A priority window with a sorcery in P0's hand carrying `abilities`, and a
+ /// `Clean` layer lattice.
+ fn guard_fixture(abilities: Vec) -> (GameState, ObjectId) {
+ let mut state = GameState::new_two_player(0);
+ state.phase = Phase::PreCombatMain;
+ state.active_player = PlayerId(0);
+ state.priority_player = PlayerId(0);
+ state.waiting_for = WaitingFor::Priority {
+ player: PlayerId(0),
+ };
+ let card_id = CardId(state.next_object_id);
+ let spell = create_object(
+ &mut state,
+ card_id,
+ PlayerId(0),
+ "Guard Test Spell".to_string(),
+ Zone::Hand,
+ );
+ let spell_object = state
+ .objects
+ .get_mut(&spell)
+ .expect("created spell must exist");
+ spell_object.card_types.core_types.push(CoreType::Sorcery);
+ *Arc::make_mut(&mut spell_object.abilities) = abilities;
+ flush_layers(&mut state);
+ (state, spell)
+ }
+
+ fn cast_action(state: &GameState, object_id: ObjectId) -> GameAction {
+ GameAction::CastSpell {
+ object_id,
+ card_id: state
+ .objects
+ .get(&object_id)
+ .expect("fixture object must exist")
+ .card_id,
+ targets: vec![],
+ payment_mode: CastPaymentMode::Auto,
+ }
+ }
+
+ /// Read the guard's answer for the root cast of `object_id`, after proving
+ /// the `layers_dirty` rail is not what answers.
+ fn guard_answer(state: &GameState, object_id: ObjectId) -> bool {
+ assert!(
+ !state.layers_dirty.is_dirty(),
+ "reach guard: a dirty lattice makes `root_may_yield_adverse_exchange` fall open before it reads any ability list"
+ );
+ root_may_yield_adverse_exchange(state, &cast_action(state, object_id))
+ }
+
+ fn guard_sees(def: AbilityDefinition) -> bool {
+ let (state, spell) = guard_fixture(vec![def]);
+ guard_answer(&state, spell)
+ }
+
+ fn carries_fight(object: &crate::game::game_object::GameObject) -> bool {
+ object
+ .base_abilities
+ .iter()
+ .chain(object.abilities.iter())
+ .any(|def| matches!(&*def.effect, Effect::Fight { .. }))
+ }
+
+ /// N1 — `damage_source: None` is ordinary spell-sourced damage and must not
+ /// admit; `is_target_sourced_self_damage` cannot match it.
+ #[test]
+ fn plain_spell_sourced_damage_is_not_an_adverse_exchange_shape() {
+ let def = AbilityDefinition::new(
+ AbilityKind::Spell,
+ Effect::DealDamage {
+ amount: QuantityExpr::Fixed { value: 3 },
+ target: TargetFilter::Any,
+ damage_source: None,
+ excess: None,
+ },
+ );
+ assert!(
+ !guard_sees(def),
+ "spell-sourced damage carries no adverse-exchange shape; admitting it would make the guard inert"
+ );
+ }
+
+ /// N2 — `DamageAll` shares the `damage_source` axis with `DealDamage`, so the
+ /// guard must be no tighter than that class.
+ #[test]
+ fn damage_all_from_a_target_source_is_admitted() {
+ let def = AbilityDefinition::new(
+ AbilityKind::Spell,
+ Effect::DamageAll {
+ amount: QuantityExpr::Fixed { value: 3 },
+ target: TargetFilter::Any,
+ player_filter: None,
+ damage_source: Some(DamageSource::Target),
+ },
+ );
+ assert!(
+ guard_sees(def),
+ "target-sourced DamageAll is in the gated class (CR 120.1); dropping the arm makes the guard tighter than the class"
+ );
+ }
+
+ /// H1 — hostile: the named source object is absent. An over-approximating
+ /// guard must fall open, never claim the root is safe.
+ #[test]
+ fn missing_source_object_falls_open() {
+ let (state, _spell) = guard_fixture(vec![fight_def()]);
+ assert!(!state.layers_dirty.is_dirty(), "reach guard: lattice Clean");
+ let action = GameAction::CastSpell {
+ object_id: ObjectId(9999),
+ card_id: CardId(9999),
+ targets: vec![],
+ payment_mode: CastPaymentMode::Auto,
+ };
+ assert!(
+ root_may_yield_adverse_exchange(&state, &action),
+ "an absent source proves nothing about the bound ability, so the guard must fall open"
+ );
+ }
+
+ /// H2 — hostile: all four entered lists are empty. The paired positive in the
+ /// same test is what keeps the `false` from being a tautology.
+ #[test]
+ fn source_with_no_abilities_cannot_reject() {
+ let (mut state, spell) = guard_fixture(vec![]);
+ {
+ let source = state.objects.get(&spell).expect("fixture spell must exist");
+ assert!(
+ source.base_abilities.is_empty(),
+ "precondition: base_abilities empty"
+ );
+ assert!(source.abilities.is_empty(), "precondition: abilities empty");
+ assert!(source.back_face.is_none(), "precondition: back_face absent");
+ assert!(
+ source.cleave_variant.is_none(),
+ "precondition: cleave_variant absent"
+ );
+ }
+ assert!(
+ !guard_answer(&state, spell),
+ "an object with no ability definitions anywhere cannot bind an adverse-exchange shape"
+ );
+
+ Arc::make_mut(
+ &mut state
+ .objects
+ .get_mut(&spell)
+ .expect("fixture spell must exist")
+ .abilities,
+ )
+ .push(fight_def());
+ assert!(
+ guard_answer(&state, spell),
+ "paired positive: the same fixture must flip once a Fight definition is present"
+ );
+ }
+
+ /// H3 — hostile multi-authority: two ability trees on one object, in both
+ /// push orders. Scanning only the first entry fails the benign-first case.
+ #[test]
+ fn a_second_ability_carrying_the_shape_still_admits() {
+ for (label, abilities) in [
+ ("benign first", vec![benign_def(), fight_def()]),
+ ("shape first", vec![fight_def(), benign_def()]),
+ ] {
+ let (state, spell) = guard_fixture(abilities);
+ assert!(
+ guard_answer(&state, spell),
+ "{label}: every ability tree on the object must be walked, not just the first"
+ );
+ }
+ }
+
+ /// H4 — assumption A's rail. A layer-6 grant that has not been flushed is not
+ /// in `abilities` yet, so the guard must refuse to answer from a stale list.
+ ///
+ /// Scope: this row decides the `layers_dirty` rail only. It deliberately
+ /// marks the lattice dirty and so never reaches assumption B (the
+ /// stale-`Clean` case), which `installing_a_layer_six_grant_marks_the_lattice_dirty`
+ /// pins from the other side.
+ #[test]
+ fn a_pending_layer_grant_falls_open() {
+ let mut state = GameState::new_two_player(0);
+ state.phase = Phase::PreCombatMain;
+ state.active_player = PlayerId(0);
+ state.priority_player = PlayerId(0);
+ let creature = add_creature(&mut state, PlayerId(0));
+ let granter = add_creature(&mut state, PlayerId(0));
+ state
+ .objects
+ .get_mut(&granter)
+ .expect("granter must exist")
+ .static_definitions
+ .push(grant_fight_static());
+ // Deliberately NOT flushed: the grant must still be pending when the
+ // guard reads the object, which is the whole point of this row.
+ crate::game::layers::mark_layers_full(&mut state);
+
+ {
+ let source = state.objects.get(&creature).expect("creature must exist");
+ assert!(
+ !carries_fight(source),
+ "precondition: the grant is still pending, so neither entered list carries the shape — without the rail the guard would answer `false`"
+ );
+ }
+ assert!(
+ state.layers_dirty.is_dirty(),
+ "precondition: the fixture is on the dirty side of the rail"
+ );
+ assert!(
+ root_may_yield_adverse_exchange(
+ &state,
+ &GameAction::ActivateAbility {
+ source_id: creature,
+ ability_index: 0,
+ }
+ ),
+ "CR 613.1f: a pending layer-6 grant can add the shape after this read, so the guard must fall open"
+ );
+
+ // Non-vacuity: the grant is real. On a flushed copy of the same state it
+ // lands in `abilities`, which is what the dirty case was hiding.
+ let mut flushed = state.clone();
+ flush_layers(&mut flushed);
+ assert!(
+ carries_fight(flushed.objects.get(&creature).expect("creature must exist")),
+ "the fixture's grant must actually install once the lattice is flushed, or the dirty case proves nothing"
+ );
+ }
+
+ /// B1-fx — assumption B's tracking fixture. Moving a permanent that carries a
+ /// layer-6 grant onto the battlefield must mark the lattice dirty. This does
+ /// not prove the engine-wide invariant (no fixture can); it pins the specific
+ /// mutation class the guard's `Clean` reading depends on.
+ #[test]
+ fn installing_a_layer_six_grant_marks_the_lattice_dirty() {
+ let mut state = GameState::new_two_player(0);
+ let card_id = CardId(state.next_object_id);
+ let granter = create_object(
+ &mut state,
+ card_id,
+ PlayerId(0),
+ "Grant Test Enchantment".to_string(),
+ Zone::Hand,
+ );
+ state
+ .objects
+ .get_mut(&granter)
+ .expect("granter must exist")
+ .static_definitions
+ .push(grant_fight_static());
+ flush_layers(&mut state);
+ assert!(
+ !state.layers_dirty.is_dirty(),
+ "precondition: the lattice starts Clean, so the mark below is the one under test"
+ );
+
+ let mut events = Vec::new();
+ crate::game::zones::move_to_zone(&mut state, granter, Zone::Battlefield, &mut events);
+ assert!(
+ state.layers_dirty.is_dirty(),
+ "CR 613.1f: installing a layer-6 grant must mark the lattice, or the guard could read a stale `Clean` list"
+ );
+ }
+
+ /// H5 — hostile: the shape is reachable only through a branch the bound
+ /// tests' `.effect` + `.sub_ability` spine never walks.
+ #[test]
+ fn shape_behind_else_or_mode_branch_still_admits() {
+ let mut with_else = benign_def();
+ with_else.else_ability = Some(Box::new(fight_def()));
+ assert!(
+ guard_sees(with_else),
+ "CR 608.2c: an `else` continuation is part of the definition tree the bind reads"
+ );
+
+ let mut with_mode = benign_def();
+ with_mode.mode_abilities.push(benign_def());
+ with_mode.mode_abilities.push(fight_def());
+ assert!(
+ guard_sees(with_mode),
+ "CR 700.2a: modes are chosen as part of casting, so a mode branch really can feed the bound spine"
+ );
+ }
+
+ /// H6 — hostile branch precedence: a non-cast/non-activate action is answered
+ /// by `RootBinding::from_action`, before the guard reads anything. The fixture
+ /// deliberately holds a Fight spell so the `false` is about the action kind.
+ #[test]
+ fn non_root_action_short_circuits_before_the_guard() {
+ let (state, spell) = guard_fixture(vec![fight_def()]);
+ assert!(
+ guard_answer(&state, spell),
+ "precondition: this state DOES carry an adverse shape on its cast root"
+ );
+ assert!(
+ !root_may_yield_adverse_exchange(&state, &GameAction::PassPriority),
+ "a non-root action is Indeterminate by construction; the guard must not read a source for it"
+ );
+ let pass = validated_candidate_actions_for_semantic_owner(&state, PlayerId(0))
+ .into_iter()
+ .find(|candidate| matches!(candidate.action, GameAction::PassPriority))
+ .expect("the engine must issue a pass-priority candidate at a priority window");
+ assert_eq!(
+ targeted_exchange_verdict(&state, &pass),
+ TargetedExchangeVerdict::Indeterminate,
+ "the verdict's own `RootBinding::from_action` early-out keeps its current precedence"
+ );
+ }
+
+ /// H7 — carrier completeness. Mirrors
+ /// `printed_cards::tests::walker_covers_every_nested_carrier` with
+ /// `Effect::Fight` as the marker instead of `Effect::Conjure`. A future nested
+ /// struct field is not caught by the compiler; these two fixtures are the
+ /// safety net, and a dropped descent fails both.
+ #[test]
+ fn predicate_sees_a_fight_in_every_nested_carrier() {
+ let mut cases: Vec<(&str, AbilityDefinition)> = Vec::new();
+
+ // --- AbilityDefinition level ---
+ let mut sub = benign_def();
+ sub.sub_ability = Some(Box::new(fight_def()));
+ cases.push(("sub_ability", sub));
+
+ let mut else_branch = benign_def();
+ else_branch.else_ability = Some(Box::new(fight_def()));
+ cases.push(("else_ability", else_branch));
+
+ let mut mode = benign_def();
+ mode.mode_abilities.push(fight_def());
+ cases.push(("mode_abilities", mode));
+
+ let mut cost = benign_def();
+ cost.cost = Some(fight_cost());
+ cases.push(("cost (EffectCost)", cost));
+
+ let mut unless_pay = benign_def();
+ unless_pay.unless_pay = Some(UnlessPayModifier {
+ cost: fight_cost(),
+ payer: TargetFilter::Controller,
+ });
+ cases.push(("unless_pay.cost", unless_pay));
+
+ // --- AbilityCost level ---
+ let mut composite = benign_def();
+ composite.cost = Some(AbilityCost::Composite {
+ costs: vec![AbilityCost::Tap, fight_cost()],
+ });
+ cases.push(("AbilityCost::Composite", composite));
+
+ let mut one_of = benign_def();
+ one_of.cost = Some(AbilityCost::OneOf {
+ costs: vec![AbilityCost::Tap, fight_cost()],
+ });
+ cases.push(("AbilityCost::OneOf", one_of));
+
+ let mut per_counter = benign_def();
+ per_counter.cost = Some(AbilityCost::PerCounter {
+ counter: CounterType::Plus1Plus1,
+ target: TargetFilter::SelfRef,
+ base: Box::new(fight_cost()),
+ });
+ cases.push(("AbilityCost::PerCounter.base", per_counter));
+
+ // --- Effect level ---
+ cases.push((
+ "Vote::per_choice_effect",
+ AbilityDefinition::new(
+ AbilityKind::Spell,
+ Effect::Vote {
+ choices: vec!["x".into()],
+ per_choice_effect: vec![Box::new(fight_def())],
+ starting_with: ControllerRef::You,
+ voter_scope: VoterScope::AllPlayers,
+ tally_mode: VoteTally::PerVote,
+ subject: VoteSubject::Named,
+ visibility: VoteVisibility::Open,
+ },
+ ),
+ ));
+ cases.push((
+ "VoteSubject::Objects::outcome_template",
+ AbilityDefinition::new(
+ AbilityKind::Spell,
+ Effect::Vote {
+ choices: vec![],
+ per_choice_effect: vec![],
+ starting_with: ControllerRef::You,
+ voter_scope: VoterScope::AllPlayers,
+ tally_mode: VoteTally::PerVote,
+ subject: VoteSubject::Objects {
+ candidate_filter: TargetFilter::Any,
+ outcome_template: Box::new(fight_def()),
+ },
+ visibility: VoteVisibility::Open,
+ },
+ ),
+ ));
+ cases.push((
+ "SeparateIntoPiles::chosen_pile_effect",
+ AbilityDefinition::new(
+ AbilityKind::Spell,
+ Effect::SeparateIntoPiles {
+ partition_subject: VoterScope::EachOpponent,
+ object_filter: TargetFilter::Any,
+ chooser: PlayerScope::Controller,
+ chosen_pile_effect: Box::new(fight_def()),
+ pile_source: PileSource::Battlefield,
+ unchosen_pile_effect: None,
+ },
+ ),
+ ));
+ cases.push((
+ "SeparateIntoPiles::unchosen_pile_effect",
+ AbilityDefinition::new(
+ AbilityKind::Spell,
+ Effect::SeparateIntoPiles {
+ partition_subject: VoterScope::EachOpponent,
+ object_filter: TargetFilter::Any,
+ chooser: PlayerScope::Controller,
+ chosen_pile_effect: Box::new(benign_def()),
+ pile_source: PileSource::Battlefield,
+ unchosen_pile_effect: Some(Box::new(fight_def())),
+ },
+ ),
+ ));
+ cases.push((
+ "RevealFromHand::on_decline",
+ AbilityDefinition::new(
+ AbilityKind::Spell,
+ Effect::RevealFromHand {
+ filter: TargetFilter::Any,
+ on_decline: Some(Box::new(fight_def())),
+ },
+ ),
+ ));
+ cases.push((
+ "CreateDelayedTrigger::effect",
+ AbilityDefinition::new(
+ AbilityKind::Spell,
+ Effect::CreateDelayedTrigger {
+ condition: DelayedTriggerCondition::AtNextPhase {
+ phase: Phase::Upkeep,
+ },
+ effect: Box::new(fight_def()),
+ uses_tracked_set: false,
+ },
+ ),
+ ));
+ cases.push((
+ "FlipCoin::win_effect",
+ AbilityDefinition::new(
+ AbilityKind::Spell,
+ Effect::FlipCoin {
+ win_effect: Some(Box::new(fight_def())),
+ lose_effect: None,
+ flipper: TargetFilter::Controller,
+ },
+ ),
+ ));
+ cases.push((
+ "FlipCoin::lose_effect",
+ AbilityDefinition::new(
+ AbilityKind::Spell,
+ Effect::FlipCoin {
+ win_effect: None,
+ lose_effect: Some(Box::new(fight_def())),
+ flipper: TargetFilter::Controller,
+ },
+ ),
+ ));
+ cases.push((
+ "FlipCoins::win_effect",
+ AbilityDefinition::new(
+ AbilityKind::Spell,
+ Effect::FlipCoins {
+ count: QuantityExpr::Fixed { value: 2 },
+ win_effect: Some(Box::new(fight_def())),
+ lose_effect: None,
+ flipper: TargetFilter::Controller,
+ },
+ ),
+ ));
+ cases.push((
+ "FlipCoins::lose_effect",
+ AbilityDefinition::new(
+ AbilityKind::Spell,
+ Effect::FlipCoins {
+ count: QuantityExpr::Fixed { value: 2 },
+ win_effect: None,
+ lose_effect: Some(Box::new(fight_def())),
+ flipper: TargetFilter::Controller,
+ },
+ ),
+ ));
+ cases.push((
+ "FlipCoinUntilLose::win_effect",
+ AbilityDefinition::new(
+ AbilityKind::Spell,
+ Effect::FlipCoinUntilLose {
+ win_effect: Box::new(fight_def()),
+ },
+ ),
+ ));
+ cases.push((
+ "RollDie::results[].effect",
+ AbilityDefinition::new(
+ AbilityKind::Spell,
+ Effect::RollDie {
+ count: QuantityExpr::Fixed { value: 1 },
+ sides: 6,
+ results: vec![DieResultBranch {
+ min: 1,
+ max: 6,
+ effect: Box::new(fight_def()),
+ }],
+ modifier: None,
+ },
+ ),
+ ));
+ cases.push((
+ "ChooseOneOf::branches",
+ AbilityDefinition::new(
+ AbilityKind::Spell,
+ Effect::ChooseOneOf {
+ chooser: PlayerFilter::Controller,
+ branches: vec![fight_def()],
+ },
+ ),
+ ));
+ cases.push((
+ "CreateDrawReplacement::replacement_effect",
+ AbilityDefinition::new(
+ AbilityKind::Spell,
+ Effect::CreateDrawReplacement {
+ replacement_effect: Box::new(fight_effect()),
+ },
+ ),
+ ));
+ cases.push((
+ "CreatePlaneswalkReplacement::replacement_effect",
+ AbilityDefinition::new(
+ AbilityKind::Spell,
+ Effect::CreatePlaneswalkReplacement {
+ replacement_effect: Box::new(fight_effect()),
+ },
+ ),
+ ));
+ cases.push((
+ "GenericEffect::static_abilities",
+ AbilityDefinition::new(
+ AbilityKind::Spell,
+ Effect::GenericEffect {
+ static_abilities: vec![grant_fight_static()],
+ duration: None,
+ target: None,
+ end_cost: None,
+ },
+ ),
+ ));
+ cases.push((
+ "Token::static_abilities",
+ AbilityDefinition::new(
+ AbilityKind::Spell,
+ Effect::Token {
+ name: "T".to_string(),
+ power: PtValue::Fixed(1),
+ toughness: PtValue::Fixed(1),
+ types: vec!["Creature".to_string()],
+ colors: vec![],
+ keywords: vec![],
+ tapped: false,
+ count: QuantityExpr::Fixed { value: 1 },
+ owner: TargetFilter::Controller,
+ attach_to: None,
+ enters_attacking: false,
+ supertypes: vec![],
+ static_abilities: vec![grant_fight_static()],
+ enter_with_counters: vec![],
+ },
+ ),
+ ));
+ cases.push((
+ "CreateEmblem::statics",
+ AbilityDefinition::new(
+ AbilityKind::Spell,
+ Effect::CreateEmblem {
+ statics: vec![grant_fight_static()],
+ triggers: vec![],
+ },
+ ),
+ ));
+
+ let mut emblem_trigger = TriggerDefinition::new(TriggerMode::ChangesZone);
+ emblem_trigger.execute = Some(Box::new(fight_def()));
+ cases.push((
+ "CreateEmblem::triggers -> TriggerDefinition::execute",
+ AbilityDefinition::new(
+ AbilityKind::Spell,
+ Effect::CreateEmblem {
+ statics: vec![],
+ triggers: vec![emblem_trigger],
+ },
+ ),
+ ));
+
+ let mut trigger_unless_pay = TriggerDefinition::new(TriggerMode::ChangesZone);
+ trigger_unless_pay.unless_pay = Some(UnlessPayModifier {
+ cost: fight_cost(),
+ payer: TargetFilter::Controller,
+ });
+ cases.push((
+ "TriggerDefinition::unless_pay.cost",
+ AbilityDefinition::new(
+ AbilityKind::Spell,
+ Effect::CreateEmblem {
+ statics: vec![],
+ triggers: vec![trigger_unless_pay],
+ },
+ ),
+ ));
+
+ let mut repl_execute = ReplacementDefinition::new(ReplacementEvent::ChangeZone);
+ repl_execute.execute = Some(Box::new(fight_def()));
+ cases.push((
+ "AddTargetReplacement -> ReplacementDefinition::execute",
+ AbilityDefinition::new(
+ AbilityKind::Spell,
+ Effect::AddTargetReplacement {
+ replacement: Box::new(repl_execute),
+ target: TargetFilter::Any,
+ },
+ ),
+ ));
+
+ let mut repl_maycost_cost = ReplacementDefinition::new(ReplacementEvent::ChangeZone);
+ repl_maycost_cost.mode = ReplacementMode::MayCost {
+ cost: fight_cost(),
+ decline: None,
+ };
+ cases.push((
+ "ReplacementMode::MayCost.cost",
+ AbilityDefinition::new(
+ AbilityKind::Spell,
+ Effect::AddTargetReplacement {
+ replacement: Box::new(repl_maycost_cost),
+ target: TargetFilter::Any,
+ },
+ ),
+ ));
+
+ let mut repl_maycost_decline = ReplacementDefinition::new(ReplacementEvent::ChangeZone);
+ repl_maycost_decline.mode = ReplacementMode::MayCost {
+ cost: AbilityCost::Tap,
+ decline: Some(Box::new(fight_def())),
+ };
+ cases.push((
+ "ReplacementMode::MayCost.decline",
+ AbilityDefinition::new(
+ AbilityKind::Spell,
+ Effect::AddTargetReplacement {
+ replacement: Box::new(repl_maycost_decline),
+ target: TargetFilter::Any,
+ },
+ ),
+ ));
+
+ let mut repl_optional = ReplacementDefinition::new(ReplacementEvent::ChangeZone);
+ repl_optional.mode = ReplacementMode::Optional {
+ decline: Some(Box::new(fight_def())),
+ };
+ cases.push((
+ "ReplacementMode::Optional.decline",
+ AbilityDefinition::new(
+ AbilityKind::Spell,
+ Effect::AddTargetReplacement {
+ replacement: Box::new(repl_optional),
+ target: TargetFilter::Any,
+ },
+ ),
+ ));
+
+ cases.push((
+ "Counter::source_rider::LosesAbilities::static_def",
+ AbilityDefinition::new(
+ AbilityKind::Spell,
+ Effect::Counter {
+ target: TargetFilter::Any,
+ source_rider: Some(CounterSourceRider::LosesAbilities {
+ static_def: Box::new(grant_fight_static()),
+ duration: Box::new(Duration::UntilHostLeavesPlay),
+ }),
+ countered_spell_zone: None,
+ },
+ ),
+ ));
+
+ // --- ContinuousModification level (reached through a static) ---
+ let mut grant_trigger = TriggerDefinition::new(TriggerMode::ChangesZone);
+ grant_trigger.execute = Some(Box::new(fight_def()));
+ cases.push((
+ "ContinuousModification::GrantTrigger",
+ static_carrier(ContinuousModification::GrantTrigger {
+ trigger: Box::new(grant_trigger),
+ }),
+ ));
+
+ let mut grant_replacement = ReplacementDefinition::new(ReplacementEvent::ChangeZone);
+ grant_replacement.execute = Some(Box::new(fight_def()));
+ cases.push((
+ "ContinuousModification::GrantReplacement",
+ static_carrier(ContinuousModification::GrantReplacement {
+ replacement: Box::new(grant_replacement),
+ }),
+ ));
+
+ cases.push((
+ "ContinuousModification::GrantStaticAbility",
+ static_carrier(ContinuousModification::GrantStaticAbility {
+ definition: Box::new(grant_fight_static()),
+ }),
+ ));
+
+ // --- CopiableValues level (reached through CopyValues) ---
+ let mut copy_abilities = empty_copiable_values();
+ copy_abilities.abilities = Arc::new(vec![fight_def()]);
+ cases.push((
+ "CopiableValues::abilities",
+ static_carrier(copy_values(copy_abilities)),
+ ));
+
+ let mut copy_trigger = TriggerDefinition::new(TriggerMode::ChangesZone);
+ copy_trigger.execute = Some(Box::new(fight_def()));
+ let mut copy_triggers = empty_copiable_values();
+ copy_triggers.trigger_definitions = Arc::new(vec![copy_trigger]);
+ cases.push((
+ "CopiableValues::trigger_definitions",
+ static_carrier(copy_values(copy_triggers)),
+ ));
+
+ let mut copy_statics = empty_copiable_values();
+ copy_statics.static_definitions = Arc::new(vec![grant_fight_static()]);
+ cases.push((
+ "CopiableValues::static_definitions",
+ static_carrier(copy_values(copy_statics)),
+ ));
+
+ let mut copy_repl = ReplacementDefinition::new(ReplacementEvent::ChangeZone);
+ copy_repl.execute = Some(Box::new(fight_def()));
+ let mut copy_replacements = empty_copiable_values();
+ copy_replacements.replacement_definitions = Arc::new(vec![copy_repl]);
+ cases.push((
+ "CopiableValues::replacement_definitions",
+ static_carrier(copy_values(copy_replacements)),
+ ));
+
+ for (carrier, def) in cases {
+ assert!(
+ guard_sees(def),
+ "the predicate missed an `Effect::Fight` planted in carrier '{carrier}'"
+ );
+ }
+ }
+
+ /// Wrap a `ContinuousModification` in the static-ability carrier
+ /// `visit_ability_def` reaches from an `AbilityDefinition`.
+ fn static_carrier(modification: ContinuousModification) -> AbilityDefinition {
+ AbilityDefinition::new(
+ AbilityKind::Spell,
+ Effect::GenericEffect {
+ static_abilities: vec![
+ StaticDefinition::new(StaticMode::Continuous).modifications(vec![modification])
+ ],
+ duration: None,
+ target: None,
+ end_cost: None,
+ },
+ )
+ }
+
+ fn copy_values(values: CopiableValues) -> ContinuousModification {
+ ContinuousModification::CopyValues {
+ values: Box::new(values),
+ display_source: crate::game::game_object::DisplaySource::default(),
+ printed_ref: None,
+ token_image_ref: None,
+ }
+ }
+
+ /// A `CopiableValues` whose four definition lists are empty, so only the list
+ /// a test overwrites can carry the marker.
+ fn empty_copiable_values() -> CopiableValues {
+ let mut state = GameState::new_two_player(0);
+ let object_id = add_creature(&mut state, PlayerId(0));
+ let values = crate::game::printed_cards::intrinsic_copiable_values(
+ state.objects.get(&object_id).expect("creature must exist"),
+ );
+ assert!(
+ values.abilities.is_empty()
+ && values.trigger_definitions.is_empty()
+ && values.static_definitions.is_empty()
+ && values.replacement_definitions.is_empty(),
+ "precondition: the base CopiableValues must be marker-free"
+ );
+ values
+ }
+
+ /// H8 — `Effect::ChooseOneOf` is the shape real card data exercises (Sycorax
+ /// Commander). The guard admits it; the bound spine then finds nothing,
+ /// because the branch is chosen at resolution, so behavior is unchanged.
+ #[test]
+ fn fight_inside_choose_one_of_branches_is_admitted() {
+ let def = AbilityDefinition::new(
+ AbilityKind::Spell,
+ Effect::ChooseOneOf {
+ chooser: PlayerFilter::Controller,
+ branches: vec![fight_def()],
+ },
+ );
+ let (state, spell) = guard_fixture(vec![def]);
+ assert!(
+ guard_answer(&state, spell),
+ "the guard must not rely on the bound-spine reduction; dropping the ChooseOneOf descent makes this `false`"
+ );
+ let root = validated_candidate_actions_for_semantic_owner(&state, PlayerId(0))
+ .into_iter()
+ .find(|candidate| {
+ matches!(candidate.action, GameAction::CastSpell { object_id, .. } if object_id == spell)
+ })
+ .expect("the engine must issue the root cast candidate");
+ assert_eq!(
+ targeted_exchange_verdict(&state, &root),
+ TargetedExchangeVerdict::Indeterminate,
+ "the announcement-time spine's root effect is still ChooseOneOf, so `find_fight_leaf` finds nothing and the verdict is unchanged"
+ );
+ }
+
+ /// H9 — hostile second-face authority. The front lists carry no shape; the
+ /// back face does. Both live cast-time face-election routes install from
+ /// exactly this field (CR 712.11b / CR 715.3a / CR 720.3a), and Fuse reads it
+ /// without installing at all.
+ #[test]
+ fn alternative_face_only_shape_still_admits() {
+ let (mut state, spell) = guard_fixture(vec![benign_def()]);
+ let mut face = crate::game::printed_cards::snapshot_object_base_face(
+ state.objects.get(&spell).expect("fixture spell must exist"),
+ );
+ face.abilities = vec![fight_def()];
+ {
+ let source = state.objects.get(&spell).expect("fixture spell must exist");
+ assert!(
+ !carries_fight(source),
+ "precondition: the front lists must be shape-free, or this row passes for the wrong reason"
+ );
+ }
+ state
+ .objects
+ .get_mut(&spell)
+ .expect("fixture spell must exist")
+ .back_face = Some(face);
+ assert!(
+ guard_answer(&state, spell),
+ "deleting the `back_face` chain arm silently loses every Reject reachable through a cast-time face swap"
+ );
+ }
+
+ /// H9b — hostile second *text* authority on the same face. Cleave's second
+ /// ability is a text-changing effect (CR 702.148b + CR 612), and
+ /// `apply_cleave_text_change` installs both ability lists from this field.
+ #[test]
+ fn cleave_variant_only_shape_still_admits() {
+ let (mut state, spell) = guard_fixture(vec![benign_def()]);
+ {
+ let source = state.objects.get(&spell).expect("fixture spell must exist");
+ assert!(
+ !carries_fight(source),
+ "precondition: the front lists must be shape-free, or this row passes for the wrong reason"
+ );
+ assert!(
+ source.back_face.is_none(),
+ "precondition: no back face, so only the cleave arm can answer"
+ );
+ }
+ state
+ .objects
+ .get_mut(&spell)
+ .expect("fixture spell must exist")
+ .cleave_variant = Some(CleaveVariant {
+ abilities: vec![fight_def()],
+ ..CleaveVariant::default()
+ });
+ assert!(
+ guard_answer(&state, spell),
+ "deleting the `cleave_variant` chain arm silently loses every Reject reachable through a cleave text change"
+ );
+ }
+
+ /// H9c — the printed list is the layer-6 *input*, not the bind. CR 613.1: a
+ /// `RemoveAllAbilities` grant (Humility, Turn to Frog) empties `abilities`
+ /// while `base_abilities` still carries the printed shape, and the reducer
+ /// re-derives from the printed list. Pins the `base_abilities` chain arm,
+ /// which was the one arm of the four with no fixture of its own.
+ #[test]
+ fn base_abilities_only_shape_still_admits() {
+ let (mut state, spell) = guard_fixture(vec![]);
+ {
+ let source = state
+ .objects
+ .get_mut(&spell)
+ .expect("fixture spell must exist");
+ *Arc::make_mut(&mut source.base_abilities) = vec![fight_def()];
+ }
+ {
+ let source = state.objects.get(&spell).expect("fixture spell must exist");
+ assert!(
+ source.abilities.is_empty(),
+ "precondition: the post-layer list must be empty, or the `abilities` arm answers and this row passes for the wrong reason"
+ );
+ assert!(
+ source.back_face.is_none() && source.cleave_variant.is_none(),
+ "precondition: no second text authority, so only the `base_abilities` arm can answer"
+ );
+ }
+ assert!(
+ guard_answer(&state, spell),
+ "deleting the `base_abilities` chain arm silently loses every Reject on a source under a layer-6 ability removal"
+ );
+ }
+
+ /// H10 — the `RootBinding::Activation` index rail. CR 602.2b: the announced
+ /// ability is chosen by `ability_index`, and
+ /// `casting::activation_ability_definition` resolves an index at or past
+ /// `obj.abilities.len()` from four SYNTHESIZED-ON-READ families
+ /// (cycling CR 702.29a, graveyard-activated, plot CR 702.170f, equip CR 702.6)
+ /// that are written into no field the guard chains.
+ ///
+ /// Pins the INDEX BOUNDARY, not a card: none of those four families carries an
+ /// adverse shape today, so a card-shaped fixture would stay green with the rail
+ /// deleted. Both halves are load-bearing — the in-range half proves the fixture
+ /// still discriminates, so the out-of-range half cannot pass vacuously.
+ #[test]
+ fn activation_beyond_printed_abilities_falls_open() {
+ let (state, spell) = guard_fixture(vec![benign_def()]);
+ assert!(
+ !state.layers_dirty.is_dirty(),
+ "reach guard: a dirty lattice makes the guard fall open before it reads the index"
+ );
+ let printed_len = state
+ .objects
+ .get(&spell)
+ .expect("fixture spell must exist")
+ .abilities
+ .len();
+ assert_eq!(
+ printed_len, 1,
+ "precondition: exactly one printed ability, so index 1 is the first synthesized slot"
+ );
+ let activation = |ability_index: usize| GameAction::ActivateAbility {
+ source_id: spell,
+ ability_index,
+ };
+ assert!(
+ !root_may_yield_adverse_exchange(&state, &activation(0)),
+ "non-vacuity: an in-range index over a benign printed list must still answer `false`, or the out-of-range row proves nothing"
+ );
+ assert!(
+ root_may_yield_adverse_exchange(&state, &activation(printed_len)),
+ "deleting the `RootBinding::Activation` index rail answers a runtime-granted activation from lists that provably cannot contain its definition"
+ );
+ }
}
diff --git a/crates/engine/src/game/effects/awaken.rs b/crates/engine/src/game/effects/awaken.rs
index 944dc782a3..51ac0c6614 100644
--- a/crates/engine/src/game/effects/awaken.rs
+++ b/crates/engine/src/game/effects/awaken.rs
@@ -45,6 +45,21 @@ use crate::types::keywords::Keyword;
/// game) — set on the sub-ability's `duration` field because `Effect::Animate`
/// itself carries no duration and `animate::resolve` reads `ability.duration`
/// (defaulting to `UntilEndOfTurn` when absent).
+///
+/// AI COUPLING — read before changing this payload. The Awaken branch of
+/// `casting::prepare_spell_cast_with_variant_override_inner` appends
+/// this rider to the bound spine, so it is SYNTHESIZED content that reaches the
+/// bind without ever being written to a `GameObject` ability field.
+/// `ai_support::targeted_exchange::root_may_yield_adverse_exchange` is a
+/// clone-free precondition that reads only the source's STORED ability lists and
+/// skips the AI's adverse-exchange preview when it finds no adverse shape there —
+/// it therefore cannot see anything this function builds. That is sound ONLY
+/// because the payload below is fixed: `Effect::PutCounter` chained to
+/// `Effect::Animate`, neither of which either judge (`find_fight_leaf`,
+/// `is_target_sourced_self_damage`) can reject on. Introducing `Effect::Fight` or
+/// `Effect::DealDamage`/`DamageAll { damage_source: Some(DamageSource::Target) }`
+/// here silently drops a `Reject` with no failing test — add a fall-open rail to
+/// that guard in the SAME change. See clause (b2)(ii) of its doc comment.
fn build_awaken_rider(count: u32) -> AbilityDefinition {
let land_you_control = TargetFilter::Typed(TypedFilter::land().controller(ControllerRef::You));
diff --git a/crates/engine/src/game/printed_cards.rs b/crates/engine/src/game/printed_cards.rs
index 378621c6b0..da67332df6 100644
--- a/crates/engine/src/game/printed_cards.rs
+++ b/crates/engine/src/game/printed_cards.rs
@@ -1,10 +1,19 @@
use crate::database::synthesis::KeywordTriggerInstaller;
use crate::database::CardDatabase;
use crate::types::ability::{
- AbilityCost, AbilityDefinition, ConjureSource, ContinuousModification, CopiableValues,
- CounterSourceRider, Effect, PtValue, QuantityExpr, ReplacementCondition, ReplacementDefinition,
- ReplacementMode, RestrictionExpiry, StaticDefinition, TargetFilter, TriggerDefinition,
- VoteSubject,
+ AbilityDefinition, ConjureSource, CopiableValues, Effect, PtValue, QuantityExpr,
+ ReplacementCondition, ReplacementDefinition, ReplacementMode, RestrictionExpiry,
+ StaticDefinition, TargetFilter, TriggerDefinition,
+};
+// `VoteSubject` is NOT re-imported here: `mod tests`'s only use of it
+// (`crate::types::ability::VoteSubject::Named`) is fully qualified, so a gated
+// import would be an `unused_imports` error.
+#[cfg(test)]
+use crate::types::ability::CounterSourceRider;
+#[cfg(test)]
+use crate::types::ability_visit::visit_effect;
+use crate::types::ability_visit::{
+ visit_ability_def, visit_replacement, visit_static, visit_trigger,
};
use crate::types::card::{CardFace, CardLayout, LayoutKind, PrintedCardRef, PrintedLoyalty};
use crate::types::card_type::{CardType, CoreType};
@@ -16,6 +25,7 @@ use crate::types::mana::{ManaColor, ManaCost, ManaCostShard};
use crate::types::replacements::ReplacementEvent;
use crate::types::zones::Zone;
use std::collections::HashMap;
+use std::ops::ControlFlow;
use std::sync::Arc;
use super::game_object::{BackFaceData, GameObject};
@@ -793,14 +803,19 @@ pub fn snapshot_object_base_face(obj: &GameObject) -> BackFaceData {
// game can reach as Conjure targets: the transitive closure of conjure names
// over the seed faces present in the game (objects + deck pools).
//
-// These walkers yield every conjure name reachable from a `CardFace`. They
-// traverse every nested ability/effect/cost carrier. The core `walk_effect`
-// match is wildcard-free so any future `Effect` variant that carries a nested
-// `Box` / `Box` must be handled here at compile time.
+// These wrappers yield every conjure name reachable from a `CardFace`. The
+// traversal itself lives in `crate::types::ability_visit`, which owns the
+// wildcard-free `Effect` / `ContinuousModification` / `AbilityCost` matches: a
+// future variant carrying a nested `Box` / `Box` is a
+// compile error there. These wrappers supply only the conjure/meld
+// name-extraction leaf (`collect_conjure_names`).
//
-// TODO: consolidate with coverage traversal (`game/coverage.rs`). The coverage
-// pass builds `ParsedItem` trees rather than yielding `Effect`s, so no reusable
-// visitor exists today; extracting one is out of scope for this memory fix.
+// The reusable visitor this file's TODO asked for now exists:
+// `crate::types::ability_visit`. `game/coverage.rs` is still NOT migrated — its
+// pass builds `ParsedItem` trees rather than yielding `Effect`s, and
+// `coverage::ability_tree_any` is deliberately narrower (it has a `_ => {}`
+// wildcard); broadening it would change the coverage report. See the
+// `types::ability_visit` module doc.
// ---------------------------------------------------------------------------
/// Collect every conjure name reachable from a single card face's ability set.
@@ -822,218 +837,10 @@ fn collect_conjure_names_from_face(face: &CardFace, out: &mut Vec) {
out.extend(face.metadata.spellbook.iter().cloned());
}
-fn walk_ability_def(def: &AbilityDefinition, out: &mut Vec) {
- walk_effect(&def.effect, out);
- if let Some(cost) = &def.cost {
- walk_cost(cost, out);
- }
- if let Some(sub) = &def.sub_ability {
- walk_ability_def(sub, out);
- }
- if let Some(else_ability) = &def.else_ability {
- walk_ability_def(else_ability, out);
- }
- for mode in &def.mode_abilities {
- walk_ability_def(mode, out);
- }
- // "unless [player] pays {cost}" — the cost may be an EffectCost that conjures.
- if let Some(unless_pay) = &def.unless_pay {
- walk_cost(&unless_pay.cost, out);
- }
-}
-
-fn walk_trigger(trigger: &TriggerDefinition, out: &mut Vec) {
- if let Some(execute) = &trigger.execute {
- walk_ability_def(execute, out);
- }
- if let Some(unless_pay) = &trigger.unless_pay {
- walk_cost(&unless_pay.cost, out);
- }
-}
-
-fn walk_replacement(replacement: &ReplacementDefinition, out: &mut Vec) {
- if let Some(execute) = &replacement.execute {
- walk_ability_def(execute, out);
- }
- // The mode carries the decline continuation (and, for MayCost, a cost),
- // either of which may conjure. Descend into both.
- match &replacement.mode {
- ReplacementMode::MayCost { cost, decline } => {
- walk_cost(cost, out);
- if let Some(decline) = decline {
- walk_ability_def(decline, out);
- }
- }
- ReplacementMode::Optional { decline } => {
- if let Some(decline) = decline {
- walk_ability_def(decline, out);
- }
- }
- ReplacementMode::Mandatory => {}
- }
- // `runtime_execute` holds a resolution-time continuation that is never
- // present on a printed/static `CardFace`; skipped intentionally.
-}
-
-fn walk_static(static_def: &StaticDefinition, out: &mut Vec) {
- for modification in &static_def.modifications {
- walk_continuous_mod(modification, out);
- }
-}
-
-fn walk_continuous_mod(modification: &ContinuousModification, out: &mut Vec) {
- match modification {
- ContinuousModification::GrantAbility { definition } => walk_ability_def(definition, out),
- ContinuousModification::GrantTrigger { trigger } => walk_trigger(trigger, out),
- ContinuousModification::GrantReplacement { replacement } => {
- walk_replacement(replacement, out)
- }
- ContinuousModification::GrantStaticAbility { definition } => walk_static(definition, out),
- ContinuousModification::CopyValues { values, .. } => walk_copiable_values(values, out),
- // Remaining modifications carry no nested ability/effect carriers.
- // GrantAllActivatedAbilitiesOf / GrantAllTriggeredAbilitiesOf only hold a
- // source `TargetFilter`; the granted abilities/triggers are pulled live
- // from the provider objects at layer collection time, not nested here.
- ContinuousModification::GrantAllActivatedAbilitiesOf { .. }
- | ContinuousModification::GrantAllTriggeredAbilitiesOf { .. }
- // CR 707.2c (Metamorphic Alteration): inert parse-time copy marker — no
- // nested ability/effect carrier to walk (the copy grant is the runtime TCE).
- | ContinuousModification::CopyChosen
- | ContinuousModification::SetName { .. }
- | ContinuousModification::SetTextName { .. }
- | ContinuousModification::AddPower { .. }
- | ContinuousModification::AddToughness { .. }
- | ContinuousModification::SetPower { .. }
- | ContinuousModification::SetToughness { .. }
- | ContinuousModification::AddKeyword { .. }
- | ContinuousModification::AddKeywordWithDerivedCost { .. }
- | ContinuousModification::RemoveKeyword { .. }
- | ContinuousModification::RemoveAllAbilities
- | ContinuousModification::AddType { .. }
- | ContinuousModification::RemoveType { .. }
- | ContinuousModification::AddSubtype { .. }
- | ContinuousModification::RemoveSubtype { .. }
- | ContinuousModification::SetCardTypes { .. }
- | ContinuousModification::RemoveAllSubtypes { .. }
- | ContinuousModification::SetDynamicPower { .. }
- | ContinuousModification::SetDynamicToughness { .. }
- | ContinuousModification::SetPowerDynamic { .. }
- | ContinuousModification::SetToughnessDynamic { .. }
- | ContinuousModification::AddDynamicPower { .. }
- | ContinuousModification::AddDynamicToughness { .. }
- | ContinuousModification::AddDynamicKeyword { .. }
- | ContinuousModification::AddAllCreatureTypes
- | ContinuousModification::AddAllBasicLandTypes
- | ContinuousModification::AddAllLandTypes
- | ContinuousModification::AddChosenSubtype { .. }
- | ContinuousModification::AddChosenColor { .. }
- | ContinuousModification::RemoveChosenKeyword
- | ContinuousModification::AddChosenKeyword
- | ContinuousModification::SetColor { .. }
- | ContinuousModification::AddColor { .. }
- | ContinuousModification::AddStaticMode { .. }
- | ContinuousModification::SwitchPowerToughness
- | ContinuousModification::AssignDamageFromToughness
- | ContinuousModification::AssignDamageAsThoughUnblocked
- | ContinuousModification::AssignNoCombatDamage
- | ContinuousModification::ChangeController
- | ContinuousModification::SetBasicLandType { .. }
- | ContinuousModification::SetChosenBasicLandType
- | ContinuousModification::SetChosenName
- | ContinuousModification::RetainPrintedTriggerFromSource { .. }
- | ContinuousModification::RetainPrintedAbilityFromSource { .. }
- | ContinuousModification::RetainAllOtherAbilitiesFromSource
- | ContinuousModification::AddSupertype { .. }
- | ContinuousModification::RemoveSupertype { .. }
- | ContinuousModification::AddCounterOnEnter { .. }
- | ContinuousModification::SetStartingLoyalty { .. }
- | ContinuousModification::RemoveManaCost => {}
- }
-}
-
-fn walk_copiable_values(values: &CopiableValues, out: &mut Vec) {
- for ability in values.abilities.iter() {
- walk_ability_def(ability, out);
- }
- for trigger in values.trigger_definitions.iter() {
- walk_trigger(trigger, out);
- }
- for static_def in values.static_definitions.iter() {
- walk_static(static_def, out);
- }
- for replacement in values.replacement_definitions.iter() {
- walk_replacement(replacement, out);
- }
-}
-
-fn walk_cost(cost: &AbilityCost, out: &mut Vec) {
- match cost {
- AbilityCost::EffectCost { effect } => walk_effect(effect, out),
- AbilityCost::Composite { costs } | AbilityCost::OneOf { costs } => {
- for sub in costs {
- walk_cost(sub, out);
- }
- }
- AbilityCost::PerCounter { base, .. } => walk_cost(base, out),
- // Remaining costs carry no nested effect/cost carriers.
- AbilityCost::Mana { .. }
- | AbilityCost::ManaDynamic { .. }
- | AbilityCost::Tap
- | AbilityCost::Untap
- | AbilityCost::Loyalty { .. }
- | AbilityCost::Sacrifice(_)
- | AbilityCost::PayLife { .. }
- | AbilityCost::Discard { .. }
- | AbilityCost::Exile { .. }
- | AbilityCost::ExileMaterials { .. }
- | AbilityCost::CollectEvidence { .. }
- | AbilityCost::ExileWithAggregate { .. }
- | AbilityCost::TapCreatures { .. }
- | AbilityCost::RemoveCounter { .. }
- | AbilityCost::PayEnergy { .. }
- | AbilityCost::PaySpeed { .. }
- | AbilityCost::ReturnToHand { .. }
- | AbilityCost::Unattach
- | AbilityCost::UnattachFrom { .. }
- | AbilityCost::Mill { .. }
- | AbilityCost::Exert
- | AbilityCost::Blight { .. }
- | AbilityCost::Reveal { .. }
- | AbilityCost::Behold { .. }
- | AbilityCost::Waterbend { .. }
- | AbilityCost::NinjutsuFamily { .. }
- // CR 118.9: a borrowed keyword cost carries no nested effect/cost carrier.
- | AbilityCost::KeywordCostOfCastSpell { .. }
- | AbilityCost::Unimplemented { .. } => {}
- }
-}
-
-/// Yield every conjure name carried by `effect` and its nested ability/effect
-/// carriers. The match is wildcard-free, so a new `Effect` variant forces a
-/// decision here (compile error until handled). That guarantee is necessary but
-/// not sufficient: a variant wrongly added to the leaf arm, or a new nested
-/// *struct field* (which is field access, not a match arm), compiles silently.
-/// `walker_covers_every_nested_carrier` is the complementary safety net for
-/// those cases — extend it whenever a carrier is added.
-fn walk_effect(effect: &Effect, out: &mut Vec) {
+/// The conjure/meld name extraction that `visit_effect` used to inline. Split
+/// out so the traversal itself is reusable (see `types::ability_visit`).
+fn collect_conjure_names(effect: &Effect, out: &mut Vec) {
match effect {
- Effect::Intensify { .. } => {}
- Effect::ApplyPerpetual { .. } => {}
- // CR 614.11: A one-shot draw replacement nests its substitute Effect
- // (Words of Worship/Wilding). Walk it so any conjure name it carries is
- // surfaced (GainLife/Token carry none today, but it is a nested carrier).
- Effect::CreateDrawReplacement { replacement_effect } => {
- walk_effect(replacement_effect, out)
- }
- // CR 614.1a: A planeswalk replacement nests its substitute Effect (Fixed
- // Point in Time: chaos ensues). Walk it so any conjure name it carries is
- // surfaced (ChaosEnsues carries none today, but it is a nested carrier).
- Effect::CreatePlaneswalkReplacement { replacement_effect } => {
- walk_effect(replacement_effect, out)
- }
- // Heist exiles a card from an opponent's library at random; it does not
- // name a conjure card, so there is no static face to preload.
- Effect::Heist { .. } | Effect::HeistExile => {}
Effect::Conjure { cards, .. } => {
// Only named-conjure has a static card name to seed into the face
// registry. Duplicate-conjure copies a card already in play (its face
@@ -1051,338 +858,46 @@ fn walk_effect(effect: &Effect, out: &mut Vec) {
// objects the resolver finds by printed identity — they need no registry
// seeding.
Effect::Meld { result, .. } => out.push(result.clone()),
- // A spellbook draft conjures the chosen card, but the list lives on the
- // card face (`metadata.spellbook`), not in the effect — the registry
- // seed collects it directly from the face (see
- // `collect_conjure_names_from_face`), so nothing to gather here.
- Effect::DraftFromSpellbook { .. } => {}
- Effect::TurnFaceUp { .. } => {}
- Effect::TurnFaceDown { .. } => {}
- // Nested-ability carriers — descend.
- Effect::Vote {
- per_choice_effect,
- subject,
- ..
- } => {
- for sub in per_choice_effect {
- walk_ability_def(sub, out);
- }
- // CR 701.38b: object-pool votes (Council's Judgment, Prime
- // Minister's Cabinet Room) leave `per_choice_effect` empty and
- // carry the sole nested AbilityDefinition in `outcome_template`.
- // Walk it so any conjure name a future object-vote outcome names is
- // surfaced (the current exile-only class carries none).
- if let VoteSubject::Objects {
- outcome_template, ..
- } = subject
- {
- walk_ability_def(outcome_template, out);
- }
- }
- Effect::SeparateIntoPiles {
- chosen_pile_effect,
- unchosen_pile_effect,
- ..
- } => {
- walk_ability_def(chosen_pile_effect, out);
- if let Some(unchosen) = unchosen_pile_effect {
- walk_ability_def(unchosen, out);
- }
- }
- Effect::RevealFromHand { on_decline, .. } => {
- if let Some(sub) = on_decline {
- walk_ability_def(sub, out);
- }
- }
- // Only the delayed `effect` is walked; the `condition`'s embedded
- // TriggerDefinition has `execute: None` by construction (it is a matcher,
- // not a payload), so it carries no conjure name.
- Effect::CreateDelayedTrigger { effect, .. } => walk_ability_def(effect, out),
- Effect::FlipCoin {
- win_effect,
- lose_effect,
- ..
- }
- | Effect::FlipCoins {
- win_effect,
- lose_effect,
- ..
- } => {
- if let Some(sub) = win_effect {
- walk_ability_def(sub, out);
- }
- if let Some(sub) = lose_effect {
- walk_ability_def(sub, out);
- }
- }
- Effect::FlipCoinUntilLose { win_effect } => walk_ability_def(win_effect, out),
- Effect::RollDie { results, .. } => {
- for branch in results {
- walk_ability_def(&branch.effect, out);
- }
- }
- Effect::ChooseOneOf { branches, .. } => {
- for branch in branches {
- walk_ability_def(branch, out);
- }
- }
- // GenericEffect applies static abilities at resolution; their
- // modifications can grant abilities/triggers that themselves conjure.
- // Descend into the granted definitions rather than treating it as a leaf.
- Effect::GenericEffect {
- static_abilities, ..
- } => {
- for static_def in static_abilities {
- walk_static(static_def, out);
- }
- }
- // Carries a nested ReplacementDefinition whose execute/decline/cost may conjure.
- Effect::AddTargetReplacement { replacement, .. } => walk_replacement(replacement, out),
- // Counter's `source_rider` may apply a static to the countered source
- // (LosesAbilities) that grants an ability that conjures. The Destroy
- // rider carries no static.
- Effect::Counter { source_rider, .. } => {
- if let Some(CounterSourceRider::LosesAbilities { static_def, .. }) = source_rider {
- walk_static(static_def, out);
- }
- }
- // Tokens and emblems can host granted static/triggered abilities that conjure.
- Effect::Token {
- static_abilities, ..
- } => {
- for static_def in static_abilities {
- walk_static(static_def, out);
- }
- }
- Effect::CreateEmblem { statics, triggers } => {
- for static_def in statics {
- walk_static(static_def, out);
- }
- for trigger in triggers {
- walk_trigger(trigger, out);
- }
- }
- // Leaf effects with no nested ability/effect carrier.
- Effect::StartYourEngines { .. }
- | Effect::ChangeSpeed { .. }
- | Effect::DealDamage { .. }
- | Effect::ApplyPostReplacementDamage { .. }
- // CR 120.1: leaf effect — the source/recipient filters carry no nested
- // ability or effect to walk.
- | Effect::EachDealsDamageEqualToPower { .. }
- | Effect::EachSourceDealsDamage { .. }
- | Effect::Draw { .. }
- | Effect::Pump { .. }
- | Effect::PairWith { .. }
- | Effect::Destroy { .. }
- | Effect::Regenerate { .. }
- | Effect::RemoveAllDamage { .. }
- | Effect::CounterAll { .. }
- | Effect::GainLife { .. }
- | Effect::LoseLife { .. }
- | Effect::ExchangeLifeWithStat { .. }
- | Effect::ExchangeLifeTotals { .. }
- // CR 701.26a/b: all tap/untap scopes are leaf effects here.
- | Effect::SetTapState { .. }
- | Effect::RemoveCounter { .. }
- | Effect::Sacrifice { .. }
- | Effect::DiscardCard { .. }
- | Effect::Mill { .. }
- | Effect::Scry { .. }
- | Effect::PumpAll { .. }
- | Effect::DamageAll { .. }
- | Effect::DamageEachPlayer { .. }
- | Effect::DestroyAll { .. }
- | Effect::ChangeZone { .. }
- | Effect::ChangeZoneAll { .. }
- | Effect::Dig { .. }
- | Effect::GainControl { .. }
- | Effect::GainControlAll { .. }
- | Effect::ControlNextTurn { .. }
- | Effect::Attach { .. }
- | Effect::UnattachAll { .. }
- | Effect::Surveil { .. }
- | Effect::Fight { .. }
- | Effect::Bounce { .. }
- | Effect::BounceAll { .. }
- | Effect::Explore
- | Effect::ExploreAll { .. }
- | Effect::Investigate
- | Effect::Tribute { .. }
- | Effect::TimeTravel
- | Effect::BecomeMonarch
- | Effect::NoOp
- | Effect::Proliferate
- | Effect::ProliferateTarget { .. }
- | Effect::EndTheTurn
- | Effect::EndCombatPhase
- | Effect::Populate
- | Effect::Clash
- | Effect::Behold { .. }
- | Effect::SwitchPT { .. }
- | Effect::CopySpell { .. }
- | Effect::EpicCopy { .. }
- | Effect::CastCopyOfCard { .. }
- | Effect::CopyTokenOf { .. }
- // owner/type_filter are TargetFilters; no nested ability carrier and the
- // copy source comes from the format pool, so this is a leaf for conjure
- // collection.
- | Effect::CreateTokenCopyFromPool { .. }
- | Effect::Myriad
- | Effect::Encore
- | Effect::ExileHaunting { .. }
- | Effect::HideawayConceal { .. }
- | Effect::CopyTokenBlockingAttacker { .. }
- | Effect::BecomeCopy { .. }
- // CR 707.2c (Metamorphic Alteration): filter-only copy choice; no nested
- // ability carrier to walk — a leaf for printed-card collection.
- | Effect::ChoosePermanent { .. }
- | Effect::GainActivatedAbilitiesOfTarget { .. }
- | Effect::ChooseCard { .. }
- | Effect::PutCounter { .. }
- | Effect::PutCounterAll { .. }
- | Effect::MultiplyCounter { .. }
- // Builds its PutCounter/RemoveCounter branches at resolution — carries no
- // static conjure name to preload.
- | Effect::ChooseCounterAdjustment { .. }
- | Effect::DoublePT { .. }
- | Effect::DoublePTAll { .. }
- | Effect::MoveCounters { .. }
- | Effect::Animate { .. }
- | Effect::RegisterBending { .. }
- | Effect::Cleanup { .. }
- | Effect::Mana { .. }
- | Effect::Discard { .. }
- | Effect::Shuffle { .. }
- | Effect::Transform { .. }
- // CR 710.4: no nested ability carrier and no conjured card name.
- | Effect::FlipPermanent { .. }
- | Effect::SearchLibrary { .. }
- | Effect::SearchOutsideGame { .. }
- | Effect::RevealHand { .. }
- | Effect::Reveal { .. }
- | Effect::RevealTop { .. }
- | Effect::ExileTop { .. }
- | Effect::ExileFaceDownPile { .. }
- | Effect::TargetOnly { .. }
- | Effect::Choose { .. }
- | Effect::OpponentGuess { .. }
- | Effect::SwapChosenLabels { .. }
- | Effect::ChooseDamageSource { .. }
- | Effect::Suspect { .. }
- | Effect::Unsuspect { .. }
- | Effect::Connive { .. }
- | Effect::PhaseOut { .. }
- | Effect::PhaseIn { .. }
- | Effect::ForceBlock { .. }
- | Effect::ForceAttack { .. }
- | Effect::SolveCase
- | Effect::BecomePrepared { .. }
- | Effect::BecomeUnprepared { .. }
- | Effect::BecomeSaddled { .. }
- | Effect::BecomeBlocked { .. }
- | Effect::SetClassLevel { .. }
- | Effect::AddRestriction { .. }
- | Effect::ReduceNextSpellCost { .. }
- | Effect::GrantNextSpellAbility { .. }
- | Effect::AddPendingETBCounters { .. }
- | Effect::AddPendingEntersModifications { .. }
- | Effect::PayCost { .. }
- | Effect::CastFromZone { .. }
- | Effect::FreeCastFromZones { .. }
- | Effect::ExileResolvingSpellInsteadOfGraveyard { .. }
- | Effect::PreventDamage { .. }
- | Effect::LoseTheGame { .. }
- | Effect::WinTheGame { .. }
- | Effect::RingTemptsYou
- | Effect::VentureIntoDungeon
- | Effect::VentureInto { .. }
- | Effect::TakeTheInitiative
- | Effect::ArrangePlanarDeckTop { .. }
- | Effect::Planeswalk
- | Effect::ChaosEnsues
- | Effect::RedistributeLifeTotals
- | Effect::ReverseTurnOrder
- | Effect::OpenAttractions { .. }
- | Effect::RollToVisitAttractions
- | Effect::AssembleContraptions { .. }
- | Effect::AssembleContraptionsFromRollDifference
- | Effect::CrankContraptions { .. }
- | Effect::ReassembleContraption { .. }
- | Effect::AssembleContraptionOnSprocket { .. }
- | Effect::ReassembleContraptionOnSprocket { .. }
- | Effect::PutSticker { .. }
- | Effect::ApplySticker { .. }
- | Effect::ProcessRadCounters
- | Effect::GrantCastingPermission { .. }
- | Effect::ChooseFromZone { .. }
- | Effect::RememberCard { .. }
- | Effect::ForEachCategory { .. }
- | Effect::ChooseObjectsIntoTrackedSet { .. }
- | Effect::ChooseAndSacrificeRest { .. }
- | Effect::EachPlayerCopyChosen { .. }
- | Effect::Exploit { .. }
- | Effect::GainEnergy { .. }
- | Effect::GivePlayerCounter { .. }
- | Effect::LoseAllPlayerCounters { .. }
- | Effect::ExileFromTopUntil { .. }
- | Effect::RevealUntil { .. }
- | Effect::Discover { .. }
- | Effect::Cascade
- | Effect::Ripple { .. }
- | Effect::MiracleCast { .. }
- | Effect::MadnessCast { .. }
- | Effect::PutAtLibraryPosition { .. }
- | Effect::ChooseDrawnThisTurnPayOrTopdeck { .. }
- | Effect::PutOnTopOrBottom { .. }
- | Effect::GiftDelivery { .. }
- | Effect::Goad { .. }
- | Effect::GoadAll { .. }
- | Effect::Detain { .. }
- | Effect::SetRoomDoorLock { .. }
- | Effect::ExchangeControl { .. }
- | Effect::ChangeTargets { .. }
- | Effect::Manifest { .. }
- | Effect::ManifestDread
- | Effect::Cloak { .. }
- | Effect::ExtraTurn { .. }
- | Effect::GrantExtraLoyaltyActivations { .. }
- | Effect::SkipNextTurn { .. }
- | Effect::SkipNextStep { .. }
- | Effect::AdditionalPhase { .. }
- | Effect::Double { .. }
- | Effect::RuntimeHandled { .. }
- | Effect::Incubate { .. }
- | Effect::Amass { .. }
- | Effect::Monstrosity { .. }
- | Effect::Renown { .. }
- | Effect::Bolster { .. }
- | Effect::Adapt { .. }
- | Effect::Learn
- | Effect::Forage
- | Effect::Harness
- | Effect::CollectEvidence { .. }
- | Effect::Endure { .. }
- | Effect::BlightEffect { .. }
- | Effect::Seek { .. }
- | Effect::SetLifeTotal { .. }
- | Effect::SetDayNight { .. }
- | Effect::GiveControl { .. }
- | Effect::RemoveFromCombat { .. }
- | Effect::CreateDamageReplacement { .. }
- | Effect::CombineHost { .. }
- | Effect::ChooseAugmentAndCombineWithHost { .. }
- // CR 614.12 + CR 303.4: ReturnAsAura.grants carry typed
- // ContinuousModifications, never conjured card names.
- | Effect::ReturnAsAura { .. }
- | Effect::Specialize
- // CR 608.2d + CR 122.1: counter-kind choice / consume carry no conjure names.
- | Effect::ChooseCounterKind { .. }
- | Effect::PutChosenCounter { .. }
- | Effect::Unimplemented { .. } => {}
+ _ => {}
}
}
+fn walk_ability_def(def: &AbilityDefinition, out: &mut Vec) {
+ let _ = visit_ability_def(def, &mut |effect| {
+ collect_conjure_names(effect, out);
+ ControlFlow::Continue(())
+ });
+}
+
+fn walk_trigger(trigger: &TriggerDefinition, out: &mut Vec) {
+ let _ = visit_trigger(trigger, &mut |effect| {
+ collect_conjure_names(effect, out);
+ ControlFlow::Continue(())
+ });
+}
+
+fn walk_replacement(replacement: &ReplacementDefinition, out: &mut Vec) {
+ let _ = visit_replacement(replacement, &mut |effect| {
+ collect_conjure_names(effect, out);
+ ControlFlow::Continue(())
+ });
+}
+
+fn walk_static(static_def: &StaticDefinition, out: &mut Vec) {
+ let _ = visit_static(static_def, &mut |effect| {
+ collect_conjure_names(effect, out);
+ ControlFlow::Continue(())
+ });
+}
+
+#[cfg(test)]
+fn walk_effect(effect: &Effect, out: &mut Vec) {
+ let _ = visit_effect(effect, &mut |e| {
+ collect_conjure_names(e, out);
+ ControlFlow::Continue(())
+ });
+}
+
/// Collect every conjure name seeded by the faces present in the game: each
/// object's printed face (resolved via the database) plus every deck-pool face
/// (carried inline as `DeckEntry.card`).
diff --git a/crates/engine/src/types/ability_visit.rs b/crates/engine/src/types/ability_visit.rs
new file mode 100644
index 0000000000..432416a88a
--- /dev/null
+++ b/crates/engine/src/types/ability_visit.rs
@@ -0,0 +1,638 @@
+//! The engine's single complete `AbilityDefinition` / `Effect` traversal.
+//!
+//! This code was moved verbatim out of `game/printed_cards.rs`, where it was
+//! specialized to conjure-name collection, and parameterized by a visitor
+//! closure so any "can this ability tree contain effect shape X" question can
+//! reuse it. The name-extraction leaf stayed behind in `printed_cards.rs`.
+//!
+//! The `match`es over `Effect`, `ContinuousModification`, and `AbilityCost` are
+//! wildcard-free **on purpose**: a new variant on any of those three enums is a
+//! compile error here, which forces a descend-or-leaf decision at the one place
+//! that owns the answer.
+//!
+//! That guarantee is necessary but not sufficient. A new nested **struct field**
+//! is field access, not a match arm, so it compiles silently. Two fixtures are
+//! the complementary safety nets:
+//!
+//! - `game::printed_cards::tests::walker_covers_every_nested_carrier`
+//! - `ai_support::targeted_exchange::tests::predicate_sees_a_fight_in_every_nested_carrier`
+//!
+//! Both plant a marker effect in every carrier this module descends into.
+//! Extend **both** whenever a carrier is added.
+//!
+//! Two narrower ad-hoc walkers remain unmigrated and are candidate future
+//! consumers: `game::coverage::ability_tree_any` (which has a `_ => {}`
+//! wildcard and omits many carriers — broadening it would change the coverage
+//! report) and `game::replacement::ability_tree_creates_tokens` (which walks
+//! only `Token` / `ChooseOneOf` / `sub_ability` / `else_ability` — broadening it
+//! would change replacement behavior). Neither is migrated here, because either
+//! migration would change behavior.
+
+use crate::types::ability::{
+ AbilityCost, AbilityDefinition, ContinuousModification, CopiableValues, CounterSourceRider,
+ Effect, ReplacementDefinition, ReplacementMode, StaticDefinition, TriggerDefinition,
+ VoteSubject,
+};
+use std::ops::ControlFlow;
+
+pub fn visit_ability_def(def: &AbilityDefinition, visit: &mut F) -> ControlFlow<()>
+where
+ F: FnMut(&Effect) -> ControlFlow<()>,
+{
+ visit_effect(&def.effect, visit)?;
+ if let Some(cost) = &def.cost {
+ visit_cost(cost, visit)?;
+ }
+ if let Some(sub) = &def.sub_ability {
+ visit_ability_def(sub, visit)?;
+ }
+ if let Some(else_ability) = &def.else_ability {
+ visit_ability_def(else_ability, visit)?;
+ }
+ for mode in &def.mode_abilities {
+ visit_ability_def(mode, visit)?;
+ }
+ // "unless [player] pays {cost}" — the cost may be an EffectCost that conjures.
+ if let Some(unless_pay) = &def.unless_pay {
+ visit_cost(&unless_pay.cost, visit)?;
+ }
+ ControlFlow::Continue(())
+}
+
+pub fn visit_trigger(trigger: &TriggerDefinition, visit: &mut F) -> ControlFlow<()>
+where
+ F: FnMut(&Effect) -> ControlFlow<()>,
+{
+ if let Some(execute) = &trigger.execute {
+ visit_ability_def(execute, visit)?;
+ }
+ if let Some(unless_pay) = &trigger.unless_pay {
+ visit_cost(&unless_pay.cost, visit)?;
+ }
+ ControlFlow::Continue(())
+}
+
+pub fn visit_replacement(replacement: &ReplacementDefinition, visit: &mut F) -> ControlFlow<()>
+where
+ F: FnMut(&Effect) -> ControlFlow<()>,
+{
+ if let Some(execute) = &replacement.execute {
+ visit_ability_def(execute, visit)?;
+ }
+ // The mode carries the decline continuation (and, for MayCost, a cost),
+ // either of which may conjure. Descend into both.
+ match &replacement.mode {
+ ReplacementMode::MayCost { cost, decline } => {
+ visit_cost(cost, visit)?;
+ if let Some(decline) = decline {
+ visit_ability_def(decline, visit)?;
+ }
+ }
+ ReplacementMode::Optional { decline } => {
+ if let Some(decline) = decline {
+ visit_ability_def(decline, visit)?;
+ }
+ }
+ ReplacementMode::Mandatory => {}
+ }
+ // `runtime_execute` holds a resolution-time continuation that is never
+ // present on a printed/static `CardFace`; skipped intentionally.
+ ControlFlow::Continue(())
+}
+
+pub fn visit_static(static_def: &StaticDefinition, visit: &mut F) -> ControlFlow<()>
+where
+ F: FnMut(&Effect) -> ControlFlow<()>,
+{
+ for modification in &static_def.modifications {
+ visit_continuous_mod(modification, visit)?;
+ }
+ ControlFlow::Continue(())
+}
+
+pub fn visit_continuous_mod(
+ modification: &ContinuousModification,
+ visit: &mut F,
+) -> ControlFlow<()>
+where
+ F: FnMut(&Effect) -> ControlFlow<()>,
+{
+ match modification {
+ ContinuousModification::GrantAbility { definition } => {
+ visit_ability_def(definition, visit)?
+ }
+ ContinuousModification::GrantTrigger { trigger } => visit_trigger(trigger, visit)?,
+ ContinuousModification::GrantReplacement { replacement } => {
+ visit_replacement(replacement, visit)?
+ }
+ ContinuousModification::GrantStaticAbility { definition } => {
+ visit_static(definition, visit)?
+ }
+ ContinuousModification::CopyValues { values, .. } => {
+ visit_copiable_values(values, visit)?
+ }
+ // Remaining modifications carry no nested ability/effect carriers.
+ // GrantAllActivatedAbilitiesOf / GrantAllTriggeredAbilitiesOf only hold a
+ // source `TargetFilter`; the granted abilities/triggers are pulled live
+ // from the provider objects at layer collection time, not nested here.
+ ContinuousModification::GrantAllActivatedAbilitiesOf { .. }
+ | ContinuousModification::GrantAllTriggeredAbilitiesOf { .. }
+ // CR 707.2c (Metamorphic Alteration): inert parse-time copy marker — no
+ // nested ability/effect carrier to walk (the copy grant is the runtime TCE).
+ | ContinuousModification::CopyChosen
+ | ContinuousModification::SetName { .. }
+ | ContinuousModification::SetTextName { .. }
+ | ContinuousModification::AddPower { .. }
+ | ContinuousModification::AddToughness { .. }
+ | ContinuousModification::SetPower { .. }
+ | ContinuousModification::SetToughness { .. }
+ | ContinuousModification::AddKeyword { .. }
+ | ContinuousModification::AddKeywordWithDerivedCost { .. }
+ | ContinuousModification::RemoveKeyword { .. }
+ | ContinuousModification::RemoveAllAbilities
+ | ContinuousModification::AddType { .. }
+ | ContinuousModification::RemoveType { .. }
+ | ContinuousModification::AddSubtype { .. }
+ | ContinuousModification::RemoveSubtype { .. }
+ | ContinuousModification::SetCardTypes { .. }
+ | ContinuousModification::RemoveAllSubtypes { .. }
+ | ContinuousModification::SetDynamicPower { .. }
+ | ContinuousModification::SetDynamicToughness { .. }
+ | ContinuousModification::SetPowerDynamic { .. }
+ | ContinuousModification::SetToughnessDynamic { .. }
+ | ContinuousModification::AddDynamicPower { .. }
+ | ContinuousModification::AddDynamicToughness { .. }
+ | ContinuousModification::AddDynamicKeyword { .. }
+ | ContinuousModification::AddAllCreatureTypes
+ | ContinuousModification::AddAllBasicLandTypes
+ | ContinuousModification::AddAllLandTypes
+ | ContinuousModification::AddChosenSubtype { .. }
+ | ContinuousModification::AddChosenColor { .. }
+ | ContinuousModification::RemoveChosenKeyword
+ | ContinuousModification::AddChosenKeyword
+ | ContinuousModification::SetColor { .. }
+ | ContinuousModification::AddColor { .. }
+ | ContinuousModification::AddStaticMode { .. }
+ | ContinuousModification::SwitchPowerToughness
+ | ContinuousModification::AssignDamageFromToughness
+ | ContinuousModification::AssignDamageAsThoughUnblocked
+ | ContinuousModification::AssignNoCombatDamage
+ | ContinuousModification::ChangeController
+ | ContinuousModification::SetBasicLandType { .. }
+ | ContinuousModification::SetChosenBasicLandType
+ | ContinuousModification::SetChosenName
+ | ContinuousModification::RetainPrintedTriggerFromSource { .. }
+ | ContinuousModification::RetainPrintedAbilityFromSource { .. }
+ | ContinuousModification::RetainAllOtherAbilitiesFromSource
+ | ContinuousModification::AddSupertype { .. }
+ | ContinuousModification::RemoveSupertype { .. }
+ | ContinuousModification::AddCounterOnEnter { .. }
+ | ContinuousModification::SetStartingLoyalty { .. }
+ | ContinuousModification::RemoveManaCost => {}
+ }
+ ControlFlow::Continue(())
+}
+
+pub fn visit_copiable_values(values: &CopiableValues, visit: &mut F) -> ControlFlow<()>
+where
+ F: FnMut(&Effect) -> ControlFlow<()>,
+{
+ for ability in values.abilities.iter() {
+ visit_ability_def(ability, visit)?;
+ }
+ for trigger in values.trigger_definitions.iter() {
+ visit_trigger(trigger, visit)?;
+ }
+ for static_def in values.static_definitions.iter() {
+ visit_static(static_def, visit)?;
+ }
+ for replacement in values.replacement_definitions.iter() {
+ visit_replacement(replacement, visit)?;
+ }
+ ControlFlow::Continue(())
+}
+
+pub fn visit_cost(cost: &AbilityCost, visit: &mut F) -> ControlFlow<()>
+where
+ F: FnMut(&Effect) -> ControlFlow<()>,
+{
+ match cost {
+ AbilityCost::EffectCost { effect } => visit_effect(effect, visit)?,
+ AbilityCost::Composite { costs } | AbilityCost::OneOf { costs } => {
+ for sub in costs {
+ visit_cost(sub, visit)?;
+ }
+ }
+ AbilityCost::PerCounter { base, .. } => visit_cost(base, visit)?,
+ // Remaining costs carry no nested effect/cost carriers.
+ AbilityCost::Mana { .. }
+ | AbilityCost::ManaDynamic { .. }
+ | AbilityCost::Tap
+ | AbilityCost::Untap
+ | AbilityCost::Loyalty { .. }
+ | AbilityCost::Sacrifice(_)
+ | AbilityCost::PayLife { .. }
+ | AbilityCost::Discard { .. }
+ | AbilityCost::Exile { .. }
+ | AbilityCost::ExileMaterials { .. }
+ | AbilityCost::CollectEvidence { .. }
+ | AbilityCost::ExileWithAggregate { .. }
+ | AbilityCost::TapCreatures { .. }
+ | AbilityCost::RemoveCounter { .. }
+ | AbilityCost::PayEnergy { .. }
+ | AbilityCost::PaySpeed { .. }
+ | AbilityCost::ReturnToHand { .. }
+ | AbilityCost::Unattach
+ | AbilityCost::UnattachFrom { .. }
+ | AbilityCost::Mill { .. }
+ | AbilityCost::Exert
+ | AbilityCost::Blight { .. }
+ | AbilityCost::Reveal { .. }
+ | AbilityCost::Behold { .. }
+ | AbilityCost::Waterbend { .. }
+ | AbilityCost::NinjutsuFamily { .. }
+ // CR 118.9: a borrowed keyword cost carries no nested effect/cost carrier.
+ | AbilityCost::KeywordCostOfCastSpell { .. }
+ | AbilityCost::Unimplemented { .. } => {}
+ }
+ ControlFlow::Continue(())
+}
+
+/// Visit `effect` and every effect reachable from its nested ability/effect
+/// carriers, pre-order, stopping early on `ControlFlow::Break`. The match is
+/// wildcard-free, so a new `Effect` variant forces a decision here (compile
+/// error until handled). That guarantee is necessary but not sufficient: a
+/// variant wrongly added to the leaf arm, or a new nested *struct field* (which
+/// is field access, not a match arm), compiles silently.
+/// `printed_cards::tests::walker_covers_every_nested_carrier` and
+/// `ai_support::targeted_exchange::tests::predicate_sees_a_fight_in_every_nested_carrier`
+/// are the complementary safety nets for those cases — extend both whenever a
+/// carrier is added.
+pub fn visit_effect(effect: &Effect, visit: &mut F) -> ControlFlow<()>
+where
+ F: FnMut(&Effect) -> ControlFlow<()>,
+{
+ visit(effect)?;
+ match effect {
+ Effect::Intensify { .. } => {}
+ Effect::ApplyPerpetual { .. } => {}
+ // CR 614.11: A one-shot draw replacement nests its substitute Effect
+ // (Words of Worship/Wilding). Walk it so any conjure name it carries is
+ // surfaced (GainLife/Token carry none today, but it is a nested carrier).
+ Effect::CreateDrawReplacement { replacement_effect } => {
+ visit_effect(replacement_effect, visit)?
+ }
+ // CR 614.1a: A planeswalk replacement nests its substitute Effect (Fixed
+ // Point in Time: chaos ensues). Walk it so any conjure name it carries is
+ // surfaced (ChaosEnsues carries none today, but it is a nested carrier).
+ Effect::CreatePlaneswalkReplacement { replacement_effect } => {
+ visit_effect(replacement_effect, visit)?
+ }
+ // Heist exiles a card from an opponent's library at random; it does not
+ // name a conjure card, so there is no static face to preload.
+ Effect::Heist { .. } | Effect::HeistExile => {}
+ // Carries no nested ability/effect carrier. Only named-conjure has a
+ // static card name to extract, and that extraction now lives in the
+ // caller's visitor closure (`printed_cards::collect_conjure_names`).
+ Effect::Conjure { .. } => {}
+ // CR 701.42 / CR 712.4b: the melded permanent presents the `result`
+ // card's characteristics, but `result` is an outside-the-game third card.
+ // Its name is extracted by the caller's visitor closure
+ // (`printed_cards::collect_conjure_names`), which seeds it so
+ // `build_conjure_registry` preloads its `CardFace` into
+ // `card_face_registry`. `source` and `partner` are live battlefield
+ // objects the resolver finds by printed identity — they need no registry
+ // seeding, and neither field is a nested ability/effect carrier.
+ Effect::Meld { .. } => {}
+ // A spellbook draft conjures the chosen card, but the list lives on the
+ // card face (`metadata.spellbook`), not in the effect — the registry
+ // seed collects it directly from the face (see
+ // `collect_conjure_names_from_face`), so nothing to gather here.
+ Effect::DraftFromSpellbook { .. } => {}
+ Effect::TurnFaceUp { .. } => {}
+ Effect::TurnFaceDown { .. } => {}
+ // Nested-ability carriers — descend.
+ Effect::Vote {
+ per_choice_effect,
+ subject,
+ ..
+ } => {
+ for sub in per_choice_effect {
+ visit_ability_def(sub, visit)?;
+ }
+ // CR 701.38b: object-pool votes (Council's Judgment, Prime
+ // Minister's Cabinet Room) leave `per_choice_effect` empty and
+ // carry the sole nested AbilityDefinition in `outcome_template`.
+ // Walk it so any conjure name a future object-vote outcome names is
+ // surfaced (the current exile-only class carries none).
+ if let VoteSubject::Objects {
+ outcome_template, ..
+ } = subject
+ {
+ visit_ability_def(outcome_template, visit)?;
+ }
+ }
+ Effect::SeparateIntoPiles {
+ chosen_pile_effect,
+ unchosen_pile_effect,
+ ..
+ } => {
+ visit_ability_def(chosen_pile_effect, visit)?;
+ if let Some(unchosen) = unchosen_pile_effect {
+ visit_ability_def(unchosen, visit)?;
+ }
+ }
+ Effect::RevealFromHand { on_decline, .. } => {
+ if let Some(sub) = on_decline {
+ visit_ability_def(sub, visit)?;
+ }
+ }
+ // Only the delayed `effect` is walked; the `condition`'s embedded
+ // TriggerDefinition has `execute: None` by construction (it is a matcher,
+ // not a payload), so it carries no conjure name.
+ Effect::CreateDelayedTrigger { effect, .. } => visit_ability_def(effect, visit)?,
+ Effect::FlipCoin {
+ win_effect,
+ lose_effect,
+ ..
+ }
+ | Effect::FlipCoins {
+ win_effect,
+ lose_effect,
+ ..
+ } => {
+ if let Some(sub) = win_effect {
+ visit_ability_def(sub, visit)?;
+ }
+ if let Some(sub) = lose_effect {
+ visit_ability_def(sub, visit)?;
+ }
+ }
+ Effect::FlipCoinUntilLose { win_effect } => visit_ability_def(win_effect, visit)?,
+ Effect::RollDie { results, .. } => {
+ for branch in results {
+ visit_ability_def(&branch.effect, visit)?;
+ }
+ }
+ Effect::ChooseOneOf { branches, .. } => {
+ for branch in branches {
+ visit_ability_def(branch, visit)?;
+ }
+ }
+ // GenericEffect applies static abilities at resolution; their
+ // modifications can grant abilities/triggers that themselves conjure.
+ // Descend into the granted definitions rather than treating it as a leaf.
+ Effect::GenericEffect {
+ static_abilities, ..
+ } => {
+ for static_def in static_abilities {
+ visit_static(static_def, visit)?;
+ }
+ }
+ // Carries a nested ReplacementDefinition whose execute/decline/cost may conjure.
+ Effect::AddTargetReplacement { replacement, .. } => visit_replacement(replacement, visit)?,
+ // Counter's `source_rider` may apply a static to the countered source
+ // (LosesAbilities) that grants an ability that conjures. The Destroy
+ // rider carries no static.
+ Effect::Counter { source_rider, .. } => {
+ if let Some(CounterSourceRider::LosesAbilities { static_def, .. }) = source_rider {
+ visit_static(static_def, visit)?;
+ }
+ }
+ // Tokens and emblems can host granted static/triggered abilities that conjure.
+ Effect::Token {
+ static_abilities, ..
+ } => {
+ for static_def in static_abilities {
+ visit_static(static_def, visit)?;
+ }
+ }
+ Effect::CreateEmblem { statics, triggers } => {
+ for static_def in statics {
+ visit_static(static_def, visit)?;
+ }
+ for trigger in triggers {
+ visit_trigger(trigger, visit)?;
+ }
+ }
+ // Leaf effects with no nested ability/effect carrier.
+ Effect::StartYourEngines { .. }
+ | Effect::ChangeSpeed { .. }
+ | Effect::DealDamage { .. }
+ | Effect::ApplyPostReplacementDamage { .. }
+ // CR 120.1: leaf effect — the source/recipient filters carry no nested
+ // ability or effect to walk.
+ | Effect::EachDealsDamageEqualToPower { .. }
+ | Effect::EachSourceDealsDamage { .. }
+ | Effect::Draw { .. }
+ | Effect::Pump { .. }
+ | Effect::PairWith { .. }
+ | Effect::Destroy { .. }
+ | Effect::Regenerate { .. }
+ | Effect::RemoveAllDamage { .. }
+ | Effect::CounterAll { .. }
+ | Effect::GainLife { .. }
+ | Effect::LoseLife { .. }
+ | Effect::ExchangeLifeWithStat { .. }
+ | Effect::ExchangeLifeTotals { .. }
+ // CR 701.26a/b: all tap/untap scopes are leaf effects here.
+ | Effect::SetTapState { .. }
+ | Effect::RemoveCounter { .. }
+ | Effect::Sacrifice { .. }
+ | Effect::DiscardCard { .. }
+ | Effect::Mill { .. }
+ | Effect::Scry { .. }
+ | Effect::PumpAll { .. }
+ | Effect::DamageAll { .. }
+ | Effect::DamageEachPlayer { .. }
+ | Effect::DestroyAll { .. }
+ | Effect::ChangeZone { .. }
+ | Effect::ChangeZoneAll { .. }
+ | Effect::Dig { .. }
+ | Effect::GainControl { .. }
+ | Effect::GainControlAll { .. }
+ | Effect::ControlNextTurn { .. }
+ | Effect::Attach { .. }
+ | Effect::UnattachAll { .. }
+ | Effect::Surveil { .. }
+ | Effect::Fight { .. }
+ | Effect::Bounce { .. }
+ | Effect::BounceAll { .. }
+ | Effect::Explore
+ | Effect::ExploreAll { .. }
+ | Effect::Investigate
+ | Effect::Tribute { .. }
+ | Effect::TimeTravel
+ | Effect::BecomeMonarch
+ | Effect::NoOp
+ | Effect::Proliferate
+ | Effect::ProliferateTarget { .. }
+ | Effect::EndTheTurn
+ | Effect::EndCombatPhase
+ | Effect::Populate
+ | Effect::Clash
+ | Effect::Behold { .. }
+ | Effect::SwitchPT { .. }
+ | Effect::CopySpell { .. }
+ | Effect::EpicCopy { .. }
+ | Effect::CastCopyOfCard { .. }
+ | Effect::CopyTokenOf { .. }
+ // owner/type_filter are TargetFilters; no nested ability carrier and the
+ // copy source comes from the format pool, so this is a leaf for conjure
+ // collection.
+ | Effect::CreateTokenCopyFromPool { .. }
+ | Effect::Myriad
+ | Effect::Encore
+ | Effect::ExileHaunting { .. }
+ | Effect::HideawayConceal { .. }
+ | Effect::CopyTokenBlockingAttacker { .. }
+ | Effect::BecomeCopy { .. }
+ // CR 707.2c (Metamorphic Alteration): filter-only copy choice; no nested
+ // ability carrier to walk — a leaf for printed-card collection.
+ | Effect::ChoosePermanent { .. }
+ | Effect::GainActivatedAbilitiesOfTarget { .. }
+ | Effect::ChooseCard { .. }
+ | Effect::PutCounter { .. }
+ | Effect::PutCounterAll { .. }
+ | Effect::MultiplyCounter { .. }
+ // Builds its PutCounter/RemoveCounter branches at resolution — carries no
+ // static conjure name to preload.
+ | Effect::ChooseCounterAdjustment { .. }
+ | Effect::DoublePT { .. }
+ | Effect::DoublePTAll { .. }
+ | Effect::MoveCounters { .. }
+ | Effect::Animate { .. }
+ | Effect::RegisterBending { .. }
+ | Effect::Cleanup { .. }
+ | Effect::Mana { .. }
+ | Effect::Discard { .. }
+ | Effect::Shuffle { .. }
+ | Effect::Transform { .. }
+ // CR 710.4: no nested ability carrier and no conjured card name.
+ | Effect::FlipPermanent { .. }
+ | Effect::SearchLibrary { .. }
+ | Effect::SearchOutsideGame { .. }
+ | Effect::RevealHand { .. }
+ | Effect::Reveal { .. }
+ | Effect::RevealTop { .. }
+ | Effect::ExileTop { .. }
+ | Effect::ExileFaceDownPile { .. }
+ | Effect::TargetOnly { .. }
+ | Effect::Choose { .. }
+ | Effect::OpponentGuess { .. }
+ | Effect::SwapChosenLabels { .. }
+ | Effect::ChooseDamageSource { .. }
+ | Effect::Suspect { .. }
+ | Effect::Unsuspect { .. }
+ | Effect::Connive { .. }
+ | Effect::PhaseOut { .. }
+ | Effect::PhaseIn { .. }
+ | Effect::ForceBlock { .. }
+ | Effect::ForceAttack { .. }
+ | Effect::SolveCase
+ | Effect::BecomePrepared { .. }
+ | Effect::BecomeUnprepared { .. }
+ | Effect::BecomeSaddled { .. }
+ | Effect::BecomeBlocked { .. }
+ | Effect::SetClassLevel { .. }
+ | Effect::AddRestriction { .. }
+ | Effect::ReduceNextSpellCost { .. }
+ | Effect::GrantNextSpellAbility { .. }
+ | Effect::AddPendingETBCounters { .. }
+ | Effect::AddPendingEntersModifications { .. }
+ | Effect::PayCost { .. }
+ | Effect::CastFromZone { .. }
+ | Effect::FreeCastFromZones { .. }
+ | Effect::ExileResolvingSpellInsteadOfGraveyard { .. }
+ | Effect::PreventDamage { .. }
+ | Effect::LoseTheGame { .. }
+ | Effect::WinTheGame { .. }
+ | Effect::RingTemptsYou
+ | Effect::VentureIntoDungeon
+ | Effect::VentureInto { .. }
+ | Effect::TakeTheInitiative
+ | Effect::ArrangePlanarDeckTop { .. }
+ | Effect::Planeswalk
+ | Effect::ChaosEnsues
+ | Effect::RedistributeLifeTotals
+ | Effect::ReverseTurnOrder
+ | Effect::OpenAttractions { .. }
+ | Effect::RollToVisitAttractions
+ | Effect::AssembleContraptions { .. }
+ | Effect::AssembleContraptionsFromRollDifference
+ | Effect::CrankContraptions { .. }
+ | Effect::ReassembleContraption { .. }
+ | Effect::AssembleContraptionOnSprocket { .. }
+ | Effect::ReassembleContraptionOnSprocket { .. }
+ | Effect::PutSticker { .. }
+ | Effect::ApplySticker { .. }
+ | Effect::ProcessRadCounters
+ | Effect::GrantCastingPermission { .. }
+ | Effect::ChooseFromZone { .. }
+ | Effect::RememberCard { .. }
+ | Effect::ForEachCategory { .. }
+ | Effect::ChooseObjectsIntoTrackedSet { .. }
+ | Effect::ChooseAndSacrificeRest { .. }
+ | Effect::EachPlayerCopyChosen { .. }
+ | Effect::Exploit { .. }
+ | Effect::GainEnergy { .. }
+ | Effect::GivePlayerCounter { .. }
+ | Effect::LoseAllPlayerCounters { .. }
+ | Effect::ExileFromTopUntil { .. }
+ | Effect::RevealUntil { .. }
+ | Effect::Discover { .. }
+ | Effect::Cascade
+ | Effect::Ripple { .. }
+ | Effect::MiracleCast { .. }
+ | Effect::MadnessCast { .. }
+ | Effect::PutAtLibraryPosition { .. }
+ | Effect::ChooseDrawnThisTurnPayOrTopdeck { .. }
+ | Effect::PutOnTopOrBottom { .. }
+ | Effect::GiftDelivery { .. }
+ | Effect::Goad { .. }
+ | Effect::GoadAll { .. }
+ | Effect::Detain { .. }
+ | Effect::SetRoomDoorLock { .. }
+ | Effect::ExchangeControl { .. }
+ | Effect::ChangeTargets { .. }
+ | Effect::Manifest { .. }
+ | Effect::ManifestDread
+ | Effect::Cloak { .. }
+ | Effect::ExtraTurn { .. }
+ | Effect::GrantExtraLoyaltyActivations { .. }
+ | Effect::SkipNextTurn { .. }
+ | Effect::SkipNextStep { .. }
+ | Effect::AdditionalPhase { .. }
+ | Effect::Double { .. }
+ | Effect::RuntimeHandled { .. }
+ | Effect::Incubate { .. }
+ | Effect::Amass { .. }
+ | Effect::Monstrosity { .. }
+ | Effect::Renown { .. }
+ | Effect::Bolster { .. }
+ | Effect::Adapt { .. }
+ | Effect::Learn
+ | Effect::Forage
+ | Effect::Harness
+ | Effect::CollectEvidence { .. }
+ | Effect::Endure { .. }
+ | Effect::BlightEffect { .. }
+ | Effect::Seek { .. }
+ | Effect::SetLifeTotal { .. }
+ | Effect::SetDayNight { .. }
+ | Effect::GiveControl { .. }
+ | Effect::RemoveFromCombat { .. }
+ | Effect::CreateDamageReplacement { .. }
+ | Effect::CombineHost { .. }
+ | Effect::ChooseAugmentAndCombineWithHost { .. }
+ // CR 614.12 + CR 303.4: ReturnAsAura.grants carry typed
+ // ContinuousModifications, never conjured card names.
+ | Effect::ReturnAsAura { .. }
+ | Effect::Specialize
+ // CR 608.2d + CR 122.1: counter-kind choice / consume carry no conjure names.
+ | Effect::ChooseCounterKind { .. }
+ | Effect::PutChosenCounter { .. }
+ | Effect::Unimplemented { .. } => {}
+ }
+ ControlFlow::Continue(())
+}
diff --git a/crates/engine/src/types/mod.rs b/crates/engine/src/types/mod.rs
index e124edd592..bf87dd9820 100644
--- a/crates/engine/src/types/mod.rs
+++ b/crates/engine/src/types/mod.rs
@@ -1,4 +1,5 @@
pub mod ability;
+pub mod ability_visit;
pub mod action_stable_order;
pub mod actions;
pub mod attribution;
diff --git a/crates/engine/tests/integration/main.rs b/crates/engine/tests/integration/main.rs
index 355ebc8005..a35edf1dc5 100644
--- a/crates/engine/tests/integration/main.rs
+++ b/crates/engine/tests/integration/main.rs
@@ -913,6 +913,7 @@ mod swans_prevention_followup;
mod swarm_combat_witness;
mod tales_of_the_ancestors_catch_up_draw;
mod talon_gates_from_hand_activation;
+mod targeted_exchange_preview_budget;
mod tchaka_venerable_king;
mod teamwork_aggregate_legal_actions;
mod teamwork_origin_composition;
diff --git a/crates/engine/tests/integration/targeted_exchange_preview_budget.rs b/crates/engine/tests/integration/targeted_exchange_preview_budget.rs
new file mode 100644
index 0000000000..37d4b39c62
--- /dev/null
+++ b/crates/engine/tests/integration/targeted_exchange_preview_budget.rs
@@ -0,0 +1,186 @@
+//! The bounded-witness budget for one `targeted_exchange_verdict` call.
+//!
+//! R1 is the row that proves the clone-free precondition actually elides work:
+//! a root whose source carries no adverse-exchange effect shape must cost zero
+//! candidate enumerations and zero reducer replays. P1 and P2 are its permanent
+//! positive reach-guards — without them the zero could be a blanket-disabled
+//! preview rather than a precondition that fired.
+
+#[cfg(feature = "test-support")]
+use engine::ai_support::{
+ targeted_exchange_verdict_with_budget, validated_candidate_actions_for_semantic_owner,
+ CandidateAction, TargetedExchangeBudget, TargetedExchangeVerdict,
+};
+#[cfg(feature = "test-support")]
+use engine::game::layers::{evaluate_layers, flush_layers};
+#[cfg(feature = "test-support")]
+use engine::game::scenario::{GameScenario, P0, P1};
+#[cfg(feature = "test-support")]
+use engine::types::actions::GameAction;
+#[cfg(feature = "test-support")]
+use engine::types::game_state::GameState;
+#[cfg(feature = "test-support")]
+use engine::types::identifiers::ObjectId;
+#[cfg(feature = "test-support")]
+use engine::types::mana::{ManaCost, ManaCostShard, ManaType, ManaUnit};
+#[cfg(feature = "test-support")]
+use engine::types::phase::Phase;
+
+/// Verbatim Oracle text, copied from `self_destruct_target_power.rs:18-19`.
+#[cfg(feature = "test-support")]
+const SELF_DESTRUCT_ORACLE: &str =
+ "Target creature you control deals X damage to any other target and X damage to itself, where X is its power.";
+
+/// Verbatim Oracle text for a root carrying no adverse-exchange shape.
+#[cfg(feature = "test-support")]
+const LIGHTNING_BOLT_ORACLE: &str = "Lightning Bolt deals 3 damage to any target.";
+
+/// Verbatim Oracle text for the Fight class (Prey Upon).
+#[cfg(feature = "test-support")]
+const PREY_UPON_ORACLE: &str =
+ "Target creature you control fights target creature you don't control.";
+
+/// Build a two-player pre-combat main phase board, put `oracle` into P0's hand
+/// as a one-red-mana sorcery, and hand back the state plus the spell's id.
+#[cfg(feature = "test-support")]
+fn board(
+ name: &str,
+ oracle: &str,
+ ai_pt: (i32, i32),
+ enemy_pt: (i32, i32),
+) -> (GameState, ObjectId) {
+ let mut scenario = GameScenario::new_n_player(2, 42);
+ scenario.at_phase(Phase::PreCombatMain);
+ scenario.add_vanilla(P0, ai_pt.0, ai_pt.1);
+ scenario.add_vanilla(P1, enemy_pt.0, enemy_pt.1);
+ let spell = scenario
+ .add_spell_to_hand_from_oracle(P0, name, false, oracle)
+ .with_mana_cost(ManaCost::Cost {
+ shards: vec![ManaCostShard::Red],
+ generic: 0,
+ })
+ .id();
+ let mut runner = scenario.build();
+ runner.state_mut().layers_dirty.mark_full();
+ evaluate_layers(runner.state_mut());
+ runner
+ .state_mut()
+ .players
+ .iter_mut()
+ .find(|player| player.id == P0)
+ .expect("P0 exists")
+ .mana_pool
+ .add(ManaUnit::new(ManaType::Red, ObjectId(0), false, vec![]));
+ let mut state = runner.state().clone();
+ // R1's third reach-guard (and every row's precondition): the guard's first
+ // rail is `layers_dirty.is_dirty() => return true`, which SPENDS the budget.
+ // A `GameScenario` that puts permanents onto the battlefield leaves an
+ // `EnteredObjects` mark, so flush before measuring.
+ flush_layers(&mut state);
+ (state, spell)
+}
+
+/// Recover the engine-issued root cast candidate for `spell`, exactly as
+/// `search.rs` does.
+#[cfg(feature = "test-support")]
+fn root_candidate(state: &GameState, spell: ObjectId) -> CandidateAction {
+ validated_candidate_actions_for_semantic_owner(state, P0)
+ .into_iter()
+ .find(|candidate| {
+ matches!(candidate.action, GameAction::CastSpell { object_id, .. } if object_id == spell)
+ })
+ .expect("the engine must issue the root cast candidate")
+}
+
+/// Assert the three reach-guards R1 depends on, so a zero budget can never be
+/// vacuous: `replay_exact_candidate` bails on a missing semantic owner or actor
+/// before spending anything, and a dirty lattice spends the budget through the
+/// guard's own fall-open rail.
+#[cfg(feature = "test-support")]
+fn assert_reach_guards(state: &GameState, root: &CandidateAction) {
+ assert!(
+ root.metadata.semantic_owner.is_some(),
+ "reach guard: without a semantic owner `replay_exact_candidate` early-returns and the zero budget is vacuous"
+ );
+ assert!(
+ root.metadata.actor.is_some(),
+ "reach guard: without an actor `replay_exact_candidate` early-returns and the zero budget is vacuous"
+ );
+ assert!(
+ !state.layers_dirty.is_dirty(),
+ "reach guard: a dirty lattice makes the guard fall open and SPEND the budget — assumption A must hold in this fixture"
+ );
+}
+
+/// R1 — a root with no adverse-exchange shape costs zero replay and zero
+/// enumeration. This is the row that measures the fix.
+#[cfg(feature = "test-support")]
+#[test]
+fn budget_is_zero_for_a_root_without_an_adverse_exchange_shape() {
+ let (state, spell) = board("Lightning Bolt", LIGHTNING_BOLT_ORACLE, (2, 2), (3, 3));
+ let root = root_candidate(&state, spell);
+ assert_reach_guards(&state, &root);
+
+ let (verdict, budget) = targeted_exchange_verdict_with_budget(&state, &root);
+ assert_eq!(verdict, TargetedExchangeVerdict::Indeterminate);
+ assert_eq!(
+ budget,
+ TargetedExchangeBudget::default(),
+ "the clone-free precondition must elide BOTH the candidate enumeration and the reducer replay for a root that provably cannot be rejected"
+ );
+}
+
+/// P1 — positive reach-guard for R1: the guard did not blanket-disable the
+/// preview. Target-sourced self damage, 2/2 source against a 3/3 recipient.
+#[cfg(feature = "test-support")]
+#[test]
+fn budget_is_spent_and_verdict_rejects_for_a_target_sourced_self_damage_root() {
+ let (state, spell) = board("Self-Destruct", SELF_DESTRUCT_ORACLE, (2, 2), (3, 3));
+ let root = root_candidate(&state, spell);
+ assert_reach_guards(&state, &root);
+
+ let (verdict, budget) = targeted_exchange_verdict_with_budget(&state, &root);
+ assert_eq!(
+ verdict,
+ TargetedExchangeVerdict::Reject,
+ "the 2/2 source dies to the 3/3 recipient's damage while the recipient survives"
+ );
+ assert!(
+ budget.candidate_enumerations >= 1,
+ "the preview must still run its candidate enumeration for a shape-bearing root"
+ );
+ assert!(
+ budget.replay_clone_applies >= 1,
+ "the preview must still clone-and-apply for a shape-bearing root"
+ );
+ assert!(
+ budget.preview_clone_resolves >= 1,
+ "the preview must still resolve the bound exchange for a shape-bearing root"
+ );
+ assert!(budget.nodes >= 1, "C2: the node cap must still be charged");
+ assert!(
+ budget.branches <= 16,
+ "C2: the branch cap must still bound exploration"
+ );
+}
+
+/// P2 — positive reach-guard, Fight class. AI 2/2 against an enemy 3/3.
+#[cfg(feature = "test-support")]
+#[test]
+fn budget_is_spent_for_a_fight_root() {
+ let (state, spell) = board("Prey Upon", PREY_UPON_ORACLE, (2, 2), (3, 3));
+ let root = root_candidate(&state, spell);
+ assert_reach_guards(&state, &root);
+
+ let (verdict, budget) = targeted_exchange_verdict_with_budget(&state, &root);
+ assert_eq!(
+ verdict,
+ TargetedExchangeVerdict::Reject,
+ "the AI's 2/2 dies to the 3/3 it fights while the 3/3 survives"
+ );
+ assert!(
+ budget.replay_clone_applies >= 1,
+ "the Fight arm of the leaf shape test must let the preview run"
+ );
+ assert!(budget.nodes >= 1, "C2: the node cap must still be charged");
+}
diff --git a/crates/phase-ai/src/search.rs b/crates/phase-ai/src/search.rs
index c495af6055..c894a20bac 100644
--- a/crates/phase-ai/src/search.rs
+++ b/crates/phase-ai/src/search.rs
@@ -6,8 +6,8 @@ use rand_chacha::ChaCha20Rng;
use engine::ai_support::{
build_decision_context, certify_fetch_then_cast, certify_pact_plan, is_pact_payment_cast,
- targeted_exchange_verdict, validated_candidate_actions_for_semantic_owner, AiDecisionContract,
- TargetedExchangeVerdict,
+ root_may_yield_adverse_exchange, targeted_exchange_verdict,
+ validated_candidate_actions_for_semantic_owner, AiDecisionContract, TargetedExchangeVerdict,
};
use engine::types::ability::{
AbilityDefinition, ContinuousModification, Duration, Effect, ResolvedAbility, StaticDefinition,
@@ -529,9 +529,20 @@ fn fast_priority_action(
action.filter(|_| !has_certified_fetch_then_cast_route(state, ai_player))
}
-/// Keep direct priority shortcuts under the same pre-cast exchange gate as the
-/// scored candidate pipeline. The engine candidate is recovered by semantic
-/// owner so replay keeps its authenticated actor instead of fabricating one.
+/// Keep the direct priority shortcuts under the pre-cast exchange gate. The
+/// engine candidate is recovered by semantic owner so replay keeps its
+/// authenticated actor instead of fabricating one.
+///
+/// The engine's clone-free precondition runs FIRST: a root whose source carries
+/// no adverse-exchange effect shape can never be rejected, so recovering its
+/// candidate — a full `validated_candidate_actions_for_semantic_owner` pass,
+/// with a `GameState` clone per candidate the cheap filters decline — is pure
+/// cost. This gate is invoked once per action from a filter over the whole
+/// priority list, so the recovery must stay behind the precondition or the pass
+/// count is quadratic in the number of castable roots. The reordering is
+/// behavior-identical: with the same precondition inside
+/// `targeted_exchange_verdict`, the old path returned `true` on every root this
+/// one short-circuits.
fn root_action_is_allowed(state: &GameState, ai_player: PlayerId, action: &GameAction) -> bool {
if !matches!(
action,
@@ -539,6 +550,9 @@ fn root_action_is_allowed(state: &GameState, ai_player: PlayerId, action: &GameA
) {
return true;
}
+ if !root_may_yield_adverse_exchange(state, action) {
+ return true;
+ }
validated_candidate_actions_for_semantic_owner(state, ai_player)
.into_iter()
.find(|candidate| candidate.action.cmp_stable(action).is_eq())