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