From cc62f5dd8df3ff7b4ff815fbbfd2ff1c1dc00f0f Mon Sep 17 00:00:00 2001 From: minion1227 Date: Thu, 23 Jul 2026 04:43:23 -0700 Subject: [PATCH 1/4] feat(phase-ai): add graveyard type-diversity axis + GraveyardTypesPolicy MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit CR 207.2c lists delirium, descend and threshold as ABILITY WORDS with no rules meaning — the mechanical content is entirely the underlying "N or more card types among cards in your graveyard" condition (CR 205.2a). 95 cards in the corpus read that quantity, and nothing in the AI modelled the graveyard's type spread: a self-mill that supplied the fourth distinct card type, switching every delirium payoff on, scored exactly the same as one that added a redundant fifth creature. Feature (`features/graveyard_types.rs`) — three structural axes, no card names: * threshold payoffs: a `QuantityComparison` condition whose lhs is `DistinctCardTypes { Zone { Graveyard, Controller } }`. `StaticDefinition` and `TriggerDefinition` carry DISTINCT condition enums that happen to share the shape, so each gets its own extractor (Backwoods Survivalists puts the clause on the static, Autumnal Gloom on the trigger) * scaling payoffs: the same quantity read as a dynamic magnitude with no threshold (Consuming Blob, Tarmogoyf). A threshold payoff is deliberately NOT also counted here, or delirium cards would be double-weighted * enablers: Mill / DiscardCard / Discard / Surveil scoped to the controller. An opponent-scoped mill is excluded — filling their graveyard does nothing for this deck's threshold `highest_threshold` is tracked so a descend 8 deck does not think it is done at four card types. Commitment is a geometric mean over (payoff, enabler): unlike the poison axis, BOTH pillars are mandatory. Payoffs with no enablers never turn on reliably and enablers with no payoff are just self-mill — neither is this archetype, and both collapse to 0.0. Calibrated to a Modern delirium shell (8 threshold + 2 scaling + 8 enablers over 37 nonland) > 0.85, with a lone-Tarmogoyf anti-calibration below the floor. Policy scores CastSpell + ActivateAbility by threshold deficit — strong band when the action supplies the LAST missing type, scaled down as the deficit grows. Once the count is at or above the deck's highest threshold the branch scores zero: every payoff is already live, so more diversity buys nothing, and scoring it would make the AI durdle with self-mill after delirium is on. Predicate order is deliberate for the search inner loop: the card-local AST check runs first and rejects almost every candidate; only a confirmed graveyard-filler pays for the graveyard scan, which walks one zone and never touches the battlefield, mana affordability, or find_legal_targets. `graveyard_types_progress` is registered UNTUNED pending a paired-seed calibration. Boundary with `reanimator`: that axis detects graveyard RECURSION TARGETS — what is worth bringing back. This one measures the graveyard's TYPE SPREAD. A graveyard of four creatures is excellent for reanimator and useless for delirium; the axes are independent by design. Co-Authored-By: Claude Opus 4.8 (1M context) --- crates/phase-ai/src/config.rs | 10 + .../phase-ai/src/features/graveyard_types.rs | 329 ++++++++++++++++++ crates/phase-ai/src/features/mod.rs | 5 + .../src/features/tests/graveyard_types.rs | 292 ++++++++++++++++ crates/phase-ai/src/features/tests/mod.rs | 1 + .../phase-ai/src/policies/graveyard_types.rs | 143 ++++++++ crates/phase-ai/src/policies/mod.rs | 1 + crates/phase-ai/src/policies/registry.rs | 4 + .../src/policies/tests/graveyard_types.rs | 37 ++ crates/phase-ai/src/policies/tests/mod.rs | 1 + 10 files changed, 823 insertions(+) create mode 100644 crates/phase-ai/src/features/graveyard_types.rs create mode 100644 crates/phase-ai/src/features/tests/graveyard_types.rs create mode 100644 crates/phase-ai/src/policies/graveyard_types.rs create mode 100644 crates/phase-ai/src/policies/tests/graveyard_types.rs diff --git a/crates/phase-ai/src/config.rs b/crates/phase-ai/src/config.rs index 049149279c..646459c38b 100644 --- a/crates/phase-ai/src/config.rs +++ b/crates/phase-ai/src/config.rs @@ -463,6 +463,10 @@ pub struct PolicyPenalties { /// scalar.) #[serde(default = "default_loop_shortcut_winning_declare_bonus")] pub loop_shortcut_winning_declare_bonus: f64, + /// CR 205.2a: card-equivalent weight for advancing graveyard card-type + /// diversity toward a delirium/descend threshold. Strong band when the + /// action supplies the last missing type. + pub graveyard_types_progress: f64, } impl Default for PolicyPenalties { @@ -527,6 +531,7 @@ impl Default for PolicyPenalties { cycling_patience_penalty: default_cycling_patience_penalty(), cycling_needed_land_penalty: default_cycling_needed_land_penalty(), loop_shortcut_winning_declare_bonus: default_loop_shortcut_winning_declare_bonus(), + graveyard_types_progress: 2.5, } } } @@ -724,6 +729,11 @@ pub const ACTIVE_POLICY_PENALTY_FIELDS: &[&str] = &[ /// Policy penalties intentionally not present in an active CMA-ES parameter /// vector yet. pub const UNTUNED_POLICY_PENALTY_FIELDS: &[(&str, &str)] = &[ + ( + "graveyard_types_progress", + "CR 205.2a delirium-threshold progress weight — awaiting a paired-seed \ + ai-gate calibration before promotion to ACTIVE.", + ), ( "artifact_cost_payoff_bonus", "new ArtifactSynergyPolicy knob; awaiting a paired-seed ai-gate calibration before joining the CMA-ES vector", diff --git a/crates/phase-ai/src/features/graveyard_types.rs b/crates/phase-ai/src/features/graveyard_types.rs new file mode 100644 index 0000000000..040ded4931 --- /dev/null +++ b/crates/phase-ai/src/features/graveyard_types.rs @@ -0,0 +1,329 @@ +//! Graveyard card-type-diversity feature — the delirium / descend / Goyf axis. +//! +//! Parser AST verification — VERIFIED against engine source: +//! - `QuantityRef::DistinctCardTypes { source: CardTypeSetSource }` at +//! `crates/engine/src/types/ability.rs:5565` (CR 205.2a card types). +//! - `CardTypeSetSource::Zone { zone: ZoneRef, scope: CountScope }` at +//! `crates/engine/src/types/ability.rs:5272` (CR 109.2a + CR 400.1) — the +//! graveyard scoping used by every card in this class. +//! - Threshold payoffs carry `StaticCondition::QuantityComparison { lhs, comparator, +//! rhs }` whose `lhs` is that quantity (Backwoods Survivalists, Autumnal Gloom). +//! - Scaling payoffs read the same quantity as a dynamic magnitude with no +//! threshold at all (Consuming Blob's `SetDynamicPower`). +//! - Enablers: `Effect::Mill { target, destination }` (`ability.rs:10102`), +//! `Effect::DiscardCard { target }` (`:10096`), `Effect::Discard { target }` +//! (`:11134`), `Effect::Surveil { target }` (`:10377`). +//! +//! No parser remediation required. +//! +//! ## Why this axis exists +//! +//! CR 207.2c lists delirium, descend, threshold and undergrowth as **ability +//! words** — they have no rules meaning, so the mechanical content is entirely +//! the underlying "N or more card types among cards in your graveyard" +//! condition. 95 cards in the corpus read that quantity, and nothing in the AI +//! modelled graveyard type-diversity as a resource: a self-mill that turns a +//! delirium payoff on scored exactly the same as one that did not. +//! +//! ## Boundary with `reanimator` +//! +//! `reanimator` detects graveyard *recursion targets* — what is worth bringing +//! back. This axis measures the *type spread* of the graveyard, which is a +//! different resource: a graveyard of four creatures is excellent for +//! reanimator and useless for delirium. The axes are independent by design. + +use engine::game::DeckEntry; +use engine::types::ability::{ + AbilityDefinition, CardTypeSetSource, ControllerRef, CountScope, Effect, QuantityExpr, + QuantityRef, StaticCondition, TargetFilter, TriggerCondition, ZoneRef, +}; +use engine::types::card_type::CoreType; + +use crate::ability_chain::collect_chain_effects; +use crate::features::commitment; + +/// Commitment at or above which graveyard type-diversity is a real plan rather +/// than an incidental Goyf. Gates `GraveyardTypesPolicy::activation`. +pub const GRAVEYARD_TYPES_FLOOR: f32 = 0.35; + +/// CR 205.2a: the delirium threshold printed on essentially every card in this +/// class. Used only as the fallback when a payoff's threshold cannot be read. +pub const DEFAULT_DELIRIUM_THRESHOLD: u32 = 4; + +/// CR 207.2c + CR 205.2a: per-deck graveyard type-diversity classification. +/// +/// Detection is structural over `CardFace.static_abilities`, `.triggers` and +/// `.abilities` — never by card name. +#[derive(Debug, Clone, Default)] +pub struct GraveyardTypesFeature { + /// Payoffs gated on a threshold ("four or more card types among cards in + /// your graveyard") — delirium, descend N, threshold. + pub threshold_payoff_count: u32, + /// Payoffs that scale continuously with the count and have no threshold + /// (Consuming Blob, Tarmogoyf-likes). + pub scaling_payoff_count: u32, + /// Cards that put cards into the controller's own graveyard — self-mill, + /// self-discard, surveil (CR 701.17 / CR 701.25). + pub enabler_count: u32, + /// The highest threshold any payoff in the deck asks for. A descend 8 deck + /// must not think it is finished at four card types. + pub highest_threshold: u32, + /// `0.0..=1.0` — how central the axis is. Consumed by + /// `GraveyardTypesPolicy::activation` as the single scaling knob. + pub commitment: f32, + /// Names of detected payoffs. NOT used for classification — that already + /// happened against the AST. Identity lookup only. + pub payoff_names: Vec, +} + +/// Structural detection over each `DeckEntry`'s `CardFace` AST. +pub fn detect(deck: &[DeckEntry]) -> GraveyardTypesFeature { + if deck.is_empty() { + return GraveyardTypesFeature::default(); + } + + let mut threshold_payoff_count = 0u32; + let mut scaling_payoff_count = 0u32; + let mut enabler_count = 0u32; + let mut highest_threshold = 0u32; + let mut total_nonland = 0u32; + let mut payoff_names: Vec = Vec::new(); + + for entry in deck { + let face = &entry.card; + if !face.card_type.core_types.contains(&CoreType::Land) { + total_nonland = total_nonland.saturating_add(entry.count); + } + + // `StaticDefinition.condition` and `TriggerDefinition.condition` are + // DISTINCT enums that happen to share the `QuantityComparison` shape, + // so each gets its own extractor rather than a forced conversion. + let threshold = face + .static_abilities + .iter() + .filter_map(|def| def.condition.as_ref()) + .filter_map(static_graveyard_type_threshold) + .chain( + face.triggers + .iter() + .filter_map(|t| t.condition.as_ref()) + .filter_map(trigger_graveyard_type_threshold), + ) + .max(); + + let scales = face_reads_graveyard_types(face); + + if let Some(threshold) = threshold { + threshold_payoff_count = threshold_payoff_count.saturating_add(entry.count); + highest_threshold = highest_threshold.max(threshold); + } else if scales { + // Only a payoff with NO threshold is a scaling payoff — otherwise a + // delirium card would be counted on both axes. + scaling_payoff_count = scaling_payoff_count.saturating_add(entry.count); + } + // One push per UNIQUE face, and once even when both axes could fire. + if threshold.is_some() || scales { + payoff_names.push(face.name.clone()); + } + + if fills_own_graveyard_parts(&face.abilities) { + enabler_count = enabler_count.saturating_add(entry.count); + } + } + + let commitment = compute_commitment( + threshold_payoff_count, + scaling_payoff_count, + enabler_count, + total_nonland, + ); + + GraveyardTypesFeature { + threshold_payoff_count, + scaling_payoff_count, + enabler_count, + highest_threshold: if highest_threshold == 0 { + DEFAULT_DELIRIUM_THRESHOLD + } else { + highest_threshold + }, + commitment, + payoff_names, + } +} + +/// Calibration: a Modern delirium shell (8 threshold payoffs + 2 scaling +/// payoffs + 8 enablers over 37 nonland) → commitment ≈ 0.90. +/// Anti-calibration: a deck running one incidental Tarmogoyf and no enablers → +/// well below `GRAVEYARD_TYPES_FLOOR`; UW control → 0.0. +/// +/// Geometric mean over (payoff, enabler): unlike poison, BOTH pillars are +/// mandatory here. Payoffs with no enablers never turn on reliably, and +/// enablers with no payoff are just self-mill — neither is this archetype. +fn compute_commitment( + threshold_payoff_count: u32, + scaling_payoff_count: u32, + enabler_count: u32, + total_nonland: u32, +) -> f32 { + let payoff_density = commitment::weighted_sum(&[ + ( + 1.0 / 8.0, + commitment::density_per_60(threshold_payoff_count, total_nonland), + ), + // A scaling payoff wants a big graveyard but never strands, so it is a + // weaker signal of intent than a threshold payoff. + ( + 0.5 / 8.0, + commitment::density_per_60(scaling_payoff_count, total_nonland), + ), + ]); + let enabler_density = + (commitment::density_per_60(enabler_count, total_nonland) / 10.0).min(1.0); + + commitment::geometric_mean(&[payoff_density, enabler_density]) +} + +/// CR 205.2a: read the threshold N out of a `QuantityComparison` condition +/// whose left side counts distinct card types in the controller's graveyard. +/// +/// Returns `None` for any other condition shape, and for an opponent-scoped +/// count — a card that punishes an OPPONENT's diverse graveyard is not a +/// payoff for this deck's own plan. +fn static_graveyard_type_threshold(condition: &StaticCondition) -> Option { + match condition { + StaticCondition::QuantityComparison { lhs, rhs, .. } => { + if !quantity_reads_own_graveyard_types(lhs) { + return None; + } + match rhs { + QuantityExpr::Fixed { value } if *value > 0 => Some(*value as u32), + _ => None, + } + } + // CR 109.3: a conjunction gates on every constraint, so a delirium + // clause nested in an `And`/`Or` still identifies the payoff. + StaticCondition::And { conditions } | StaticCondition::Or { conditions } => conditions + .iter() + .filter_map(static_graveyard_type_threshold) + .max(), + StaticCondition::Not { condition } => static_graveyard_type_threshold(condition), + _ => None, + } +} + +/// CR 205.2a: the `TriggerCondition` twin of [`static_graveyard_type_threshold`] +/// — Autumnal Gloom carries its delirium clause on the trigger, not the static. +fn trigger_graveyard_type_threshold(condition: &TriggerCondition) -> Option { + match condition { + TriggerCondition::QuantityComparison { lhs, rhs, .. } => { + if !quantity_reads_own_graveyard_types(lhs) { + return None; + } + match rhs { + QuantityExpr::Fixed { value } if *value > 0 => Some(*value as u32), + _ => None, + } + } + TriggerCondition::Not { condition } => trigger_graveyard_type_threshold(condition), + _ => None, + } +} + +/// True when a `QuantityExpr` reads distinct card types in the controller's +/// own graveyard, at any nesting depth (Consuming Blob wraps it in `Offset`). +fn quantity_reads_own_graveyard_types(expr: &QuantityExpr) -> bool { + match expr { + QuantityExpr::Ref { qty } => matches!( + qty, + QuantityRef::DistinctCardTypes { + source: CardTypeSetSource::Zone { + zone: ZoneRef::Graveyard, + scope: CountScope::Controller | CountScope::All, + }, + } + ), + QuantityExpr::Fixed { .. } => false, + QuantityExpr::DivideRounded { inner, .. } + | QuantityExpr::Offset { inner, .. } + | QuantityExpr::ClampMin { inner, .. } + | QuantityExpr::Multiply { inner, .. } => quantity_reads_own_graveyard_types(inner), + QuantityExpr::UpTo { max } => quantity_reads_own_graveyard_types(max), + QuantityExpr::Power { exponent, .. } => quantity_reads_own_graveyard_types(exponent), + QuantityExpr::Difference { left, right } => { + quantity_reads_own_graveyard_types(left) || quantity_reads_own_graveyard_types(right) + } + QuantityExpr::Sum { exprs } | QuantityExpr::Max { exprs } => { + exprs.iter().any(quantity_reads_own_graveyard_types) + } + } +} + +/// True when any continuous modification or effect on the face scales off the +/// graveyard type count (Consuming Blob's `SetDynamicPower`). +fn face_reads_graveyard_types(face: &engine::types::card::CardFace) -> bool { + let in_statics = face.static_abilities.iter().any(|def| { + def.modifications + .iter() + .filter_map(crate::features::graveyard_types::modification_quantity) + .any(quantity_reads_own_graveyard_types) + }); + in_statics +} + +/// The dynamic magnitude carried by a continuous modification, if any. Mirrors +/// `game::quantity::continuous_modification_dynamic_quantity`. +pub(crate) fn modification_quantity( + m: &engine::types::ability::ContinuousModification, +) -> Option<&QuantityExpr> { + use engine::types::ability::ContinuousModification as CM; + match m { + CM::SetDynamicPower { value } + | CM::SetDynamicToughness { value } + | CM::SetPowerDynamic { value } + | CM::SetToughnessDynamic { value } + | CM::AddDynamicPower { value } + | CM::AddDynamicToughness { value } + | CM::AddDynamicKeyword { value, .. } => Some(value), + _ => None, + } +} + +/// CR 701.17 + CR 701.25 + CR 404.1: an ability chain that puts cards into the +/// CONTROLLER's own graveyard — self-mill, self-discard, or surveil. +/// +/// An opponent-scoped mill is deliberately excluded: filling an opponent's +/// graveyard does nothing for this deck's threshold (and actively helps a +/// Goyf-style symmetric count, which this axis does not chase). +pub(crate) fn fills_own_graveyard_parts(abilities: &[AbilityDefinition]) -> bool { + abilities.iter().any(|ability| { + collect_chain_effects(ability) + .iter() + .any(|effect| match effect { + Effect::Mill { + target, + destination, + .. + } => { + *destination == engine::types::zones::Zone::Graveyard + && filter_is_controller_scoped(target) + } + Effect::DiscardCard { target, .. } => filter_is_controller_scoped(target), + Effect::Discard { target, .. } => filter_is_controller_scoped(target), + Effect::Surveil { target, .. } => filter_is_controller_scoped(target), + _ => false, + }) + }) +} + +/// True when a `TargetFilter` resolves to the ability's own controller. +fn filter_is_controller_scoped(filter: &TargetFilter) -> bool { + match filter { + TargetFilter::Controller | TargetFilter::SelfRef => true, + TargetFilter::Typed(typed) => matches!(typed.controller, Some(ControllerRef::You)), + TargetFilter::Or { filters } => filters.iter().any(filter_is_controller_scoped), + // CR 109.3: every constraint of a conjunction must hold. + TargetFilter::And { filters } => filters.iter().all(filter_is_controller_scoped), + _ => false, + } +} diff --git a/crates/phase-ai/src/features/mod.rs b/crates/phase-ai/src/features/mod.rs index a0430f971a..3b81df52c7 100644 --- a/crates/phase-ai/src/features/mod.rs +++ b/crates/phase-ai/src/features/mod.rs @@ -15,6 +15,7 @@ pub mod control; pub mod enchantments; pub mod energy; pub mod equipment; +pub mod graveyard_types; pub mod landfall; pub mod lifegain; pub mod mana_ramp; @@ -36,6 +37,7 @@ pub use control::ControlFeature; pub use enchantments::EnchantmentsFeature; pub use energy::EnergyFeature; pub use equipment::EquipmentFeature; +pub use graveyard_types::GraveyardTypesFeature; pub use landfall::LandfallFeature; pub use lifegain::LifegainFeature; pub use mana_ramp::ManaRampFeature; @@ -78,6 +80,8 @@ pub struct DeckFeatures { pub reanimator: ReanimatorFeature, pub mill: MillFeature, pub energy: EnergyFeature, + /// CR 207.2c + CR 205.2a: delirium / descend graveyard type-diversity. + pub graveyard_types: GraveyardTypesFeature, /// Declaration-derived: the deck's declared bracket tier. Unlike the /// other fields here, this is not structurally detected from card text — /// it is a per-deck declaration set at deck-analysis time from deck @@ -122,6 +126,7 @@ impl DeckFeatures { reanimator: reanimator::detect(deck), mill: mill::detect(deck), energy: energy::detect(deck), + graveyard_types: graveyard_types::detect(deck), bracket_tier: tier, } } diff --git a/crates/phase-ai/src/features/tests/graveyard_types.rs b/crates/phase-ai/src/features/tests/graveyard_types.rs new file mode 100644 index 0000000000..9c9e3ca5d4 --- /dev/null +++ b/crates/phase-ai/src/features/tests/graveyard_types.rs @@ -0,0 +1,292 @@ +//! Unit tests for `features::graveyard_types` — structural detection + +//! calibration anchors for the delirium / descend / Goyf axis. No +//! `#[cfg(test)]` in SOURCE files; tests live here. + +use engine::game::DeckEntry; +use engine::types::ability::{ + AbilityDefinition, AbilityKind, CardTypeSetSource, Comparator, ContinuousModification, + ControllerRef, CountScope, Effect, QuantityExpr, QuantityRef, StaticCondition, + StaticDefinition, TargetFilter, TriggerCondition, TriggerDefinition, TypedFilter, ZoneRef, +}; +use engine::types::card::CardFace; +use engine::types::card_type::{CardType, CoreType}; +use engine::types::triggers::TriggerMode; +use engine::types::zones::Zone; + +use crate::features::graveyard_types::*; + +fn creature(name: &str) -> CardFace { + CardFace { + name: name.to_string(), + card_type: CardType { + supertypes: Vec::new(), + core_types: vec![CoreType::Creature], + subtypes: Vec::new(), + }, + ..Default::default() + } +} + +fn entry(card: CardFace, count: u32) -> DeckEntry { + DeckEntry { card, count } +} + +/// CR 205.2a: distinct card types among cards in the controller's graveyard. +fn own_graveyard_types() -> QuantityExpr { + QuantityExpr::Ref { + qty: QuantityRef::DistinctCardTypes { + source: CardTypeSetSource::Zone { + zone: ZoneRef::Graveyard, + scope: CountScope::Controller, + }, + }, + } +} + +fn opponent_graveyard_types() -> QuantityExpr { + QuantityExpr::Ref { + qty: QuantityRef::DistinctCardTypes { + source: CardTypeSetSource::Zone { + zone: ZoneRef::Graveyard, + scope: CountScope::Opponents, + }, + }, + } +} + +/// Backwoods Survivalists shape: a static gated on "four or more card types". +fn threshold_payoff(name: &str, threshold: i32, lhs: QuantityExpr) -> CardFace { + let mut face = creature(name); + face.static_abilities = vec![StaticDefinition::continuous() + .affected(TargetFilter::SelfRef) + .modifications(vec![ContinuousModification::AddPower { value: 1 }]) + .condition(StaticCondition::QuantityComparison { + lhs, + comparator: Comparator::GE, + rhs: QuantityExpr::Fixed { value: threshold }, + })]; + face +} + +/// Autumnal Gloom shape: the delirium clause rides the TRIGGER, not the static. +fn trigger_threshold_payoff(name: &str, threshold: i32) -> CardFace { + let mut face = creature(name); + face.triggers = vec![TriggerDefinition::new(TriggerMode::Phase).condition( + TriggerCondition::QuantityComparison { + lhs: own_graveyard_types(), + comparator: Comparator::GE, + rhs: QuantityExpr::Fixed { value: threshold }, + }, + )]; + face +} + +/// Consuming Blob / Tarmogoyf shape: scales continuously, no threshold. +fn scaling_payoff(name: &str) -> CardFace { + let mut face = creature(name); + face.static_abilities = vec![StaticDefinition::continuous() + .affected(TargetFilter::SelfRef) + .modifications(vec![ContinuousModification::SetDynamicPower { + value: own_graveyard_types(), + }])]; + face +} + +fn self_mill_enabler(name: &str) -> CardFace { + let mut face = creature(name); + face.abilities = vec![AbilityDefinition::new( + AbilityKind::Activated, + Effect::Mill { + count: QuantityExpr::Fixed { value: 1 }, + target: TargetFilter::Controller, + destination: Zone::Graveyard, + }, + )]; + face +} + +#[test] +fn empty_deck_produces_defaults() { + let feature = detect(&[]); + assert_eq!(feature.threshold_payoff_count, 0); + assert_eq!(feature.commitment, 0.0); + assert!(feature.payoff_names.is_empty()); +} + +#[test] +fn vanilla_creature_not_registered() { + let feature = detect(&[entry(creature("Grizzly Bears"), 4)]); + assert_eq!(feature.threshold_payoff_count, 0); + assert_eq!(feature.scaling_payoff_count, 0); + assert_eq!(feature.commitment, 0.0); +} + +#[test] +fn detects_static_threshold_payoff() { + let feature = detect(&[entry( + threshold_payoff("Backwoods Survivalists", 4, own_graveyard_types()), + 4, + )]); + assert_eq!(feature.threshold_payoff_count, 4); + assert_eq!(feature.highest_threshold, 4); +} + +#[test] +fn detects_trigger_threshold_payoff() { + let feature = detect(&[entry(trigger_threshold_payoff("Autumnal Gloom", 4), 4)]); + assert_eq!(feature.threshold_payoff_count, 4); +} + +#[test] +fn detects_scaling_payoff() { + let feature = detect(&[entry(scaling_payoff("Consuming Blob"), 2)]); + assert_eq!(feature.scaling_payoff_count, 2); + assert_eq!(feature.threshold_payoff_count, 0); +} + +/// A delirium card must land on the threshold axis only — counting it as a +/// scaling payoff too would double-weight it in the commitment formula. +#[test] +fn threshold_payoff_not_double_counted_as_scaling() { + let mut face = threshold_payoff("Hybrid", 4, own_graveyard_types()); + face.static_abilities[0].modifications = vec![ContinuousModification::SetDynamicPower { + value: own_graveyard_types(), + }]; + let feature = detect(&[entry(face, 4)]); + assert_eq!(feature.threshold_payoff_count, 4); + assert_eq!(feature.scaling_payoff_count, 0); +} + +/// A card punishing an OPPONENT's diverse graveyard is not a payoff for this +/// deck's own plan. +#[test] +fn opponent_scoped_graveyard_count_ignored() { + let feature = detect(&[entry( + threshold_payoff("Punisher", 4, opponent_graveyard_types()), + 4, + )]); + assert_eq!(feature.threshold_payoff_count, 0); +} + +#[test] +fn descend_eight_tracks_highest_threshold() { + let deck = vec![ + entry( + threshold_payoff("Delirium Four", 4, own_graveyard_types()), + 2, + ), + entry( + threshold_payoff("Descend Eight", 8, own_graveyard_types()), + 2, + ), + ]; + assert_eq!(detect(&deck).highest_threshold, 8); +} + +#[test] +fn detects_self_mill_enabler() { + let feature = detect(&[entry(self_mill_enabler("Stitcher's Supplier"), 4)]); + assert_eq!(feature.enabler_count, 4); +} + +/// Filling an OPPONENT's graveyard does nothing for this deck's threshold. +#[test] +fn opponent_mill_not_an_enabler() { + let mut face = creature("Opponent Mill"); + face.abilities = vec![AbilityDefinition::new( + AbilityKind::Activated, + Effect::Mill { + count: QuantityExpr::Fixed { value: 3 }, + target: TargetFilter::Typed( + TypedFilter::creature().controller(ControllerRef::Opponent), + ), + destination: Zone::Graveyard, + }, + )]; + let feature = detect(&[entry(face, 4)]); + assert_eq!(feature.enabler_count, 0); +} + +#[test] +fn payoff_names_dedup_per_face() { + let feature = detect(&[entry( + threshold_payoff("Backwoods Survivalists", 4, own_graveyard_types()), + 4, + )]); + assert_eq!( + feature.payoff_names, + vec!["Backwoods Survivalists".to_string()] + ); +} + +/// Calibration anchor: a Modern delirium shell — 8 threshold payoffs + +/// 2 scaling payoffs + 8 enablers over 37 nonland. +#[test] +fn delirium_shell_hits_calibration_floor() { + let deck = vec![ + entry( + threshold_payoff("Backwoods Survivalists", 4, own_graveyard_types()), + 4, + ), + entry(threshold_payoff("Grim Flayer", 4, own_graveyard_types()), 4), + entry(scaling_payoff("Tarmogoyf"), 2), + entry(self_mill_enabler("Stitcher's Supplier"), 4), + entry(self_mill_enabler("Thought Scour"), 4), + entry(creature("Filler"), 19), + ]; + let feature = detect(&deck); + assert_eq!(feature.threshold_payoff_count, 8); + assert_eq!(feature.enabler_count, 8); + assert!( + feature.commitment > 0.85, + "delirium shell must clear 0.85, got {}", + feature.commitment + ); +} + +/// Anti-calibration: an incidental Goyf with no enablers is not this archetype. +#[test] +fn lone_goyf_without_enablers_below_floor() { + let deck = vec![ + entry(scaling_payoff("Tarmogoyf"), 4), + entry(creature("Filler"), 33), + ]; + let feature = detect(&deck); + assert!( + feature.commitment < GRAVEYARD_TYPES_FLOOR, + "a lone Goyf is not a delirium deck, got {}", + feature.commitment + ); +} + +/// Geometric mean: payoffs with zero enablers collapse to 0.0 — the payoff +/// never turns on reliably, so the axis is not this deck's plan. +#[test] +fn payoffs_without_enablers_collapse() { + let deck = vec![ + entry( + threshold_payoff("Backwoods Survivalists", 4, own_graveyard_types()), + 8, + ), + entry(creature("Filler"), 29), + ]; + assert_eq!(detect(&deck).commitment, 0.0); +} + +/// And the mirror: enablers with no payoff are just self-mill. +#[test] +fn enablers_without_payoffs_collapse() { + let deck = vec![ + entry(self_mill_enabler("Thought Scour"), 8), + entry(creature("Filler"), 29), + ]; + assert_eq!(detect(&deck).commitment, 0.0); +} + +#[test] +fn control_deck_below_floor() { + assert_eq!( + detect(&[entry(creature("Counterspell"), 37)]).commitment, + 0.0 + ); +} diff --git a/crates/phase-ai/src/features/tests/mod.rs b/crates/phase-ai/src/features/tests/mod.rs index 5e2c7e1e87..ee2aa32a90 100644 --- a/crates/phase-ai/src/features/tests/mod.rs +++ b/crates/phase-ai/src/features/tests/mod.rs @@ -6,6 +6,7 @@ pub mod blink; pub mod enchantments; pub mod energy; pub mod equipment; +pub mod graveyard_types; pub mod lifegain; pub mod mill; pub mod no_name_matching; diff --git a/crates/phase-ai/src/policies/graveyard_types.rs b/crates/phase-ai/src/policies/graveyard_types.rs new file mode 100644 index 0000000000..574203bfb6 --- /dev/null +++ b/crates/phase-ai/src/policies/graveyard_types.rs @@ -0,0 +1,143 @@ +//! `GraveyardTypesPolicy` — makes graveyard card-type diversity a resource the +//! AI can see (delirium / descend / threshold). +//! +//! ## The defect this closes +//! +//! CR 207.2c lists delirium, descend and threshold as **ability words** with no +//! rules meaning — the mechanical content is entirely the underlying "N or more +//! card types among cards in your graveyard" condition (CR 205.2a). 95 cards in +//! the corpus read that quantity, and nothing in the AI modelled the graveyard's +//! type spread. A self-mill that put the fourth distinct card type into the +//! graveyard — switching every delirium payoff on — scored exactly the same as +//! one that put in a redundant fifth creature. +//! +//! ## Why the threshold-met branch scores zero +//! +//! Once the count is at or above the deck's highest threshold, every payoff is +//! already live and additional diversity buys nothing on this axis. Scoring it +//! anyway would make the AI keep durdling with self-mill after delirium is on, +//! which is exactly the failure mode this policy exists to avoid. Same +//! no-progress-no-score backbone as `PoisonClockPolicy`, different resource. +//! +//! ## Performance +//! +//! `verdict()` runs per candidate per search node, so predicate order matters. +//! The card-local AST check (`fills_own_graveyard_parts` over the candidate's +//! own abilities) runs FIRST and rejects the overwhelming majority of +//! candidates; only a confirmed graveyard-filler pays for the graveyard scan, +//! which walks one zone's objects and never touches the battlefield, mana +//! affordability, or `find_legal_targets`. + +use std::collections::HashSet; + +use engine::types::actions::GameAction; +use engine::types::card_type::CoreType; +use engine::types::game_state::GameState; +use engine::types::player::PlayerId; +use engine::types::zones::Zone; + +use crate::features::graveyard_types::{fills_own_graveyard_parts, GRAVEYARD_TYPES_FLOOR}; +use crate::features::DeckFeatures; + +use super::context::PolicyContext; +use super::registry::{DecisionKind, PolicyId, PolicyReason, PolicyVerdict, TacticalPolicy}; + +pub struct GraveyardTypesPolicy; + +/// CR 404.1 + CR 205.2a: how many distinct card types sit in this player's +/// graveyard. Uses `owner`, not `controller` — control is a battlefield notion +/// and a card in a graveyard belongs to its owner. +pub(crate) fn distinct_graveyard_types(state: &GameState, player: PlayerId) -> u32 { + let mut seen: HashSet = HashSet::new(); + for object in state.objects.values() { + if object.zone != Zone::Graveyard || object.owner != player { + continue; + } + for core_type in &object.card_types.core_types { + seen.insert(*core_type); + } + } + seen.len() as u32 +} + +impl TacticalPolicy for GraveyardTypesPolicy { + fn id(&self) -> PolicyId { + PolicyId::GraveyardTypes + } + + fn decision_kinds(&self) -> &'static [DecisionKind] { + &[DecisionKind::CastSpell, DecisionKind::ActivateAbility] + } + + fn activation( + &self, + features: &DeckFeatures, + _state: &GameState, + _player: PlayerId, + ) -> Option { + if features.graveyard_types.commitment < GRAVEYARD_TYPES_FLOOR { + None + } else { + Some(features.graveyard_types.commitment) + } + } + + fn verdict(&self, ctx: &PolicyContext<'_>) -> PolicyVerdict { + // Cheapest discriminator first: a card-local AST walk over just this + // candidate's abilities. Everything below is gated behind it. + let abilities = match &ctx.candidate.action { + GameAction::CastSpell { object_id, .. } => ctx + .state + .objects + .get(object_id) + .map(|obj| obj.abilities.as_slice()), + GameAction::ActivateAbility { + source_id, + ability_index, + } => ctx + .state + .objects + .get(source_id) + .and_then(|obj| obj.abilities.get(*ability_index)) + .map(std::slice::from_ref), + _ => None, + }; + let Some(abilities) = abilities else { + return PolicyVerdict::neutral(PolicyReason::new("graveyard_types_na")); + }; + if !fills_own_graveyard_parts(abilities) { + return PolicyVerdict::neutral(PolicyReason::new("graveyard_types_na")); + } + + let threshold = ctx + .context + .session + .features + .get(&ctx.ai_player) + .map(|f| f.graveyard_types.highest_threshold) + .unwrap_or(0); + let current = distinct_graveyard_types(ctx.state, ctx.ai_player); + let scalar = ctx.config.policy_penalties.graveyard_types_progress; + + // Every payoff is already live — more diversity buys nothing here. + if threshold == 0 || current >= threshold { + return PolicyVerdict::neutral( + PolicyReason::new("graveyard_types_threshold_met") + .with_fact("graveyard_types", current as i64), + ); + } + + let deficit = threshold - current; + let reason = PolicyReason::new("graveyard_types_progress") + .with_fact("graveyard_types", current as i64) + .with_fact("deficit", deficit as i64); + + // The last missing type is worth far more than the first: it is what + // actually switches the payoffs on. + if deficit == 1 { + PolicyVerdict::strong(scalar, reason) + } else { + PolicyVerdict::preference(scalar / f64::from(deficit), reason) + } + } +} diff --git a/crates/phase-ai/src/policies/mod.rs b/crates/phase-ai/src/policies/mod.rs index 237ab920e2..0894b40764 100644 --- a/crates/phase-ai/src/policies/mod.rs +++ b/crates/phase-ai/src/policies/mod.rs @@ -22,6 +22,7 @@ mod etb_value; mod evasion_removal_priority; mod fetch_land_patience; mod free_outlet_activation; +mod graveyard_types; pub(crate) mod hand_disruption; mod hold_mana_up; mod interaction_reservation; diff --git a/crates/phase-ai/src/policies/registry.rs b/crates/phase-ai/src/policies/registry.rs index ab6796c3eb..d585b516f8 100644 --- a/crates/phase-ai/src/policies/registry.rs +++ b/crates/phase-ai/src/policies/registry.rs @@ -16,6 +16,7 @@ use super::etb_value::EtbValuePolicy; use super::evasion_removal_priority::EvasionRemovalPriorityPolicy; use super::fetch_land_patience::FetchLandPatiencePolicy; use super::free_outlet_activation::FreeOutletActivationPolicy; +use super::graveyard_types::GraveyardTypesPolicy; use super::hand_disruption::HandDisruptionPolicy; use super::hold_mana_up::HoldManaUpForInteractionPolicy; use super::interaction_reservation::InteractionReservationPolicy; @@ -129,6 +130,8 @@ pub enum PolicyId { SeparatePilesTiming, XCastGate, LoopShortcut, + /// CR 207.2c + CR 205.2a: delirium / descend graveyard type-diversity. + GraveyardTypes, } /// Coarse routing kind for a candidate decision. Each policy declares which @@ -320,6 +323,7 @@ impl Default for PolicyRegistry { Box::new(PayoffPolicy::new(&ARTIFACT_SYNERGY)), Box::new(BoardDevelopmentPolicy), Box::new(EtbValuePolicy), + Box::new(GraveyardTypesPolicy), Box::new(PayoffPolicy::new(&ENCHANTMENTS_PAYOFF)), Box::new(PayoffPolicy::new(&EQUIPMENT_PAYOFF)), Box::new(CopyValuePolicy), diff --git a/crates/phase-ai/src/policies/tests/graveyard_types.rs b/crates/phase-ai/src/policies/tests/graveyard_types.rs new file mode 100644 index 0000000000..019e255607 --- /dev/null +++ b/crates/phase-ai/src/policies/tests/graveyard_types.rs @@ -0,0 +1,37 @@ +//! Unit tests for `policies::graveyard_types` — the delirium/descend progress +//! policy. No `#[cfg(test)]` in SOURCE files; tests live here. + +use crate::features::graveyard_types::GRAVEYARD_TYPES_FLOOR; +use crate::features::DeckFeatures; +use crate::policies::graveyard_types::*; +use crate::policies::registry::TacticalPolicy; +use engine::types::game_state::GameState; +use engine::types::player::PlayerId; + +#[test] +fn activation_opts_out_below_floor() { + let mut features = DeckFeatures::default(); + features.graveyard_types.commitment = GRAVEYARD_TYPES_FLOOR - 0.01; + let state = GameState::default(); + assert!(GraveyardTypesPolicy + .activation(&features, &state, PlayerId(0)) + .is_none()); +} + +#[test] +fn activation_opts_in_above_floor() { + let mut features = DeckFeatures::default(); + features.graveyard_types.commitment = 0.9; + let state = GameState::default(); + assert_eq!( + GraveyardTypesPolicy.activation(&features, &state, PlayerId(0)), + Some(0.9) + ); +} + +/// CR 404.1: an empty graveyard has zero distinct card types. +#[test] +fn empty_graveyard_counts_zero_types() { + let state = GameState::default(); + assert_eq!(distinct_graveyard_types(&state, PlayerId(0)), 0); +} diff --git a/crates/phase-ai/src/policies/tests/mod.rs b/crates/phase-ai/src/policies/tests/mod.rs index 46f9fc14d2..81ffa88559 100644 --- a/crates/phase-ai/src/policies/tests/mod.rs +++ b/crates/phase-ai/src/policies/tests/mod.rs @@ -7,6 +7,7 @@ pub mod effect_classify_snapshot; pub mod enchantments_payoff; pub mod energy_payoff; pub mod equipment_payoff; +pub mod graveyard_types; pub mod lifegain_payoff; pub mod mill_payoff; pub mod mulligan_input_lint; From ec075b9d5ee8c4771ff2c2a5d0f3fa30f834ac79 Mon Sep 17 00:00:00 2001 From: minion1227 Date: Thu, 23 Jul 2026 16:24:32 -0700 Subject: [PATCH 2/4] fix(phase-ai): address review on graveyard type-diversity axis (#6544) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Resolves the five blocking items from the maintainer review of #6544. 1. Preserve pre-field PolicyPenalties artifacts. `graveyard_types_progress` now carries `#[serde(default = "default_graveyard_types_progress")]`, and both the `Default` impl and the serde attribute call that one shared fn — the same pattern as the neighbouring recently-added penalties. `ai_tune` deserializes the `policy_penalties` section straight into the struct, so a pre-field artifact previously failed with a missing-field error; a compatibility test now strips the key from a serialized artifact, tunes a neighbour, and asserts the tuned value survives while the new one falls back to the shared default. 2. Model "no threshold" distinctly. `highest_threshold` becomes `Option`; the fabricated `DEFAULT_DELIRIUM_THRESHOLD` fallback (and the const) are gone. A scaling-only deck (Consuming Blob, Tarmogoyf) has no threshold to finish, so the policy no longer goes neutral at four types: a threshold deck races to its gate, and any deck with a scaling payoff keeps earning a diminishing-but-nonzero signal past it. Covered by verdict-level tests, including the required "scaling-only deck still gets progress above four types". 3. Respect comparison truth semantics. Threshold extraction now normalizes only genuinely positive mandatory gates: `>=` / `>` (with the strict-bound off-by-one, `> 3` ⟺ `>= 4`), in either orientation (`N <= types` flips to `types >= N`). `<`, `<=`, `=`, `!=` are rejected — they reward fewer or an exact count. `Not` no longer passes through as positive (negating "N or more" is a "fewer than N" condition). `And` takes the max mandatory threshold; `Or` yields one only when EVERY branch is a graveyard threshold (then the minimum), since a single non-graveyard branch lets the payoff fire without delirium. Tests discriminate GE, GT, each negative comparator, negation, mirrored orientation, and both compounds. 4. Keep the axis strictly controller-scoped. `quantity_reads_own_graveyard_types` no longer accepts `CountScope::All`: the policy counts only the AI's owned graveyard, so an all-graveyards payoff would be detected as an own-graveyard plan while the policy measured a different quantity. Scope match is explicit (Controller/Owner accepted per CR 108.3 + CR 109.5). 5. Use the action's authoritative ability semantics. `CastSpell` now inspects only the spell's own resolution chain via `CastFacts::primary_effects`, so casting a permanent that merely HAS an activated self-mill ability is no longer credited; `ActivateAbility` resolves through `cast_facts::effective_activated_ability`, the engine's enumerated index space (correct for runtime-granted abilities where `GameObject::abilities` is not). Regression coverage for both decisions, plus the negative case. Also routes the verdict through `PolicyVerdict::score` so a tuned-up scalar auto-bands instead of tripping a band assertion. cargo test -p phase-ai --lib — 1401 passed (graveyard suite 12 → 39) cargo clippy -p phase-ai -p engine --all-targets --features proptest — clean Co-Authored-By: Claude Opus 4.8 (1M context) --- crates/phase-ai/src/config.rs | 41 +- .../phase-ai/src/features/graveyard_types.rs | 182 +++++-- .../src/features/tests/graveyard_types.rs | 218 +++++++- .../phase-ai/src/policies/graveyard_types.rs | 128 +++-- .../src/policies/tests/graveyard_types.rs | 485 +++++++++++++++++- 5 files changed, 943 insertions(+), 111 deletions(-) diff --git a/crates/phase-ai/src/config.rs b/crates/phase-ai/src/config.rs index 646459c38b..27c263eaf0 100644 --- a/crates/phase-ai/src/config.rs +++ b/crates/phase-ai/src/config.rs @@ -466,6 +466,7 @@ pub struct PolicyPenalties { /// CR 205.2a: card-equivalent weight for advancing graveyard card-type /// diversity toward a delirium/descend threshold. Strong band when the /// action supplies the last missing type. + #[serde(default = "default_graveyard_types_progress")] pub graveyard_types_progress: f64, } @@ -531,11 +532,18 @@ impl Default for PolicyPenalties { cycling_patience_penalty: default_cycling_patience_penalty(), cycling_needed_land_penalty: default_cycling_needed_land_penalty(), loop_shortcut_winning_declare_bonus: default_loop_shortcut_winning_declare_bonus(), - graveyard_types_progress: 2.5, + graveyard_types_progress: default_graveyard_types_progress(), } } } +/// CR 205.2a. Shared by `Default` and `#[serde(default)]` so a tuning artifact +/// written before this field existed still deserializes (`ai_tune` reads the +/// `policy_penalties` section directly into this struct). +fn default_graveyard_types_progress() -> f64 { + 2.5 +} + fn default_wasted_cast_penalty() -> f64 { -8.0 } @@ -1504,6 +1512,37 @@ mod tests { assert_eq!(p.cycling_needed_land_penalty, -2.0); } + /// Artifact compatibility: `ai_tune` deserializes a persisted + /// `policy_penalties` section straight into `PolicyPenalties` + /// (`bin/ai_tune.rs`, `TuneGroup::Penalties`), so an artifact written + /// before `graveyard_types_progress` existed must still load — with its own + /// tuned values intact and the new field filled from the shared default. + #[test] + fn policy_penalties_load_pre_graveyard_types_artifact() { + let mut artifact = serde_json::to_value(PolicyPenalties::default()).unwrap(); + let object = artifact.as_object_mut().expect("serializes as object"); + object + .remove("graveyard_types_progress") + .expect("field must be present before removal"); + // A value CMA-ES could plausibly have tuned, to prove the round-trip + // reads the artifact rather than silently falling back to Default. + object.insert("wasted_cast_penalty".into(), serde_json::json!(-3.5)); + + let loaded: PolicyPenalties = serde_json::from_value(artifact) + .expect("a pre-graveyard_types_progress artifact must still deserialize"); + assert_eq!(loaded.wasted_cast_penalty, -3.5, "tuned value preserved"); + assert_eq!( + loaded.graveyard_types_progress, + default_graveyard_types_progress(), + "absent field must fall back to the shared default" + ); + assert_eq!( + PolicyPenalties::default().graveyard_types_progress, + default_graveyard_types_progress(), + "Default and serde must share one source of truth" + ); + } + #[test] fn every_policy_penalty_is_tuning_registered_or_explicitly_untuned() { let value = serde_json::to_value(PolicyPenalties::default()).unwrap(); diff --git a/crates/phase-ai/src/features/graveyard_types.rs b/crates/phase-ai/src/features/graveyard_types.rs index 040ded4931..93cf8d27d8 100644 --- a/crates/phase-ai/src/features/graveyard_types.rs +++ b/crates/phase-ai/src/features/graveyard_types.rs @@ -34,8 +34,8 @@ use engine::game::DeckEntry; use engine::types::ability::{ - AbilityDefinition, CardTypeSetSource, ControllerRef, CountScope, Effect, QuantityExpr, - QuantityRef, StaticCondition, TargetFilter, TriggerCondition, ZoneRef, + AbilityDefinition, CardTypeSetSource, Comparator, ControllerRef, CountScope, Effect, + QuantityExpr, QuantityRef, StaticCondition, TargetFilter, TriggerCondition, ZoneRef, }; use engine::types::card_type::CoreType; @@ -46,10 +46,6 @@ use crate::features::commitment; /// than an incidental Goyf. Gates `GraveyardTypesPolicy::activation`. pub const GRAVEYARD_TYPES_FLOOR: f32 = 0.35; -/// CR 205.2a: the delirium threshold printed on essentially every card in this -/// class. Used only as the fallback when a payoff's threshold cannot be read. -pub const DEFAULT_DELIRIUM_THRESHOLD: u32 = 4; - /// CR 207.2c + CR 205.2a: per-deck graveyard type-diversity classification. /// /// Detection is structural over `CardFace.static_abilities`, `.triggers` and @@ -65,9 +61,13 @@ pub struct GraveyardTypesFeature { /// Cards that put cards into the controller's own graveyard — self-mill, /// self-discard, surveil (CR 701.17 / CR 701.25). pub enabler_count: u32, - /// The highest threshold any payoff in the deck asks for. A descend 8 deck - /// must not think it is finished at four card types. - pub highest_threshold: u32, + /// The highest threshold any *threshold* payoff in the deck asks for, or + /// `None` when the deck has no threshold payoff at all. A descend 8 deck + /// must not think it is finished at four card types; a scaling-only deck + /// (Consuming Blob, Tarmogoyf) has no threshold to "finish" and must keep + /// being rewarded for diversity — so absence is modelled distinctly from a + /// concrete four, never invented. + pub highest_threshold: Option, /// `0.0..=1.0` — how central the axis is. Consumed by /// `GraveyardTypesPolicy::activation` as the single scaling knob. pub commitment: f32, @@ -85,7 +85,7 @@ pub fn detect(deck: &[DeckEntry]) -> GraveyardTypesFeature { let mut threshold_payoff_count = 0u32; let mut scaling_payoff_count = 0u32; let mut enabler_count = 0u32; - let mut highest_threshold = 0u32; + let mut highest_threshold: Option = None; let mut total_nonland = 0u32; let mut payoff_names: Vec = Vec::new(); @@ -115,7 +115,9 @@ pub fn detect(deck: &[DeckEntry]) -> GraveyardTypesFeature { if let Some(threshold) = threshold { threshold_payoff_count = threshold_payoff_count.saturating_add(entry.count); - highest_threshold = highest_threshold.max(threshold); + // `Option` orders `None < Some(_)`, so `max` keeps the highest + // real threshold and never regresses to a fabricated default. + highest_threshold = highest_threshold.max(Some(threshold)); } else if scales { // Only a payoff with NO threshold is a scaling payoff — otherwise a // delirium card would be counted on both axes. @@ -142,11 +144,8 @@ pub fn detect(deck: &[DeckEntry]) -> GraveyardTypesFeature { threshold_payoff_count, scaling_payoff_count, enabler_count, - highest_threshold: if highest_threshold == 0 { - DEFAULT_DELIRIUM_THRESHOLD - } else { - highest_threshold - }, + // No fabricated fallback: `None` when the deck has no threshold payoff. + highest_threshold, commitment, payoff_names, } @@ -184,30 +183,37 @@ fn compute_commitment( commitment::geometric_mean(&[payoff_density, enabler_density]) } -/// CR 205.2a: read the threshold N out of a `QuantityComparison` condition -/// whose left side counts distinct card types in the controller's graveyard. +/// CR 205.2a: read the threshold N out of a `StaticCondition` whose comparison +/// counts distinct card types in the controller's graveyard — but only when the +/// comparison is a genuine positive "N or more types" gate. /// -/// Returns `None` for any other condition shape, and for an opponent-scoped -/// count — a card that punishes an OPPONENT's diverse graveyard is not a -/// payoff for this deck's own plan. +/// Returns `None` for any other condition shape, for an opponent-scoped count (a +/// card that punishes an OPPONENT's diverse graveyard is not this deck's plan), +/// and for a comparison whose truth semantics reward FEWER or an EXACT number of +/// types (see [`positive_graveyard_threshold`]). fn static_graveyard_type_threshold(condition: &StaticCondition) -> Option { match condition { - StaticCondition::QuantityComparison { lhs, rhs, .. } => { - if !quantity_reads_own_graveyard_types(lhs) { - return None; - } - match rhs { - QuantityExpr::Fixed { value } if *value > 0 => Some(*value as u32), - _ => None, - } - } - // CR 109.3: a conjunction gates on every constraint, so a delirium - // clause nested in an `And`/`Or` still identifies the payoff. - StaticCondition::And { conditions } | StaticCondition::Or { conditions } => conditions + StaticCondition::QuantityComparison { + lhs, + comparator, + rhs, + } => positive_graveyard_threshold(lhs, *comparator, rhs), + // CR 109.3: an `And` gates on EVERY constraint, so a delirium conjunct + // is mandatory — take the highest graveyard threshold present. + StaticCondition::And { conditions } => conditions .iter() .filter_map(static_graveyard_type_threshold) .max(), - StaticCondition::Not { condition } => static_graveyard_type_threshold(condition), + // An `Or` is satisfied by ANY branch, so a graveyard threshold is a + // mandatory gate only when EVERY branch is one — then the easiest + // (minimum) is what self-mill must reach. A single non-graveyard branch + // means the payoff can fire without delirium, so it is not our plan. + StaticCondition::Or { conditions } => { + all_graveyard_thresholds_min(conditions.iter().map(static_graveyard_type_threshold)) + } + // CR 205.2a: negating "N or more types" is a "fewer than N" condition — + // it rewards a SMALLER graveyard, the opposite of a delirium payoff. + StaticCondition::Not { .. } => None, _ => None, } } @@ -216,22 +222,100 @@ fn static_graveyard_type_threshold(condition: &StaticCondition) -> Option { /// — Autumnal Gloom carries its delirium clause on the trigger, not the static. fn trigger_graveyard_type_threshold(condition: &TriggerCondition) -> Option { match condition { - TriggerCondition::QuantityComparison { lhs, rhs, .. } => { - if !quantity_reads_own_graveyard_types(lhs) { - return None; - } - match rhs { - QuantityExpr::Fixed { value } if *value > 0 => Some(*value as u32), - _ => None, - } + TriggerCondition::QuantityComparison { + lhs, + comparator, + rhs, + } => positive_graveyard_threshold(lhs, *comparator, rhs), + TriggerCondition::And { conditions } => conditions + .iter() + .filter_map(trigger_graveyard_type_threshold) + .max(), + TriggerCondition::Or { conditions } => { + all_graveyard_thresholds_min(conditions.iter().map(trigger_graveyard_type_threshold)) } - TriggerCondition::Not { condition } => trigger_graveyard_type_threshold(condition), + TriggerCondition::Not { .. } => None, + _ => None, + } +} + +/// The `Or`-combinator rule shared by both extractors: yield a threshold only +/// when EVERY disjunct is itself a graveyard threshold, and then the minimum — +/// the easiest branch self-mill can satisfy. Any `None` child (a branch that +/// enables the payoff without delirium) collapses the whole `Or` to `None`. +fn all_graveyard_thresholds_min(children: impl Iterator>) -> Option { + children + .collect::>>() + .and_then(|thresholds| thresholds.into_iter().min()) +} + +/// CR 205.2a: normalize a single `count CMP N` comparison into the delirium +/// threshold it mandates — the least graveyard-type count that satisfies it — or +/// `None` when the comparison is not a positive "N or more types" gate. +/// +/// Handles both orientations (`types >= N`, `N <= types`) and the strict-bound +/// off-by-one (`types > N` ⟺ `types >= N+1`). Rejects `<`, `<=`, `=`, `!=` +/// against the graveyard count: those reward FEWER or an EXACT number of types, +/// so self-milling toward N is not what they want. +fn positive_graveyard_threshold( + lhs: &QuantityExpr, + comparator: Comparator, + rhs: &QuantityExpr, +) -> Option { + // Orient so the graveyard-type count is the subject and the constant is the + // bound; flip the comparator when the count sits on the right. + let (bound, oriented) = if quantity_reads_own_graveyard_types(lhs) { + (fixed_quantity_value(rhs)?, comparator) + } else if quantity_reads_own_graveyard_types(rhs) { + (fixed_quantity_value(lhs)?, flip_comparator(comparator)) + } else { + return None; + }; + // `oriented` now reads `types CMP bound`; a delirium gate is a lower bound. + match oriented { + // types >= bound → needs `bound` types (a zero bound gates nothing). + Comparator::GE if bound > 0 => Some(bound as u32), + // types > bound → needs `bound + 1` types. + Comparator::GT if bound >= 0 => Some((bound + 1) as u32), + Comparator::GT + | Comparator::GE + | Comparator::LT + | Comparator::LE + | Comparator::EQ + | Comparator::NE => None, + } +} + +/// The `i32` behind a `QuantityExpr::Fixed`, or `None` for any dynamic value. +fn fixed_quantity_value(expr: &QuantityExpr) -> Option { + match expr { + QuantityExpr::Fixed { value } => Some(*value), _ => None, } } +/// Reflect a comparator across its operands (`a CMP b` ⟺ `b flip(CMP) a`), so a +/// `constant CMP count` comparison can be re-read as `count CMP constant`. +fn flip_comparator(comparator: Comparator) -> Comparator { + match comparator { + Comparator::GT => Comparator::LT, + Comparator::LT => Comparator::GT, + Comparator::GE => Comparator::LE, + Comparator::LE => Comparator::GE, + Comparator::EQ => Comparator::EQ, + Comparator::NE => Comparator::NE, + } +} + /// True when a `QuantityExpr` reads distinct card types in the controller's -/// own graveyard, at any nesting depth (Consuming Blob wraps it in `Offset`). +/// OWN graveyard, at any nesting depth (Consuming Blob wraps it in `Offset`). +/// +/// Only own-graveyard scopes qualify. `CountScope::All` is deliberately +/// excluded: the policy's `distinct_graveyard_types` counts only the AI's owned +/// objects, so classifying an all-graveyards payoff (Tarmogoyf-class) as an +/// own-graveyard plan would let an opponent satisfy it while the policy keeps +/// rewarding self-mill against a different quantity. Opponent- and +/// iterated-player scopes are likewise not this deck's own plan. fn quantity_reads_own_graveyard_types(expr: &QuantityExpr) -> bool { match expr { QuantityExpr::Ref { qty } => matches!( @@ -239,7 +323,9 @@ fn quantity_reads_own_graveyard_types(expr: &QuantityExpr) -> bool { QuantityRef::DistinctCardTypes { source: CardTypeSetSource::Zone { zone: ZoneRef::Graveyard, - scope: CountScope::Controller | CountScope::All, + // CR 108.3 + CR 109.5: "your graveyard" — the controller as + // owner over a non-battlefield zone. + scope: CountScope::Controller | CountScope::Owner, }, } ), @@ -295,8 +381,10 @@ pub(crate) fn modification_quantity( /// An opponent-scoped mill is deliberately excluded: filling an opponent's /// graveyard does nothing for this deck's threshold (and actively helps a /// Goyf-style symmetric count, which this axis does not chase). -pub(crate) fn fills_own_graveyard_parts(abilities: &[AbilityDefinition]) -> bool { - abilities.iter().any(|ability| { +pub(crate) fn fills_own_graveyard_parts<'a>( + abilities: impl IntoIterator, +) -> bool { + abilities.into_iter().any(|ability| { collect_chain_effects(ability) .iter() .any(|effect| match effect { diff --git a/crates/phase-ai/src/features/tests/graveyard_types.rs b/crates/phase-ai/src/features/tests/graveyard_types.rs index 9c9e3ca5d4..0f36e7c4d3 100644 --- a/crates/phase-ai/src/features/tests/graveyard_types.rs +++ b/crates/phase-ai/src/features/tests/graveyard_types.rs @@ -128,7 +128,7 @@ fn detects_static_threshold_payoff() { 4, )]); assert_eq!(feature.threshold_payoff_count, 4); - assert_eq!(feature.highest_threshold, 4); + assert_eq!(feature.highest_threshold, Some(4)); } #[test] @@ -180,7 +180,221 @@ fn descend_eight_tracks_highest_threshold() { 2, ), ]; - assert_eq!(detect(&deck).highest_threshold, 8); + assert_eq!(detect(&deck).highest_threshold, Some(8)); +} + +/// A scaling-only deck has NO threshold — `highest_threshold` must stay `None` +/// rather than inventing a four-type ceiling that would make the policy stop +/// rewarding a payoff that keeps scaling. +#[test] +fn scaling_only_deck_has_no_threshold() { + let feature = detect(&[ + entry(scaling_payoff("Consuming Blob"), 4), + entry(self_mill_enabler("Stitcher's Supplier"), 4), + ]); + assert_eq!(feature.scaling_payoff_count, 4); + assert_eq!(feature.threshold_payoff_count, 0); + assert_eq!(feature.highest_threshold, None); +} + +// ─── comparator / negation / compound threshold semantics (CR 205.2a) ──────── + +fn static_threshold(comparator: Comparator, lhs: QuantityExpr, rhs: QuantityExpr) -> CardFace { + let mut face = creature("Cmp"); + face.static_abilities = vec![StaticDefinition::continuous() + .affected(TargetFilter::SelfRef) + .modifications(vec![ContinuousModification::AddPower { value: 1 }]) + .condition(StaticCondition::QuantityComparison { + lhs, + comparator, + rhs, + })]; + face +} + +/// `types >= N` and `types > N` are both positive gates; `>` normalizes to the +/// strict boundary (`> 3` needs four types, same as `>= 4`). +#[test] +fn positive_comparators_yield_thresholds() { + let ge = detect(&[entry( + static_threshold( + Comparator::GE, + own_graveyard_types(), + QuantityExpr::Fixed { value: 4 }, + ), + 1, + )]); + assert_eq!(ge.highest_threshold, Some(4), "types >= 4"); + + let gt = detect(&[entry( + static_threshold( + Comparator::GT, + own_graveyard_types(), + QuantityExpr::Fixed { value: 3 }, + ), + 1, + )]); + assert_eq!(gt.highest_threshold, Some(4), "types > 3 ⟺ types >= 4"); +} + +/// `<`, `<=`, `=`, `!=` reward FEWER or an EXACT number of types — self-mill +/// toward N is not what they want, so they are not threshold payoffs. +#[test] +fn non_positive_comparators_are_not_thresholds() { + for comparator in [ + Comparator::LT, + Comparator::LE, + Comparator::EQ, + Comparator::NE, + ] { + let feature = detect(&[entry( + static_threshold( + comparator, + own_graveyard_types(), + QuantityExpr::Fixed { value: 4 }, + ), + 1, + )]); + assert_eq!( + feature.threshold_payoff_count, 0, + "{comparator:?} against the graveyard count is not a delirium gate" + ); + assert_eq!(feature.highest_threshold, None); + } +} + +/// The mirror orientation `N <= types` / `N < types` reads as the same lower +/// bound once the comparator is flipped across its operands. +#[test] +fn mirrored_orientation_normalizes_to_lower_bound() { + let le = detect(&[entry( + static_threshold( + Comparator::LE, + QuantityExpr::Fixed { value: 4 }, + own_graveyard_types(), + ), + 1, + )]); + assert_eq!(le.highest_threshold, Some(4), "4 <= types ⟺ types >= 4"); + + let lt = detect(&[entry( + static_threshold( + Comparator::LT, + QuantityExpr::Fixed { value: 3 }, + own_graveyard_types(), + ), + 1, + )]); + assert_eq!(lt.highest_threshold, Some(4), "3 < types ⟺ types >= 4"); +} + +/// CR 205.2a: negating "N or more types" is a "fewer than N" condition — the +/// opposite of a delirium payoff, so it is not counted. +#[test] +fn negated_threshold_is_not_a_payoff() { + let mut face = creature("Anti-Delirium"); + face.static_abilities = vec![StaticDefinition::continuous() + .affected(TargetFilter::SelfRef) + .modifications(vec![ContinuousModification::AddPower { value: 1 }]) + .condition(StaticCondition::Not { + condition: Box::new(StaticCondition::QuantityComparison { + lhs: own_graveyard_types(), + comparator: Comparator::GE, + rhs: QuantityExpr::Fixed { value: 4 }, + }), + })]; + let feature = detect(&[entry(face, 1)]); + assert_eq!(feature.threshold_payoff_count, 0); + assert_eq!(feature.highest_threshold, None); +} + +/// CR 109.3: an `And` gates on every constraint, so a delirium conjunct is +/// mandatory and the highest graveyard threshold present is taken. +#[test] +fn and_takes_the_highest_mandatory_threshold() { + let mut face = creature("Conjunctive"); + face.static_abilities = vec![StaticDefinition::continuous() + .affected(TargetFilter::SelfRef) + .modifications(vec![ContinuousModification::AddPower { value: 1 }]) + .condition(StaticCondition::And { + conditions: vec![ + StaticCondition::QuantityComparison { + lhs: own_graveyard_types(), + comparator: Comparator::GE, + rhs: QuantityExpr::Fixed { value: 4 }, + }, + StaticCondition::QuantityComparison { + lhs: own_graveyard_types(), + comparator: Comparator::GE, + rhs: QuantityExpr::Fixed { value: 6 }, + }, + ], + })]; + assert_eq!(detect(&[entry(face, 1)]).highest_threshold, Some(6)); +} + +/// An `Or` makes a graveyard threshold a mandatory gate ONLY when every branch +/// is a graveyard threshold (then the easiest, minimum branch). A single +/// non-graveyard branch means the payoff can fire without delirium. +#[test] +fn or_is_a_gate_only_when_every_branch_is_graveyard() { + // Every branch is a graveyard threshold → the minimum is the effective gate. + let mut all_gy = creature("All Graveyard Or"); + all_gy.static_abilities = vec![StaticDefinition::continuous() + .affected(TargetFilter::SelfRef) + .modifications(vec![ContinuousModification::AddPower { value: 1 }]) + .condition(StaticCondition::Or { + conditions: vec![ + StaticCondition::QuantityComparison { + lhs: own_graveyard_types(), + comparator: Comparator::GE, + rhs: QuantityExpr::Fixed { value: 4 }, + }, + StaticCondition::QuantityComparison { + lhs: own_graveyard_types(), + comparator: Comparator::GE, + rhs: QuantityExpr::Fixed { value: 6 }, + }, + ], + })]; + assert_eq!(detect(&[entry(all_gy, 1)]).highest_threshold, Some(4)); + + // One non-graveyard branch → not a mandatory delirium gate. + let mut mixed = creature("Mixed Or"); + mixed.static_abilities = vec![StaticDefinition::continuous() + .affected(TargetFilter::SelfRef) + .modifications(vec![ContinuousModification::AddPower { value: 1 }]) + .condition(StaticCondition::Or { + conditions: vec![ + StaticCondition::QuantityComparison { + lhs: own_graveyard_types(), + comparator: Comparator::GE, + rhs: QuantityExpr::Fixed { value: 4 }, + }, + StaticCondition::QuantityComparison { + lhs: opponent_graveyard_types(), + comparator: Comparator::GE, + rhs: QuantityExpr::Fixed { value: 4 }, + }, + ], + })]; + assert_eq!(detect(&[entry(mixed, 1)]).threshold_payoff_count, 0); +} + +/// CR 108.3: an all-graveyards payoff must NOT be read as an own-graveyard plan +/// — the policy would count only the AI's own graveyard, a different quantity. +#[test] +fn all_graveyards_scope_is_not_own_graveyard() { + let all_scope = QuantityExpr::Ref { + qty: QuantityRef::DistinctCardTypes { + source: CardTypeSetSource::Zone { + zone: ZoneRef::Graveyard, + scope: CountScope::All, + }, + }, + }; + let feature = detect(&[entry(threshold_payoff("All Graveyards", 4, all_scope), 4)]); + assert_eq!(feature.threshold_payoff_count, 0); } #[test] diff --git a/crates/phase-ai/src/policies/graveyard_types.rs b/crates/phase-ai/src/policies/graveyard_types.rs index 574203bfb6..e14b13c581 100644 --- a/crates/phase-ai/src/policies/graveyard_types.rs +++ b/crates/phase-ai/src/policies/graveyard_types.rs @@ -11,21 +11,24 @@ //! graveyard — switching every delirium payoff on — scored exactly the same as //! one that put in a redundant fifth creature. //! -//! ## Why the threshold-met branch scores zero +//! ## When the axis stops rewarding //! -//! Once the count is at or above the deck's highest threshold, every payoff is -//! already live and additional diversity buys nothing on this axis. Scoring it -//! anyway would make the AI keep durdling with self-mill after delirium is on, -//! which is exactly the failure mode this policy exists to avoid. Same -//! no-progress-no-score backbone as `PoisonClockPolicy`, different resource. +//! A *threshold* payoff (delirium, descend N) goes live at its threshold; once +//! the graveyard reaches it, more diversity buys nothing and the branch scores +//! zero — otherwise the AI would durdle with self-mill after delirium is on. A +//! *scaling* payoff (Consuming Blob, Tarmogoyf) has no threshold and keeps +//! wanting a bigger, more diverse graveyard, so it is rewarded continuously +//! with a diminishing signal. A deck's threshold is modelled as `Option` +//! precisely so a scaling-only deck is never handed a fabricated four-type +//! ceiling. Same no-progress-no-score backbone as `PoisonClockPolicy`. //! //! ## Performance //! //! `verdict()` runs per candidate per search node, so predicate order matters. -//! The card-local AST check (`fills_own_graveyard_parts` over the candidate's -//! own abilities) runs FIRST and rejects the overwhelming majority of -//! candidates; only a confirmed graveyard-filler pays for the graveyard scan, -//! which walks one zone's objects and never touches the battlefield, mana +//! The card-local AST check (`fills_own_graveyard_parts`, over the action's +//! authoritative effect chain) runs FIRST and rejects the overwhelming majority +//! of candidates; only a confirmed graveyard-filler pays for the graveyard +//! scan, which walks one zone's objects and never touches the battlefield, mana //! affordability, or `find_legal_targets`. use std::collections::HashSet; @@ -83,61 +86,84 @@ impl TacticalPolicy for GraveyardTypesPolicy { } fn verdict(&self, ctx: &PolicyContext<'_>) -> PolicyVerdict { - // Cheapest discriminator first: a card-local AST walk over just this - // candidate's abilities. Everything below is gated behind it. - let abilities = match &ctx.candidate.action { - GameAction::CastSpell { object_id, .. } => ctx - .state - .objects - .get(object_id) - .map(|obj| obj.abilities.as_slice()), - GameAction::ActivateAbility { - source_id, - ability_index, - } => ctx - .state - .objects - .get(source_id) - .and_then(|obj| obj.abilities.get(*ability_index)) - .map(std::slice::from_ref), - _ => None, - }; - let Some(abilities) = abilities else { - return PolicyVerdict::neutral(PolicyReason::new("graveyard_types_na")); - }; - if !fills_own_graveyard_parts(abilities) { + // Cheapest discriminator first, and it must inspect exactly the effect + // the action performs — via the engine's authoritative ability + // enumeration, not every ability sitting on the object. + if !candidate_fills_own_graveyard(ctx) { return PolicyVerdict::neutral(PolicyReason::new("graveyard_types_na")); } - let threshold = ctx + let feature = ctx .context .session .features .get(&ctx.ai_player) - .map(|f| f.graveyard_types.highest_threshold) - .unwrap_or(0); + .map(|f| &f.graveyard_types); + let threshold = feature.and_then(|f| f.highest_threshold); + let has_scaling = feature.is_some_and(|f| f.scaling_payoff_count > 0); let current = distinct_graveyard_types(ctx.state, ctx.ai_player); let scalar = ctx.config.policy_penalties.graveyard_types_progress; - // Every payoff is already live — more diversity buys nothing here. - if threshold == 0 || current >= threshold { - return PolicyVerdict::neutral( - PolicyReason::new("graveyard_types_threshold_met") + // Below an unmet threshold: race to switch every delirium payoff on. + // The last missing type is worth far more than the first — it is what + // actually turns the payoffs on. Routes through `PolicyVerdict::score` + // so a tuned-up `scalar` auto-bands instead of tripping a band assert. + if let Some(threshold) = threshold { + if current < threshold { + let deficit = threshold - current; + let delta = if deficit == 1 { + scalar + } else { + scalar / f64::from(deficit) + }; + return PolicyVerdict::score( + delta, + PolicyReason::new("graveyard_types_progress") + .with_fact("graveyard_types", current as i64) + .with_fact("deficit", deficit as i64), + ); + } + } + + // At/over the threshold, or no threshold at all: only a SCALING payoff + // still wants a bigger, more diverse graveyard. Diminishing (the nth + // type matters less than the first) but never zero, so a Tarmogoyf / + // Consuming Blob deck keeps being rewarded past four types. + if has_scaling { + return PolicyVerdict::score( + scalar / f64::from(current + 1), + PolicyReason::new("graveyard_types_scaling") .with_fact("graveyard_types", current as i64), ); } - let deficit = threshold - current; - let reason = PolicyReason::new("graveyard_types_progress") - .with_fact("graveyard_types", current as i64) - .with_fact("deficit", deficit as i64); + // A threshold-only deck already at its threshold: delirium is on and + // nothing scales, so more diversity buys nothing on this axis. + PolicyVerdict::neutral( + PolicyReason::new("graveyard_types_threshold_met") + .with_fact("graveyard_types", current as i64), + ) + } +} - // The last missing type is worth far more than the first: it is what - // actually switches the payoffs on. - if deficit == 1 { - PolicyVerdict::strong(scalar, reason) - } else { - PolicyVerdict::preference(scalar / f64::from(deficit), reason) - } +/// True when the candidate action ACTUALLY fills the AI's own graveyard. +/// +/// The lookup respects the action's authoritative ability semantics: +/// * `CastSpell` → the spell's own resolution chain (`CastFacts::primary_effects`, +/// which is the `AbilityKind::Spell` abilities only). Casting a permanent that +/// merely *has* an activated self-mill ability does not qualify — the cast +/// itself fills no graveyard (CR 601.2). +/// * `ActivateAbility` → the ability at the engine's runtime-enumerated index +/// (`effective_activated_ability`), which is the correct index space for +/// runtime-granted abilities where `GameObject::abilities` is not (CR 602.2). +fn candidate_fills_own_graveyard(ctx: &PolicyContext<'_>) -> bool { + match &ctx.candidate.action { + GameAction::CastSpell { .. } => ctx + .cast_facts() + .is_some_and(|facts| fills_own_graveyard_parts(facts.primary_effects.iter().copied())), + GameAction::ActivateAbility { .. } => ctx + .effective_activated_ability() + .is_some_and(|ability| fills_own_graveyard_parts(std::iter::once(&ability))), + _ => false, } } diff --git a/crates/phase-ai/src/policies/tests/graveyard_types.rs b/crates/phase-ai/src/policies/tests/graveyard_types.rs index 019e255607..70e7cae44e 100644 --- a/crates/phase-ai/src/policies/tests/graveyard_types.rs +++ b/crates/phase-ai/src/policies/tests/graveyard_types.rs @@ -1,20 +1,226 @@ //! Unit tests for `policies::graveyard_types` — the delirium/descend progress //! policy. No `#[cfg(test)]` in SOURCE files; tests live here. +//! +//! The `verdict` path runs against a real `PolicyContext` built over a +//! two-player `GameState`, mirroring the `energy_payoff` policy-test shape, so +//! the authoritative cast/activated-ability lookup and the graveyard scan are +//! exercised end to end rather than in isolation. -use crate::features::graveyard_types::GRAVEYARD_TYPES_FLOOR; +use std::sync::Arc; + +use engine::ai_support::{ActionMetadata, AiDecisionContext, CandidateAction, TacticalClass}; +use engine::game::zones::create_object; +use engine::types::ability::{AbilityDefinition, AbilityKind, Effect, QuantityExpr, TargetFilter}; +use engine::types::actions::GameAction; +use engine::types::card_type::{CardType, CoreType}; +use engine::types::game_state::{CastPaymentMode, GameState, WaitingFor}; +use engine::types::identifiers::{CardId, ObjectId}; +use engine::types::player::PlayerId; +use engine::types::zones::Zone; + +use crate::config::AiConfig; +use crate::context::AiContext; +use crate::features::graveyard_types::{GraveyardTypesFeature, GRAVEYARD_TYPES_FLOOR}; use crate::features::DeckFeatures; +use crate::policies::context::{PolicyContext, SearchDepth}; use crate::policies::graveyard_types::*; -use crate::policies::registry::TacticalPolicy; -use engine::types::game_state::GameState; -use engine::types::player::PlayerId; +use crate::policies::registry::{PolicyReason, PolicyVerdict, TacticalPolicy}; +use crate::session::AiSession; + +const AI: PlayerId = PlayerId(0); + +// ─── fixtures ─────────────────────────────────────────────────────────────── + +fn config() -> AiConfig { + AiConfig::default() +} + +/// An `AiContext` whose cached graveyard feature carries the given threshold and +/// scaling posture, so `verdict` reads them the way it would in a real game. +fn context_with( + config: &AiConfig, + highest_threshold: Option, + scaling_payoff_count: u32, +) -> AiContext { + let features = DeckFeatures { + graveyard_types: GraveyardTypesFeature { + threshold_payoff_count: highest_threshold.map_or(0, |_| 8), + scaling_payoff_count, + enabler_count: 8, + highest_threshold, + commitment: 0.9, + payoff_names: Vec::new(), + }, + ..Default::default() + }; + let mut session = AiSession::empty(); + session.features.insert(AI, features); + let mut context = AiContext::empty(&config.weights); + context.session = Arc::new(session); + context.player = AI; + context +} + +fn priority_decision() -> AiDecisionContext { + AiDecisionContext { + waiting_for: WaitingFor::Priority { player: AI }, + candidates: Vec::new(), + } +} + +fn ctx<'a>( + state: &'a GameState, + candidate: &'a CandidateAction, + decision: &'a AiDecisionContext, + context: &'a AiContext, + config: &'a AiConfig, +) -> PolicyContext<'a> { + PolicyContext { + state, + decision, + candidate, + ai_player: AI, + config, + context, + cast_facts: None, + search_depth: SearchDepth::Root, + } +} + +fn self_mill_effect() -> Effect { + Effect::Mill { + count: QuantityExpr::Fixed { value: 1 }, + target: TargetFilter::Controller, + destination: Zone::Graveyard, + } +} + +fn draw_effect() -> Effect { + Effect::Draw { + count: QuantityExpr::Fixed { value: 1 }, + target: TargetFilter::Controller, + } +} + +/// Put `count` objects with distinct core types into the AI's graveyard so +/// `distinct_graveyard_types` reports exactly `count`. +fn seed_graveyard_types(state: &mut GameState, count: usize) { + const TYPES: [CoreType; 6] = [ + CoreType::Creature, + CoreType::Instant, + CoreType::Sorcery, + CoreType::Artifact, + CoreType::Enchantment, + CoreType::Land, + ]; + for (i, core) in TYPES.iter().take(count).enumerate() { + let oid = create_object( + state, + CardId(1000 + i as u64), + AI, + format!("GY {i}"), + Zone::Graveyard, + ); + state.objects.get_mut(&oid).unwrap().card_types = CardType { + supertypes: Vec::new(), + core_types: vec![*core], + subtypes: Vec::new(), + }; + } +} + +/// A hand object whose SPELL resolution mills the controller (Thought Scour +/// shape) — casting it fills the graveyard. `card_id` is aligned to the object +/// id so `cast_candidate` resolves it through `cast_facts`. +fn mill_spell(state: &mut GameState, idx: u64) -> ObjectId { + let oid = create_object( + state, + CardId(idx), + AI, + format!("Mill Spell {idx}"), + Zone::Hand, + ); + let object = state.objects.get_mut(&oid).unwrap(); + object.card_id = CardId(oid.0); + object.card_types = CardType { + supertypes: Vec::new(), + core_types: vec![CoreType::Instant], + subtypes: Vec::new(), + }; + *Arc::make_mut(&mut object.abilities) = vec![AbilityDefinition::new( + AbilityKind::Spell, + self_mill_effect(), + )]; + oid +} + +/// A hand object that is a CREATURE with an ACTIVATED self-mill ability and no +/// spell-resolution mill — casting it fills no graveyard; activating it does. +fn creature_with_activated_mill(state: &mut GameState, idx: u64) -> ObjectId { + let oid = create_object( + state, + CardId(idx), + AI, + format!("Filler Body {idx}"), + Zone::Hand, + ); + let object = state.objects.get_mut(&oid).unwrap(); + object.card_id = CardId(oid.0); + object.card_types = CardType { + supertypes: Vec::new(), + core_types: vec![CoreType::Creature], + subtypes: Vec::new(), + }; + *Arc::make_mut(&mut object.abilities) = vec![AbilityDefinition::new( + AbilityKind::Activated, + self_mill_effect(), + )]; + oid +} + +fn cast_candidate(object_id: ObjectId) -> CandidateAction { + CandidateAction { + action: GameAction::CastSpell { + object_id, + card_id: CardId(object_id.0), + targets: Vec::new(), + payment_mode: CastPaymentMode::default(), + }, + metadata: ActionMetadata::for_actor(Some(AI), TacticalClass::Spell), + } +} + +fn activate_candidate(source_id: ObjectId, ability_index: usize) -> CandidateAction { + CandidateAction { + action: GameAction::ActivateAbility { + source_id, + ability_index, + }, + metadata: ActionMetadata::for_actor(Some(AI), TacticalClass::Ability), + } +} + +fn score_of(verdict: PolicyVerdict) -> (f64, PolicyReason) { + match verdict { + PolicyVerdict::Score { delta, reason } => (delta, reason), + PolicyVerdict::Reject { reason } => panic!("unexpected Reject: {reason:?}"), + } +} + +// Object ids for hand fixtures must match `cast_candidate`'s `card_id == +// object_id.0`, so keep them clear of the 1000+ graveyard-seed ids. +fn state() -> GameState { + GameState::new_two_player(42) +} + +// ─── activation + helpers ─────────────────────────────────────────────────── #[test] fn activation_opts_out_below_floor() { let mut features = DeckFeatures::default(); features.graveyard_types.commitment = GRAVEYARD_TYPES_FLOOR - 0.01; - let state = GameState::default(); assert!(GraveyardTypesPolicy - .activation(&features, &state, PlayerId(0)) + .activation(&features, &state(), AI) .is_none()); } @@ -22,9 +228,8 @@ fn activation_opts_out_below_floor() { fn activation_opts_in_above_floor() { let mut features = DeckFeatures::default(); features.graveyard_types.commitment = 0.9; - let state = GameState::default(); assert_eq!( - GraveyardTypesPolicy.activation(&features, &state, PlayerId(0)), + GraveyardTypesPolicy.activation(&features, &state(), AI), Some(0.9) ); } @@ -32,6 +237,266 @@ fn activation_opts_in_above_floor() { /// CR 404.1: an empty graveyard has zero distinct card types. #[test] fn empty_graveyard_counts_zero_types() { - let state = GameState::default(); - assert_eq!(distinct_graveyard_types(&state, PlayerId(0)), 0); + assert_eq!(distinct_graveyard_types(&state(), AI), 0); +} + +#[test] +fn distinct_graveyard_types_counts_each_core_type_once() { + let mut state = state(); + seed_graveyard_types(&mut state, 3); + assert_eq!(distinct_graveyard_types(&state, AI), 3); +} + +// ─── verdict: threshold race (CR 205.2a) ──────────────────────────────────── + +/// One type short of the threshold, casting a self-mill is the strongest play — +/// it can switch every delirium payoff on. +#[test] +fn verdict_rewards_the_last_missing_type_strongly() { + let config = config(); + let context = context_with(&config, Some(4), 0); + let mut state = state(); + seed_graveyard_types(&mut state, 3); // deficit == 1 + let spell = mill_spell(&mut state, 1); + let decision = priority_decision(); + + let (delta, reason) = score_of(GraveyardTypesPolicy.verdict(&ctx( + &state, + &cast_candidate(spell), + &decision, + &context, + &config, + ))); + assert_eq!(reason.kind, "graveyard_types_progress"); + assert!( + delta > crate::policies::registry::PREFERENCE_MAX, + "the last missing type is a strong play, got {delta}" + ); +} + +/// Farther from the threshold, the same self-mill is worth less per point. +#[test] +fn verdict_scales_progress_by_deficit() { + let config = config(); + let context = context_with(&config, Some(4), 0); + let decision = priority_decision(); + + let score_at = |types: usize| { + let mut state = state(); + seed_graveyard_types(&mut state, types); + let spell = mill_spell(&mut state, 1); + score_of(GraveyardTypesPolicy.verdict(&ctx( + &state, + &cast_candidate(spell), + &decision, + &context, + &config, + ))) + .0 + }; + + // deficit 3 (1 type) < deficit 1 (3 types): closer to the threshold scores + // higher. + assert!(score_at(3) > score_at(1)); + assert!(score_at(1) > 0.0); +} + +/// At or over the threshold with no scaling payoff, delirium is on and more +/// diversity buys nothing. +#[test] +fn verdict_is_neutral_once_threshold_met_without_scaling() { + let config = config(); + let context = context_with(&config, Some(4), 0); + let mut state = state(); + seed_graveyard_types(&mut state, 4); + let spell = mill_spell(&mut state, 1); + let decision = priority_decision(); + + let (delta, reason) = score_of(GraveyardTypesPolicy.verdict(&ctx( + &state, + &cast_candidate(spell), + &decision, + &context, + &config, + ))); + assert_eq!(reason.kind, "graveyard_types_threshold_met"); + assert_eq!(delta, 0.0); +} + +// ─── verdict: scaling-only continuation (the item-2 fix) ──────────────────── + +/// A scaling-only deck (no threshold) must keep receiving a progress signal +/// ABOVE four types — the payoff continues to scale, so the old invented +/// four-type ceiling was wrong. +#[test] +fn verdict_still_rewards_scaling_only_deck_above_four_types() { + let config = config(); + let context = context_with(&config, None, 4); // no threshold, has scaling + let mut state = state(); + seed_graveyard_types(&mut state, 5); // above the old ceiling of 4 + let spell = mill_spell(&mut state, 1); + let decision = priority_decision(); + + let (delta, reason) = score_of(GraveyardTypesPolicy.verdict(&ctx( + &state, + &cast_candidate(spell), + &decision, + &context, + &config, + ))); + assert_eq!(reason.kind, "graveyard_types_scaling"); + assert!( + delta > 0.0, + "a scaling payoff still wants more types, got {delta}" + ); +} + +/// The scaling reward diminishes as the graveyard grows — the sixth type is +/// worth less than the second. +#[test] +fn verdict_scaling_reward_diminishes() { + let config = config(); + let context = context_with(&config, None, 4); + let decision = priority_decision(); + + let score_at = |types: usize| { + let mut state = state(); + seed_graveyard_types(&mut state, types); + let spell = mill_spell(&mut state, 1); + score_of(GraveyardTypesPolicy.verdict(&ctx( + &state, + &cast_candidate(spell), + &decision, + &context, + &config, + ))) + .0 + }; + assert!(score_at(1) > score_at(5)); + assert!(score_at(5) > 0.0); +} + +/// A mixed deck past its threshold still rewards diversity for the scaling half. +#[test] +fn verdict_mixed_deck_keeps_scaling_past_threshold() { + let config = config(); + let context = context_with(&config, Some(4), 2); // both threshold and scaling + let mut state = state(); + seed_graveyard_types(&mut state, 5); // past the threshold + let spell = mill_spell(&mut state, 1); + let decision = priority_decision(); + + let (_, reason) = score_of(GraveyardTypesPolicy.verdict(&ctx( + &state, + &cast_candidate(spell), + &decision, + &context, + &config, + ))); + assert_eq!(reason.kind, "graveyard_types_scaling"); +} + +// ─── verdict: authoritative cast/activate semantics (the item-5 fix) ───────── + +/// CR 601.2: casting a permanent that merely HAS an activated self-mill ability +/// fills no graveyard — the cast's own resolution does nothing here. +#[test] +fn verdict_ignores_cast_of_a_body_with_an_activated_mill() { + let config = config(); + let context = context_with(&config, Some(4), 0); + let mut state = state(); + seed_graveyard_types(&mut state, 3); + let body = creature_with_activated_mill(&mut state, 1); + let decision = priority_decision(); + + let (delta, reason) = score_of(GraveyardTypesPolicy.verdict(&ctx( + &state, + &cast_candidate(body), + &decision, + &context, + &config, + ))); + assert_eq!(reason.kind, "graveyard_types_na"); + assert_eq!(delta, 0.0); +} + +/// But ACTIVATING that same body's self-mill ability is credited, resolved +/// through the engine's enumerated ability index. +#[test] +fn verdict_credits_the_activated_mill_ability() { + let config = config(); + let context = context_with(&config, Some(4), 0); + let mut state = state(); + seed_graveyard_types(&mut state, 3); + let body = creature_with_activated_mill(&mut state, 1); + let decision = priority_decision(); + + let (delta, reason) = score_of(GraveyardTypesPolicy.verdict(&ctx( + &state, + &activate_candidate(body, 0), + &decision, + &context, + &config, + ))); + assert_eq!(reason.kind, "graveyard_types_progress"); + assert!(delta > 0.0); +} + +/// A spell whose OWN resolution mills is credited on cast. +#[test] +fn verdict_credits_a_spell_that_mills_on_resolution() { + let config = config(); + let context = context_with(&config, Some(4), 0); + let mut state = state(); + seed_graveyard_types(&mut state, 3); + let spell = mill_spell(&mut state, 1); + let decision = priority_decision(); + + let (delta, reason) = score_of(GraveyardTypesPolicy.verdict(&ctx( + &state, + &cast_candidate(spell), + &decision, + &context, + &config, + ))); + assert_eq!(reason.kind, "graveyard_types_progress"); + assert!(delta > 0.0); +} + +/// A spell that does not touch the graveyard is neutral. +#[test] +fn verdict_ignores_a_non_filling_spell() { + let config = config(); + let context = context_with(&config, Some(4), 0); + let mut state = state(); + seed_graveyard_types(&mut state, 3); + let oid = create_object( + &mut state, + CardId(1), + AI, + "Draw Spell".to_string(), + Zone::Hand, + ); + { + let object = state.objects.get_mut(&oid).unwrap(); + object.card_id = CardId(oid.0); + object.card_types = CardType { + supertypes: Vec::new(), + core_types: vec![CoreType::Instant], + subtypes: Vec::new(), + }; + *Arc::make_mut(&mut object.abilities) = + vec![AbilityDefinition::new(AbilityKind::Spell, draw_effect())]; + } + let decision = priority_decision(); + + let (delta, reason) = score_of(GraveyardTypesPolicy.verdict(&ctx( + &state, + &cast_candidate(oid), + &decision, + &context, + &config, + ))); + assert_eq!(reason.kind, "graveyard_types_na"); + assert_eq!(delta, 0.0); } From f6239086f818f8c424f8874f3e7ff5bccb34674c Mon Sep 17 00:00:00 2001 From: minion1227 Date: Thu, 23 Jul 2026 18:19:56 -0700 Subject: [PATCH 3/4] fix(phase-ai): give both graveyard detectors the same three-carrier walk (#6544) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Addresses the two reach blockers from the second review. They were mirror images of one omission — enabler detection read `abilities` but not `triggers`; threshold detection read `triggers` but not `abilities` — so this is one change that gives both detectors the same three-carrier, chain-walking scan. Blocker 1 — enabler detection never read `face.triggers`, which zeroed the axis. `compute_commitment` ends in a geometric mean, so `enabler_count == 0` forces `commitment == 0.0`, which drops the deck under `GRAVEYARD_TYPES_FLOOR` and switches the policy off entirely. Stitcher's Supplier — the archetypal delirium enabler — is `abilities: []` with mill triggers, so the flagship shell scored nothing at all. `fills_own_graveyard_parts` now takes `(abilities, triggers)` and walks `triggers[*].execute` too, mirroring `tokens_wide::is_token_generator_parts`. Blocker 2 — threshold extraction missed the third condition carrier, `AbilityDefinition.condition`. On Traverse the Ulvenwald the gate is not at `abilities[0].condition` but at `abilities[0].sub_ability.condition` (the "…instead search…" clause lowers onto the sub-ability, wrapped in `ConditionInstead`), so folding only the top level in would still have missed it. Added `ability_graveyard_type_threshold` (QuantityCheck / ConditionInstead / And-max / Or-all-then-min / Not-None, matching the two existing extractors) plus `ability_chain_graveyard_type_threshold`, which walks the sub_ability and else_ability chain. All three carriers now feed one `.max()`. Also, per the same review: * `Effect::Dig { rest_destination: Graveyard }` is now an enabler (CR 701.20e) — the densest type-spread filler on the axis. Needed the three-carrier walk too: Satyr Wayfinder carries it in triggers, Grisly Salvage in abilities. * The `CastSpell` gate no longer discards `CastFacts::immediate_etb_triggers`. Excluding *activated* abilities was right (CR 601.2); excluding ETB triggers overshot and made casting Stitcher's Supplier return `graveyard_types_na`. * Single authority restored: `reanimator::effect_fills_own_graveyard` is extended (DiscardCard, Surveil, rest-to-graveyard Dig; SelfRef / Typed-You / Or / And scopes) and both graveyard axes now call it, instead of two "single authorities" disagreeing about one question. * Docs: Tarmogoyf is `CountScope::All` and correctly rejected, so Consuming Blob is the example throughout; the zone-scan claim now matches the implementation; the enabler list includes Dig. * Dead branch removed — `scalar / deficit` already peaks at `scalar` when the deficit is 1. Fixtures were the reason all of this shipped green: every enabler fixture was an ability-borne Mill. Added trigger-borne mill, Dig-borne (both carriers) with a rest-to-library negative, ability-chain threshold (incl. participation in `highest_threshold`), a commitment-path guard that trigger-only enablers clear the floor, and ETB-trigger cast coverage with a non-filling control. Left alone per the review: the two inherited `CR 109.3` annotations, which are a repo-wide cleanup rather than this PR's. cargo test -p phase-ai --lib — 1409 passed (graveyard suite 39 → 47) cargo clippy -p phase-ai -p engine --all-targets --features proptest — clean Co-Authored-By: Claude Opus 4.8 (1M context) --- .../phase-ai/src/features/graveyard_types.rs | 164 +++++++++++----- crates/phase-ai/src/features/reanimator.rs | 50 ++++- .../src/features/tests/graveyard_types.rs | 183 +++++++++++++++++- .../phase-ai/src/policies/graveyard_types.rs | 44 +++-- .../src/policies/tests/graveyard_types.rs | 94 ++++++++- 5 files changed, 457 insertions(+), 78 deletions(-) diff --git a/crates/phase-ai/src/features/graveyard_types.rs b/crates/phase-ai/src/features/graveyard_types.rs index 93cf8d27d8..e6a6c5a3bf 100644 --- a/crates/phase-ai/src/features/graveyard_types.rs +++ b/crates/phase-ai/src/features/graveyard_types.rs @@ -10,9 +10,10 @@ //! rhs }` whose `lhs` is that quantity (Backwoods Survivalists, Autumnal Gloom). //! - Scaling payoffs read the same quantity as a dynamic magnitude with no //! threshold at all (Consuming Blob's `SetDynamicPower`). -//! - Enablers: `Effect::Mill { target, destination }` (`ability.rs:10102`), -//! `Effect::DiscardCard { target }` (`:10096`), `Effect::Discard { target }` -//! (`:11134`), `Effect::Surveil { target }` (`:10377`). +//! - Enablers: `Effect::Mill`, `Effect::Discard`, `Effect::DiscardCard`, +//! `Effect::Surveil`, and `Effect::Dig { rest_destination: Graveyard }` — +//! classified by the shared `reanimator::effect_fills_own_graveyard` +//! authority, and read from BOTH `abilities` and `triggers[*].execute`. //! //! No parser remediation required. //! @@ -34,8 +35,8 @@ use engine::game::DeckEntry; use engine::types::ability::{ - AbilityDefinition, CardTypeSetSource, Comparator, ControllerRef, CountScope, Effect, - QuantityExpr, QuantityRef, StaticCondition, TargetFilter, TriggerCondition, ZoneRef, + AbilityCondition, AbilityDefinition, CardTypeSetSource, Comparator, CountScope, QuantityExpr, + QuantityRef, StaticCondition, TriggerCondition, TriggerDefinition, ZoneRef, }; use engine::types::card_type::CoreType; @@ -56,15 +57,16 @@ pub struct GraveyardTypesFeature { /// your graveyard") — delirium, descend N, threshold. pub threshold_payoff_count: u32, /// Payoffs that scale continuously with the count and have no threshold - /// (Consuming Blob, Tarmogoyf-likes). + /// (Consuming Blob-likes). pub scaling_payoff_count: u32, /// Cards that put cards into the controller's own graveyard — self-mill, - /// self-discard, surveil (CR 701.17 / CR 701.25). + /// self-discard, surveil, or a rest-to-graveyard dig (CR 701.17 / CR 701.25 + /// / CR 701.20e). Counted from ability chains AND trigger bodies. pub enabler_count: u32, /// The highest threshold any *threshold* payoff in the deck asks for, or /// `None` when the deck has no threshold payoff at all. A descend 8 deck /// must not think it is finished at four card types; a scaling-only deck - /// (Consuming Blob, Tarmogoyf) has no threshold to "finish" and must keep + /// (Consuming Blob) has no threshold to "finish" and must keep /// being rewarded for diversity — so absence is modelled distinctly from a /// concrete four, never invented. pub highest_threshold: Option, @@ -95,9 +97,14 @@ pub fn detect(deck: &[DeckEntry]) -> GraveyardTypesFeature { total_nonland = total_nonland.saturating_add(entry.count); } - // `StaticDefinition.condition` and `TriggerDefinition.condition` are - // DISTINCT enums that happen to share the `QuantityComparison` shape, - // so each gets its own extractor rather than a forced conversion. + // A graveyard-type gate can ride any of THREE carriers, and they are + // DISTINCT enums that merely share the same comparison shape, so each + // gets its own extractor rather than a forced conversion: + // * `StaticDefinition.condition` (Backwoods Survivalists) + // * `TriggerDefinition.condition` (Autumnal Gloom) + // * `AbilityDefinition.condition` (Traverse the Ulvenwald) — walked + // down the sub_ability/else_ability chain, where the gate usually + // sits rather than at the ability root. let threshold = face .static_abilities .iter() @@ -109,6 +116,11 @@ pub fn detect(deck: &[DeckEntry]) -> GraveyardTypesFeature { .filter_map(|t| t.condition.as_ref()) .filter_map(trigger_graveyard_type_threshold), ) + .chain( + face.abilities + .iter() + .filter_map(ability_chain_graveyard_type_threshold), + ) .max(); let scales = face_reads_graveyard_types(face); @@ -128,7 +140,7 @@ pub fn detect(deck: &[DeckEntry]) -> GraveyardTypesFeature { payoff_names.push(face.name.clone()); } - if fills_own_graveyard_parts(&face.abilities) { + if fills_own_graveyard_parts(&face.abilities, &face.triggers) { enabler_count = enabler_count.saturating_add(entry.count); } } @@ -153,8 +165,8 @@ pub fn detect(deck: &[DeckEntry]) -> GraveyardTypesFeature { /// Calibration: a Modern delirium shell (8 threshold payoffs + 2 scaling /// payoffs + 8 enablers over 37 nonland) → commitment ≈ 0.90. -/// Anti-calibration: a deck running one incidental Tarmogoyf and no enablers → -/// well below `GRAVEYARD_TYPES_FLOOR`; UW control → 0.0. +/// Anti-calibration: a deck running one incidental scaling body and no +/// enablers → well below `GRAVEYARD_TYPES_FLOOR`; UW control → 0.0. /// /// Geometric mean over (payoff, enabler): unlike poison, BOTH pillars are /// mandatory here. Payoffs with no enablers never turn on reliably, and @@ -239,6 +251,58 @@ fn trigger_graveyard_type_threshold(condition: &TriggerCondition) -> Option } } +/// CR 205.2a: the `AbilityCondition` twin of [`static_graveyard_type_threshold`] +/// — the THIRD condition carrier, `AbilityDefinition.condition`. +/// +/// Traverse the Ulvenwald is the shape that forces this: its delirium gate is +/// not on `abilities[0].condition` but on `abilities[0].sub_ability.condition`, +/// because *"Delirium — If there are four or more card types among cards in your +/// graveyard, **instead** search…"* replaces the second clause and so lowers +/// onto the sub-ability, wrapped in `ConditionInstead`. Walking only the top +/// level would still miss it — see [`ability_chain_graveyard_type_threshold`]. +fn ability_graveyard_type_threshold(condition: &AbilityCondition) -> Option { + match condition { + AbilityCondition::QuantityCheck { + lhs, + comparator, + rhs, + } => positive_graveyard_threshold(lhs, *comparator, rhs), + // CR 608.2c: "instead" wraps the gate without changing its polarity. + AbilityCondition::ConditionInstead { inner } => ability_graveyard_type_threshold(inner), + AbilityCondition::And { conditions } => conditions + .iter() + .filter_map(ability_graveyard_type_threshold) + .max(), + AbilityCondition::Or { conditions } => { + all_graveyard_thresholds_min(conditions.iter().map(ability_graveyard_type_threshold)) + } + AbilityCondition::Not { .. } => None, + _ => None, + } +} + +/// Walk an ability and its `sub_ability` / `else_ability` chain, returning the +/// highest graveyard-type threshold gating any link. +/// +/// The chain walk is the load-bearing part: the gate frequently sits on a +/// sub-ability rather than the ability root (Traverse the Ulvenwald), so a +/// top-level-only read returns `None` for the very cards this axis exists for. +fn ability_chain_graveyard_type_threshold(ability: &AbilityDefinition) -> Option { + let here = ability + .condition + .as_ref() + .and_then(ability_graveyard_type_threshold); + let sub = ability + .sub_ability + .as_deref() + .and_then(ability_chain_graveyard_type_threshold); + let alt = ability + .else_ability + .as_deref() + .and_then(ability_chain_graveyard_type_threshold); + here.into_iter().chain(sub).chain(alt).max() +} + /// The `Or`-combinator rule shared by both extractors: yield a threshold only /// when EVERY disjunct is itself a graveyard threshold, and then the minimum — /// the easiest branch self-mill can satisfy. Any `None` child (a branch that @@ -375,43 +439,51 @@ pub(crate) fn modification_quantity( } } -/// CR 701.17 + CR 701.25 + CR 404.1: an ability chain that puts cards into the -/// CONTROLLER's own graveyard — self-mill, self-discard, or surveil. +/// CR 701.17 + CR 701.25 + CR 701.20e + CR 404.1: true when any ability chain +/// OR trigger body puts cards into the CONTROLLER's own graveyard — self-mill, +/// self-discard, surveil, or a rest-to-graveyard dig. /// -/// An opponent-scoped mill is deliberately excluded: filling an opponent's -/// graveyard does nothing for this deck's threshold (and actively helps a -/// Goyf-style symmetric count, which this axis does not chase). -pub(crate) fn fills_own_graveyard_parts<'a>( +/// Both carriers matter and missing either zeroes the axis: the archetypal +/// enabler (Stitcher's Supplier) is `abilities: []` with mill *triggers*, and +/// `compute_commitment`'s geometric mean collapses to `0.0` when +/// `enabler_count == 0`, which drops the deck below `GRAVEYARD_TYPES_FLOOR` and +/// switches the policy off entirely. Mirrors `tokens_wide::is_token_generator_parts`. +/// +/// The per-effect question delegates to +/// [`crate::features::reanimator::effect_fills_own_graveyard`] — the single +/// authority both graveyard axes share, so they cannot drift on which effects +/// and scopes count. An opponent-scoped mill is excluded there: filling an +/// opponent's graveyard does nothing for this deck's threshold. +pub(crate) fn fills_own_graveyard_parts( + abilities: &[AbilityDefinition], + triggers: &[TriggerDefinition], +) -> bool { + let chain_fills = |ability: &AbilityDefinition| { + collect_chain_effects(ability) + .iter() + .copied() + .any(crate::features::reanimator::effect_fills_own_graveyard) + }; + if abilities.iter().any(&chain_fills) { + return true; + } + // CR 603.6a: "when this enters, mill three" lives on the trigger body. + triggers + .iter() + .any(|trigger| trigger.execute.as_deref().is_some_and(&chain_fills)) +} + +/// The live-policy companion to [`fills_own_graveyard_parts`]: the same +/// single-authority question asked over an already-resolved effect chain (a +/// cast's spell abilities plus its immediate ETB triggers, or one activated +/// ability), where the carrier split has already been made by the caller. +pub(crate) fn abilities_fill_own_graveyard<'a>( abilities: impl IntoIterator, ) -> bool { abilities.into_iter().any(|ability| { collect_chain_effects(ability) .iter() - .any(|effect| match effect { - Effect::Mill { - target, - destination, - .. - } => { - *destination == engine::types::zones::Zone::Graveyard - && filter_is_controller_scoped(target) - } - Effect::DiscardCard { target, .. } => filter_is_controller_scoped(target), - Effect::Discard { target, .. } => filter_is_controller_scoped(target), - Effect::Surveil { target, .. } => filter_is_controller_scoped(target), - _ => false, - }) + .copied() + .any(crate::features::reanimator::effect_fills_own_graveyard) }) } - -/// True when a `TargetFilter` resolves to the ability's own controller. -fn filter_is_controller_scoped(filter: &TargetFilter) -> bool { - match filter { - TargetFilter::Controller | TargetFilter::SelfRef => true, - TargetFilter::Typed(typed) => matches!(typed.controller, Some(ControllerRef::You)), - TargetFilter::Or { filters } => filters.iter().any(filter_is_controller_scoped), - // CR 109.3: every constraint of a conjunction must hold. - TargetFilter::And { filters } => filters.iter().all(filter_is_controller_scoped), - _ => false, - } -} diff --git a/crates/phase-ai/src/features/reanimator.rs b/crates/phase-ai/src/features/reanimator.rs index 25419019c7..41ae1fe9bd 100644 --- a/crates/phase-ai/src/features/reanimator.rs +++ b/crates/phase-ai/src/features/reanimator.rs @@ -235,28 +235,58 @@ fn effect_is_reanimation(effect: &Effect) -> bool { ) } -/// CR 701.17a / CR 701.9a: an effect that fills the controller's own graveyard. +/// CR 701.17a / CR 701.9a / CR 701.25a / CR 701.20e: an effect that fills the +/// controller's own graveyard. +/// +/// **Single authority for "does this effect load my own graveyard".** Shared by +/// the reanimator axis (what is there to bring back) and the graveyard +/// type-diversity axis (how wide the graveyard's type spread is). Those axes +/// measure different resources but ask this one question identically, so they +/// must not answer it from two divergent local copies. +/// /// A self-mill must deposit into the graveyard (not exile); a discard always -/// goes to the graveyard. Both must be controller-scoped — a `Player`-targeted -/// mill/discard is opponent disruption (Mind Rot, Glimpse the Unthinkable), not -/// a self-enabler. -fn effect_fills_own_graveyard(effect: &Effect) -> bool { +/// goes there. `Surveil` (CR 701.25a) and a `Dig` whose *rest* goes to the +/// graveyard (CR 701.20e — "look at N, put one in hand, the rest into your +/// graveyard") both deposit as well, and the latter is the densest type-spread +/// filler on the board. Every form must be controller-scoped — a +/// `Player`-targeted mill/discard is opponent disruption (Mind Rot, Glimpse the +/// Unthinkable), not a self-enabler. +pub(crate) fn effect_fills_own_graveyard(effect: &Effect) -> bool { match effect { Effect::Mill { target, destination, .. } => *destination == Zone::Graveyard && target_fills_own_graveyard(target), - Effect::Discard { target, .. } => target_fills_own_graveyard(target), + Effect::Discard { target, .. } | Effect::DiscardCard { target, .. } => { + target_fills_own_graveyard(target) + } + Effect::Surveil { target, .. } => target_fills_own_graveyard(target), + // CR 701.20e: only the rest-to-graveyard form deposits; a Dig whose + // remainder goes to the bottom of the library fills nothing. + Effect::Dig { + player, + rest_destination, + .. + } => *rest_destination == Some(Zone::Graveyard) && target_fills_own_graveyard(player), _ => false, } } -/// CR 608.2c: "you mill/discard" (`Controller`) and the unspecified default -/// (`Any`, e.g. "discard two cards" / "mill three cards") load the resolving -/// player's own graveyard. An explicitly targeted `Player` is opponent-facing. +/// CR 608.2c: "you mill/discard" (`Controller`), the source itself (`SelfRef`), +/// an explicitly you-controlled scope (`Typed { controller: You }`), and the +/// unspecified default (`Any`, e.g. "discard two cards" / "mill three cards") +/// all load the resolving player's own graveyard. An explicitly targeted +/// `Player` is opponent-facing. fn target_fills_own_graveyard(filter: &TargetFilter) -> bool { - matches!(filter, TargetFilter::Controller | TargetFilter::Any) + match filter { + TargetFilter::Controller | TargetFilter::Any | TargetFilter::SelfRef => true, + TargetFilter::Typed(typed) => matches!(typed.controller, Some(ControllerRef::You)), + TargetFilter::Or { filters } => filters.iter().any(target_fills_own_graveyard), + // Every constraint of a conjunction must hold for the match. + TargetFilter::And { filters } => filters.iter().all(target_fills_own_graveyard), + _ => false, + } } /// CR 608.2b: unwrap a reanimation target filter. Accepts a filter that diff --git a/crates/phase-ai/src/features/tests/graveyard_types.rs b/crates/phase-ai/src/features/tests/graveyard_types.rs index 0f36e7c4d3..6a7aae0f80 100644 --- a/crates/phase-ai/src/features/tests/graveyard_types.rs +++ b/crates/phase-ai/src/features/tests/graveyard_types.rs @@ -4,9 +4,10 @@ use engine::game::DeckEntry; use engine::types::ability::{ - AbilityDefinition, AbilityKind, CardTypeSetSource, Comparator, ContinuousModification, - ControllerRef, CountScope, Effect, QuantityExpr, QuantityRef, StaticCondition, - StaticDefinition, TargetFilter, TriggerCondition, TriggerDefinition, TypedFilter, ZoneRef, + AbilityCondition, AbilityDefinition, AbilityKind, CardTypeSetSource, Comparator, + ContinuousModification, ControllerRef, CountScope, DigSource, Effect, QuantityExpr, + QuantityRef, StaticCondition, StaticDefinition, TargetFilter, TriggerCondition, + TriggerDefinition, TypedFilter, ZoneRef, }; use engine::types::card::CardFace; use engine::types::card_type::{CardType, CoreType}; @@ -105,6 +106,85 @@ fn self_mill_enabler(name: &str) -> CardFace { face } +/// Stitcher's Supplier shape: `abilities: []`, the mill rides a TRIGGER body. +/// The archetypal delirium enabler — and the shape that made the whole axis +/// inert when only `abilities` was scanned. +fn trigger_mill_enabler(name: &str) -> CardFace { + let mut face = creature(name); + face.triggers = + vec![ + TriggerDefinition::new(TriggerMode::ChangesZone).execute(AbilityDefinition::new( + AbilityKind::Spell, + Effect::Mill { + count: QuantityExpr::Fixed { value: 3 }, + target: TargetFilter::Controller, + destination: Zone::Graveyard, + }, + )), + ]; + face +} + +/// CR 701.20e: "look at N, keep one, rest into your graveyard" — the densest +/// type-spread filler there is. `in_trigger` picks the Satyr Wayfinder shape +/// (trigger-borne) vs the Grisly Salvage shape (ability-borne). +fn dig_to_graveyard_enabler(name: &str, in_trigger: bool) -> CardFace { + let dig = Effect::Dig { + player: TargetFilter::Controller, + count: QuantityExpr::Fixed { value: 4 }, + destination: None, + keep_count: Some(1), + keep_count_expr: None, + up_to: false, + filter: TargetFilter::Any, + rest_destination: Some(Zone::Graveyard), + reveal: true, + enter_tapped: false, + source: DigSource::Library, + }; + let mut face = creature(name); + if in_trigger { + face.triggers = vec![TriggerDefinition::new(TriggerMode::ChangesZone) + .execute(AbilityDefinition::new(AbilityKind::Spell, dig))]; + } else { + face.abilities = vec![AbilityDefinition::new(AbilityKind::Spell, dig)]; + } + face +} + +/// Traverse the Ulvenwald shape: the delirium gate is NOT at +/// `abilities[0].condition` but at `abilities[0].sub_ability.condition`, wrapped +/// in `ConditionInstead` — "Delirium — ... instead search ...". The third +/// condition carrier, and only reachable by walking the sub_ability chain. +fn ability_chain_threshold_payoff(name: &str, threshold: i32) -> CardFace { + let mut sub = AbilityDefinition::new( + AbilityKind::Spell, + Effect::Draw { + count: QuantityExpr::Fixed { value: 1 }, + target: TargetFilter::Controller, + }, + ); + sub.condition = Some(AbilityCondition::ConditionInstead { + inner: Box::new(AbilityCondition::QuantityCheck { + lhs: own_graveyard_types(), + comparator: Comparator::GE, + rhs: QuantityExpr::Fixed { value: threshold }, + }), + }); + let mut root = AbilityDefinition::new( + AbilityKind::Spell, + Effect::Draw { + count: QuantityExpr::Fixed { value: 1 }, + target: TargetFilter::Controller, + }, + ); + root.sub_ability = Some(Box::new(sub)); + + let mut face = creature(name); + face.abilities = vec![root]; + face +} + #[test] fn empty_deck_produces_defaults() { let feature = detect(&[]); @@ -504,3 +584,100 @@ fn control_deck_below_floor() { 0.0 ); } + +// ─── the three condition/effect carriers (regression guards) ──────────────── + +/// Blocker regression: Stitcher's Supplier is `abilities: []` with mill +/// TRIGGERS. Reading only `abilities` left `enabler_count == 0`, which drives +/// `compute_commitment`'s geometric mean to 0.0 and switches the whole axis off. +#[test] +fn trigger_borne_mill_is_an_enabler() { + let feature = detect(&[entry(trigger_mill_enabler("Stitcher's Supplier"), 4)]); + assert_eq!(feature.enabler_count, 4); +} + +/// The mirror of the above through the real commitment path: a delirium shell +/// whose enablers are ALL trigger-borne must still clear the policy floor. +#[test] +fn trigger_only_enablers_still_clear_the_floor() { + let deck = vec![ + entry( + threshold_payoff("Backwoods Survivalists", 4, own_graveyard_types()), + 8, + ), + entry(trigger_mill_enabler("Stitcher's Supplier"), 8), + entry(creature("Filler"), 21), + ]; + let feature = detect(&deck); + assert_eq!(feature.enabler_count, 8); + assert!( + feature.commitment >= GRAVEYARD_TYPES_FLOOR, + "trigger-borne enablers must not zero the geometric mean, got {}", + feature.commitment + ); +} + +/// CR 701.20e: a rest-to-graveyard dig is an enabler from either carrier. +#[test] +fn dig_to_graveyard_is_an_enabler_from_either_carrier() { + for in_trigger in [true, false] { + let feature = detect(&[entry( + dig_to_graveyard_enabler("Satyr Wayfinder", in_trigger), + 4, + )]); + assert_eq!( + feature.enabler_count, 4, + "dig-to-graveyard must count (in_trigger={in_trigger})" + ); + } +} + +/// A dig whose remainder goes to the bottom of the library deposits nothing. +#[test] +fn dig_without_graveyard_rest_is_not_an_enabler() { + let mut face = dig_to_graveyard_enabler("Impulse", false); + face.abilities = vec![AbilityDefinition::new( + AbilityKind::Spell, + Effect::Dig { + player: TargetFilter::Controller, + count: QuantityExpr::Fixed { value: 4 }, + destination: None, + keep_count: Some(1), + keep_count_expr: None, + up_to: false, + filter: TargetFilter::Any, + rest_destination: None, + reveal: false, + enter_tapped: false, + source: DigSource::Library, + }, + )]; + assert_eq!(detect(&[entry(face, 4)]).enabler_count, 0); +} + +/// Blocker regression: the third condition carrier. Traverse the Ulvenwald's +/// gate sits on `abilities[0].sub_ability.condition`, so a top-level-only read +/// (or one that skipped `abilities` entirely) returned `None`. +#[test] +fn threshold_in_the_ability_chain_is_detected() { + let feature = detect(&[entry( + ability_chain_threshold_payoff("Traverse the Ulvenwald", 4), + 4, + )]); + assert_eq!(feature.threshold_payoff_count, 4); + assert_eq!(feature.highest_threshold, Some(4)); +} + +/// The ability-chain carrier feeds the same `.max()` as the other two, so a +/// descend-8 gate there still raises the deck's highest threshold. +#[test] +fn ability_chain_threshold_participates_in_highest_threshold() { + let deck = vec![ + entry( + threshold_payoff("Delirium Four", 4, own_graveyard_types()), + 2, + ), + entry(ability_chain_threshold_payoff("Descend Eight", 8), 2), + ]; + assert_eq!(detect(&deck).highest_threshold, Some(8)); +} diff --git a/crates/phase-ai/src/policies/graveyard_types.rs b/crates/phase-ai/src/policies/graveyard_types.rs index e14b13c581..25742de5c2 100644 --- a/crates/phase-ai/src/policies/graveyard_types.rs +++ b/crates/phase-ai/src/policies/graveyard_types.rs @@ -16,7 +16,7 @@ //! A *threshold* payoff (delirium, descend N) goes live at its threshold; once //! the graveyard reaches it, more diversity buys nothing and the branch scores //! zero — otherwise the AI would durdle with self-mill after delirium is on. A -//! *scaling* payoff (Consuming Blob, Tarmogoyf) has no threshold and keeps +//! *scaling* payoff (Consuming Blob) has no threshold and keeps //! wanting a bigger, more diverse graveyard, so it is rewarded continuously //! with a diminishing signal. A deck's threshold is modelled as `Option` //! precisely so a scaling-only deck is never handed a fabricated four-type @@ -28,8 +28,9 @@ //! The card-local AST check (`fills_own_graveyard_parts`, over the action's //! authoritative effect chain) runs FIRST and rejects the overwhelming majority //! of candidates; only a confirmed graveyard-filler pays for the graveyard -//! scan, which walks one zone's objects and never touches the battlefield, mana -//! affordability, or `find_legal_targets`. +//! scan. `GameState` carries no zone index, so that scan filters +//! `state.objects` (house practice) — but it never touches mana affordability +//! or `find_legal_targets`. use std::collections::HashSet; @@ -39,7 +40,7 @@ use engine::types::game_state::GameState; use engine::types::player::PlayerId; use engine::types::zones::Zone; -use crate::features::graveyard_types::{fills_own_graveyard_parts, GRAVEYARD_TYPES_FLOOR}; +use crate::features::graveyard_types::{abilities_fill_own_graveyard, GRAVEYARD_TYPES_FLOOR}; use crate::features::DeckFeatures; use super::context::PolicyContext; @@ -110,12 +111,11 @@ impl TacticalPolicy for GraveyardTypesPolicy { // so a tuned-up `scalar` auto-bands instead of tripping a band assert. if let Some(threshold) = threshold { if current < threshold { + // `scalar / deficit` already peaks at `scalar` when the + // deficit is 1 — the last missing type is what turns the + // payoffs on, and no separate branch is needed to say so. let deficit = threshold - current; - let delta = if deficit == 1 { - scalar - } else { - scalar / f64::from(deficit) - }; + let delta = scalar / f64::from(deficit); return PolicyVerdict::score( delta, PolicyReason::new("graveyard_types_progress") @@ -127,8 +127,8 @@ impl TacticalPolicy for GraveyardTypesPolicy { // At/over the threshold, or no threshold at all: only a SCALING payoff // still wants a bigger, more diverse graveyard. Diminishing (the nth - // type matters less than the first) but never zero, so a Tarmogoyf / - // Consuming Blob deck keeps being rewarded past four types. + // type matters less than the first) but never zero, so a Consuming + // Blob deck keeps being rewarded past four types. if has_scaling { return PolicyVerdict::score( scalar / f64::from(current + 1), @@ -150,20 +150,28 @@ impl TacticalPolicy for GraveyardTypesPolicy { /// /// The lookup respects the action's authoritative ability semantics: /// * `CastSpell` → the spell's own resolution chain (`CastFacts::primary_effects`, -/// which is the `AbilityKind::Spell` abilities only). Casting a permanent that -/// merely *has* an activated self-mill ability does not qualify — the cast -/// itself fills no graveyard (CR 601.2). +/// the `AbilityKind::Spell` abilities) **plus its immediate ETB triggers** +/// (`CastFacts::immediate_etb_triggers`). CR 601.2 excludes *activated* +/// abilities — casting a permanent that merely has an activated self-mill does +/// not qualify — but an ETB trigger fires as a consequence of the cast, which +/// is exactly why `CastFacts` carries it as its own field. Excluding it made +/// the archetypal delirium play (casting Stitcher's Supplier or Satyr +/// Wayfinder) score `graveyard_types_na`. /// * `ActivateAbility` → the ability at the engine's runtime-enumerated index /// (`effective_activated_ability`), which is the correct index space for /// runtime-granted abilities where `GameObject::abilities` is not (CR 602.2). fn candidate_fills_own_graveyard(ctx: &PolicyContext<'_>) -> bool { match &ctx.candidate.action { - GameAction::CastSpell { .. } => ctx - .cast_facts() - .is_some_and(|facts| fills_own_graveyard_parts(facts.primary_effects.iter().copied())), + GameAction::CastSpell { .. } => ctx.cast_facts().is_some_and(|facts| { + let etb_bodies = facts + .immediate_etb_triggers + .iter() + .filter_map(|trigger| trigger.execute.as_deref()); + abilities_fill_own_graveyard(facts.primary_effects.iter().copied().chain(etb_bodies)) + }), GameAction::ActivateAbility { .. } => ctx .effective_activated_ability() - .is_some_and(|ability| fills_own_graveyard_parts(std::iter::once(&ability))), + .is_some_and(|ability| abilities_fill_own_graveyard(std::iter::once(&ability))), _ => false, } } diff --git a/crates/phase-ai/src/policies/tests/graveyard_types.rs b/crates/phase-ai/src/policies/tests/graveyard_types.rs index 70e7cae44e..1eae43e60d 100644 --- a/crates/phase-ai/src/policies/tests/graveyard_types.rs +++ b/crates/phase-ai/src/policies/tests/graveyard_types.rs @@ -10,12 +10,15 @@ use std::sync::Arc; use engine::ai_support::{ActionMetadata, AiDecisionContext, CandidateAction, TacticalClass}; use engine::game::zones::create_object; -use engine::types::ability::{AbilityDefinition, AbilityKind, Effect, QuantityExpr, TargetFilter}; +use engine::types::ability::{ + AbilityDefinition, AbilityKind, Effect, QuantityExpr, TargetFilter, TriggerDefinition, +}; use engine::types::actions::GameAction; use engine::types::card_type::{CardType, CoreType}; use engine::types::game_state::{CastPaymentMode, GameState, WaitingFor}; use engine::types::identifiers::{CardId, ObjectId}; use engine::types::player::PlayerId; +use engine::types::triggers::TriggerMode; use engine::types::zones::Zone; use crate::config::AiConfig; @@ -500,3 +503,92 @@ fn verdict_ignores_a_non_filling_spell() { assert_eq!(reason.kind, "graveyard_types_na"); assert_eq!(delta, 0.0); } + +// ─── ETB-trigger casts (the delirium play the gate used to drop) ───────────── + +/// Stitcher's Supplier shape: a creature whose self-mill rides an ETB TRIGGER, +/// not a spell ability. `CastFacts` carries it as `immediate_etb_triggers` +/// precisely because it fires as a consequence of the cast. +fn etb_mill_creature(state: &mut GameState, idx: u64) -> ObjectId { + let oid = create_object( + state, + CardId(idx), + AI, + format!("Stitcher {idx}"), + Zone::Hand, + ); + let object = state.objects.get_mut(&oid).unwrap(); + object.card_id = CardId(oid.0); + object.card_types = CardType { + supertypes: Vec::new(), + core_types: vec![CoreType::Creature], + subtypes: Vec::new(), + }; + *Arc::make_mut(&mut object.abilities) = Vec::new(); + object.trigger_definitions.push( + TriggerDefinition::new(TriggerMode::ChangesZone) + .valid_card(TargetFilter::SelfRef) + .destination(Zone::Battlefield) + .execute(AbilityDefinition::new( + AbilityKind::Spell, + self_mill_effect(), + )), + ); + oid +} + +/// Casting the archetypal delirium enabler must score. Excluding ETB triggers +/// from the cast gate made this exact play return `graveyard_types_na`. +#[test] +fn verdict_credits_a_cast_whose_etb_trigger_mills() { + let config = config(); + let context = context_with(&config, Some(4), 0); + let mut state = state(); + seed_graveyard_types(&mut state, 3); + let stitcher = etb_mill_creature(&mut state, 1); + let decision = priority_decision(); + + let (delta, reason) = score_of(GraveyardTypesPolicy.verdict(&ctx( + &state, + &cast_candidate(stitcher), + &decision, + &context, + &config, + ))); + assert_eq!(reason.kind, "graveyard_types_progress"); + assert!(delta > 0.0); +} + +/// Control: an ETB trigger that does NOT fill the graveyard stays neutral, so +/// the branch discriminates on the trigger body rather than on "has any ETB". +#[test] +fn verdict_ignores_a_cast_whose_etb_trigger_does_not_fill() { + let config = config(); + let context = context_with(&config, Some(4), 0); + let mut state = state(); + seed_graveyard_types(&mut state, 3); + let oid = etb_mill_creature(&mut state, 1); + state.objects.get_mut(&oid).unwrap().trigger_definitions = Default::default(); + state + .objects + .get_mut(&oid) + .unwrap() + .trigger_definitions + .push( + TriggerDefinition::new(TriggerMode::ChangesZone) + .valid_card(TargetFilter::SelfRef) + .destination(Zone::Battlefield) + .execute(AbilityDefinition::new(AbilityKind::Spell, draw_effect())), + ); + let decision = priority_decision(); + + let (delta, reason) = score_of(GraveyardTypesPolicy.verdict(&ctx( + &state, + &cast_candidate(oid), + &decision, + &context, + &config, + ))); + assert_eq!(reason.kind, "graveyard_types_na"); + assert_eq!(delta, 0.0); +} From 691f7e44f1aac42964106ae76f02311134ee6cff Mon Sep 17 00:00:00 2001 From: minion1227 Date: Thu, 23 Jul 2026 18:58:02 -0700 Subject: [PATCH 4/4] test(phase-ai): cover Not/And/Or on the ability-chain threshold carrier (#6544) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The static- and trigger-condition carriers each have negation and compound-condition regression tests; the new `AbilityCondition` carrier shipped with positive-path coverage only, so its `Not => None` rejection and its And-max / Or-all-then-min rules were unguarded. Adds, through the Traverse-shaped sub_ability fixture: * negated "N or more types" is not a payoff (the inverted "fewer than N" gate), matching the other two carriers, * a non-positive comparator on the same carrier is likewise rejected, * `And` takes the highest mandatory threshold, `Or` yields one only when every branch is a graveyard threshold (else the payoff can fire without delirium). cargo test -p phase-ai --lib — 1412 passed (graveyard suite 47 → 50) cargo clippy -p phase-ai -p engine --all-targets --features proptest — clean Co-Authored-By: Claude Opus 4.8 (1M context) --- .../src/features/tests/graveyard_types.rs | 102 ++++++++++++++++++ 1 file changed, 102 insertions(+) diff --git a/crates/phase-ai/src/features/tests/graveyard_types.rs b/crates/phase-ai/src/features/tests/graveyard_types.rs index 6a7aae0f80..ffea6ef714 100644 --- a/crates/phase-ai/src/features/tests/graveyard_types.rs +++ b/crates/phase-ai/src/features/tests/graveyard_types.rs @@ -681,3 +681,105 @@ fn ability_chain_threshold_participates_in_highest_threshold() { ]; assert_eq!(detect(&deck).highest_threshold, Some(8)); } + +/// Build an ability-chain payoff whose gate is an arbitrary `AbilityCondition` +/// on the sub-ability — the Traverse carrier, parameterized for the negation and +/// compound cases below. +fn ability_chain_gated_by(name: &str, condition: AbilityCondition) -> CardFace { + let mut sub = AbilityDefinition::new( + AbilityKind::Spell, + Effect::Draw { + count: QuantityExpr::Fixed { value: 1 }, + target: TargetFilter::Controller, + }, + ); + sub.condition = Some(condition); + let mut root = AbilityDefinition::new( + AbilityKind::Spell, + Effect::Draw { + count: QuantityExpr::Fixed { value: 1 }, + target: TargetFilter::Controller, + }, + ); + root.sub_ability = Some(Box::new(sub)); + let mut face = creature(name); + face.abilities = vec![root]; + face +} + +fn ability_quantity_check(comparator: Comparator, threshold: i32) -> AbilityCondition { + AbilityCondition::QuantityCheck { + lhs: own_graveyard_types(), + comparator, + rhs: QuantityExpr::Fixed { value: threshold }, + } +} + +/// CR 205.2a: negating "N or more types" is a "fewer than N" gate — the third +/// carrier rejects it exactly like the static and trigger carriers do. +#[test] +fn negated_ability_chain_threshold_is_not_a_payoff() { + let face = ability_chain_gated_by( + "Anti-Delirium Chain", + AbilityCondition::Not { + condition: Box::new(ability_quantity_check(Comparator::GE, 4)), + }, + ); + let feature = detect(&[entry(face, 4)]); + assert_eq!(feature.threshold_payoff_count, 0); + assert_eq!(feature.highest_threshold, None); +} + +/// A non-positive comparator on the ability carrier is rejected too. +#[test] +fn non_positive_comparator_in_the_ability_chain_is_not_a_payoff() { + let face = ability_chain_gated_by( + "Fewer Types Chain", + ability_quantity_check(Comparator::LT, 4), + ); + assert_eq!(detect(&[entry(face, 4)]).threshold_payoff_count, 0); +} + +/// The ability carrier follows the same combinator rules as the other two: +/// `And` is a mandatory gate (take the max), `Or` only when every branch is a +/// graveyard threshold. +#[test] +fn ability_chain_and_or_follow_the_shared_combinator_rules() { + let and_face = ability_chain_gated_by( + "Conjunctive Chain", + AbilityCondition::And { + conditions: vec![ + ability_quantity_check(Comparator::GE, 4), + ability_quantity_check(Comparator::GE, 6), + ], + }, + ); + assert_eq!(detect(&[entry(and_face, 1)]).highest_threshold, Some(6)); + + let all_gy_or = ability_chain_gated_by( + "All-Graveyard Or Chain", + AbilityCondition::Or { + conditions: vec![ + ability_quantity_check(Comparator::GE, 4), + ability_quantity_check(Comparator::GE, 6), + ], + }, + ); + assert_eq!(detect(&[entry(all_gy_or, 1)]).highest_threshold, Some(4)); + + // One branch that delirium does not gate → the payoff can fire without it. + let mixed_or = ability_chain_gated_by( + "Mixed Or Chain", + AbilityCondition::Or { + conditions: vec![ + ability_quantity_check(Comparator::GE, 4), + AbilityCondition::QuantityCheck { + lhs: opponent_graveyard_types(), + comparator: Comparator::GE, + rhs: QuantityExpr::Fixed { value: 4 }, + }, + ], + }, + ); + assert_eq!(detect(&[entry(mixed_or, 1)]).threshold_payoff_count, 0); +}