diff --git a/crates/phase-ai/src/config.rs b/crates/phase-ai/src/config.rs index 29f2a56d70..69504451e0 100644 --- a/crates/phase-ai/src/config.rs +++ b/crates/phase-ai/src/config.rs @@ -468,6 +468,11 @@ pub struct PolicyPenalties { /// clock progress below that. #[serde(default = "default_poison_clock_pressure")] pub poison_clock_pressure: 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. + #[serde(default = "default_graveyard_types_progress")] + pub graveyard_types_progress: f64, } impl Default for PolicyPenalties { @@ -533,10 +538,18 @@ impl Default for PolicyPenalties { cycling_needed_land_penalty: default_cycling_needed_land_penalty(), loop_shortcut_winning_declare_bonus: default_loop_shortcut_winning_declare_bonus(), poison_clock_pressure: default_poison_clock_pressure(), + 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 } @@ -742,6 +755,11 @@ pub const UNTUNED_POLICY_PENALTY_FIELDS: &[(&str, &str)] = &[ load-bearing for correctness, not taste. Promote to ACTIVE only with a \ paired-seed ai-gate calibration.", ), + ( + "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", @@ -1543,6 +1561,37 @@ mod tests { ); } + /// 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 new file mode 100644 index 0000000000..e6a6c5a3bf --- /dev/null +++ b/crates/phase-ai/src/features/graveyard_types.rs @@ -0,0 +1,489 @@ +//! 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`, `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. +//! +//! ## 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::{ + AbilityCondition, AbilityDefinition, CardTypeSetSource, Comparator, CountScope, QuantityExpr, + QuantityRef, StaticCondition, TriggerCondition, TriggerDefinition, 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 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-likes). + pub scaling_payoff_count: u32, + /// Cards that put cards into the controller's own graveyard — self-mill, + /// 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) 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, + /// 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: Option = None; + 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); + } + + // 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() + .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), + ) + .chain( + face.abilities + .iter() + .filter_map(ability_chain_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); + // `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. + 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, &face.triggers) { + 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, + // No fabricated fallback: `None` when the deck has no threshold payoff. + 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 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 +/// 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 `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, 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, + 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(), + // 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, + } +} + +/// 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, + 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 { .. } => None, + _ => None, + } +} + +/// 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 +/// 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`). +/// +/// 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!( + qty, + QuantityRef::DistinctCardTypes { + source: CardTypeSetSource::Zone { + zone: ZoneRef::Graveyard, + // CR 108.3 + CR 109.5: "your graveyard" — the controller as + // owner over a non-battlefield zone. + scope: CountScope::Controller | CountScope::Owner, + }, + } + ), + 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 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. +/// +/// 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() + .copied() + .any(crate::features::reanimator::effect_fills_own_graveyard) + }) +} diff --git a/crates/phase-ai/src/features/mod.rs b/crates/phase-ai/src/features/mod.rs index 798a031ad8..521b512fd5 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; @@ -37,6 +38,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; @@ -82,6 +84,8 @@ pub struct DeckFeatures { pub energy: EnergyFeature, /// CR 104.3d: the alternate poison win clock (toxic / infect / proliferate). pub poison: PoisonFeature, + /// 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 @@ -127,6 +131,7 @@ impl DeckFeatures { mill: mill::detect(deck), energy: energy::detect(deck), poison: poison::detect(deck), + graveyard_types: graveyard_types::detect(deck), bracket_tier: tier, } } 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 new file mode 100644 index 0000000000..ffea6ef714 --- /dev/null +++ b/crates/phase-ai/src/features/tests/graveyard_types.rs @@ -0,0 +1,785 @@ +//! 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::{ + 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}; +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 +} + +/// 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(&[]); + 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, Some(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, 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] +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 + ); +} + +// ─── 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)); +} + +/// 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); +} diff --git a/crates/phase-ai/src/features/tests/mod.rs b/crates/phase-ai/src/features/tests/mod.rs index a7a85c1374..c22837884e 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..25742de5c2 --- /dev/null +++ b/crates/phase-ai/src/policies/graveyard_types.rs @@ -0,0 +1,177 @@ +//! `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. +//! +//! ## When the axis stops rewarding +//! +//! 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) 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 action's +//! authoritative effect chain) runs FIRST and rejects the overwhelming majority +//! of candidates; only a confirmed graveyard-filler pays for the graveyard +//! 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; + +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::{abilities_fill_own_graveyard, 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, 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 feature = ctx + .context + .session + .features + .get(&ctx.ai_player) + .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; + + // 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 { + // `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 = 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 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), + ); + } + + // 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), + ) + } +} + +/// 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`, +/// 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| { + 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| abilities_fill_own_graveyard(std::iter::once(&ability))), + _ => false, + } +} diff --git a/crates/phase-ai/src/policies/mod.rs b/crates/phase-ai/src/policies/mod.rs index bba1bcf225..e645221242 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 1da04d6872..f36b43e734 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; @@ -132,6 +133,8 @@ pub enum PolicyId { LoopShortcut, /// CR 104.3d: the alternate poison win clock. PoisonClock, + /// CR 207.2c + CR 205.2a: delirium / descend graveyard type-diversity. + GraveyardTypes, } /// Coarse routing kind for a candidate decision. Each policy declares which @@ -324,6 +327,7 @@ impl Default for PolicyRegistry { Box::new(BoardDevelopmentPolicy), Box::new(EtbValuePolicy), Box::new(PoisonClockPolicy), + 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..1eae43e60d --- /dev/null +++ b/crates/phase-ai/src/policies/tests/graveyard_types.rs @@ -0,0 +1,594 @@ +//! 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 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, 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; +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::{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; + assert!(GraveyardTypesPolicy + .activation(&features, &state(), AI) + .is_none()); +} + +#[test] +fn activation_opts_in_above_floor() { + let mut features = DeckFeatures::default(); + features.graveyard_types.commitment = 0.9; + assert_eq!( + GraveyardTypesPolicy.activation(&features, &state(), AI), + Some(0.9) + ); +} + +/// CR 404.1: an empty graveyard has zero distinct card types. +#[test] +fn empty_graveyard_counts_zero_types() { + 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); +} + +// ─── 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); +} diff --git a/crates/phase-ai/src/policies/tests/mod.rs b/crates/phase-ai/src/policies/tests/mod.rs index 032349f134..ecaaa98afb 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;