diff --git a/crates/phase-ai/src/config.rs b/crates/phase-ai/src/config.rs index 69504451e0..fe4194cd35 100644 --- a/crates/phase-ai/src/config.rs +++ b/crates/phase-ai/src/config.rs @@ -473,6 +473,14 @@ pub struct PolicyPenalties { /// action supplies the last missing type. #[serde(default = "default_graveyard_types_progress")] pub graveyard_types_progress: f64, + /// CR 700.5: card-equivalent value of one primary-color pip a cast adds + /// toward the deck's devotion payoffs (preference band, per pip). + #[serde(default = "default_devotion_pip_progress")] + pub devotion_pip_progress: f64, + /// CR 700.5: extra value when a cast crosses a god's `DevotionGE` + /// threshold, turning a non-creature enchantment into a body. + #[serde(default = "default_devotion_god_activation")] + pub devotion_god_activation: f64, } impl Default for PolicyPenalties { @@ -539,6 +547,8 @@ impl Default for PolicyPenalties { 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(), + devotion_pip_progress: default_devotion_pip_progress(), + devotion_god_activation: default_devotion_god_activation(), } } } @@ -613,6 +623,14 @@ fn default_lethality_tapout_penalty() -> f64 { fn default_sacrifice_land_penalty() -> f64 { 4.0 } + +fn default_devotion_pip_progress() -> f64 { + 0.35 +} + +fn default_devotion_god_activation() -> f64 { + 2.5 +} fn default_sacrifice_token_cost() -> f64 { 0.5 } @@ -749,6 +767,14 @@ 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)] = &[ + ( + "devotion_pip_progress", + "CR 700.5 per-pip devotion progress weight — awaiting a paired-seed ai-gate calibration.", + ), + ( + "devotion_god_activation", + "CR 700.5 god-threshold-crossing swing weight — awaiting a paired-seed ai-gate calibration.", + ), ( "poison_clock_pressure", "CR 104.3d win-detector weight — a critical-band term whose magnitude is \ @@ -1592,6 +1618,40 @@ mod tests { ); } + #[test] + fn policy_penalties_load_pre_devotion_artifact() { + let mut artifact = serde_json::to_value(PolicyPenalties::default()).unwrap(); + let object = artifact.as_object_mut().expect("serializes as object"); + object + .remove("devotion_pip_progress") + .expect("field must be present before removal"); + object + .remove("devotion_god_activation") + .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-devotion artifact must still deserialize"); + assert_eq!(loaded.wasted_cast_penalty, -3.5, "tuned value preserved"); + assert_eq!( + loaded.devotion_pip_progress, + default_devotion_pip_progress(), + "absent pip field must fall back to the shared default" + ); + assert_eq!( + loaded.devotion_god_activation, + default_devotion_god_activation(), + "absent god-activation field must fall back to the shared default" + ); + assert_eq!( + PolicyPenalties::default().devotion_pip_progress, + default_devotion_pip_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/devotion.rs b/crates/phase-ai/src/features/devotion.rs new file mode 100644 index 0000000000..8d7e7505cb --- /dev/null +++ b/crates/phase-ai/src/features/devotion.rs @@ -0,0 +1,453 @@ +//! Devotion feature — mono-color pip-density payoff detection (CR 700.5). +//! +//! Parser AST verification — VERIFIED against engine source: +//! - `StaticCondition::DevotionGE { colors: Vec, threshold: u32 }` at +//! `crates/engine/src/types/ability.rs:7070` — the Theros gods and +//! devotion-gated statics ("as long as your devotion to black is less than +//! five, Erebos isn't a creature", parsed as `RemoveType{Creature}` gated on +//! `Not{DevotionGE}`). +//! - `QuantityRef::Devotion { colors: DevotionColors }` at +//! `crates/engine/src/types/ability.rs:5574` — scaling payoffs (Gray +//! Merchant's drain, Anax's power) and Nykthos-style mana ramp (`Effect::Mana` +//! whose `ManaProduction` count is devotion). +//! - `DevotionColors::{Fixed(Vec), ChosenColor}` at +//! `crates/engine/src/types/ability.rs:1818`. +//! - Pip density reuses `ManaCost::count_colored_pips` (`types/mana.rs:1746`), +//! the single CR 700.5 counting authority (hybrid `{G/W}{G/W}` counts as 2). +//! +//! No parser remediation required. +//! +//! ## Why this axis exists +//! +//! CR 700.5: devotion to a color is the number of that color's mana symbols +//! among the mana costs of permanents you control. It is the payoff currency +//! for the Theros gods (which are not creatures below their threshold), Gray +//! Merchant-style drains, and Nykthos-style ramp — 43 cards in the corpus read +//! it. +//! The AI's evaluation models mana value and board presence but not pip +//! density, so it will not prefer a double-pip permanent over an off-color one, +//! nor see that a god is one pip from turning on. +//! +//! ## Boundary with `tribal` / `mana_ramp` +//! +//! A mono-color devotion deck often looks tribal or ramp-flavoured, but the +//! resource is distinct: devotion counts *colored pips*, not creatures of a +//! type or mana sources. A five-Forest ramp deck has high `mana_ramp` and zero +//! devotion; a {B}{B}-heavy Gray Merchant deck is the reverse. + +use engine::game::DeckEntry; +use engine::types::ability::{ + AbilityDefinition, DevotionColors, Effect, QuantityExpr, QuantityRef, StaticCondition, + StaticDefinition, +}; +use engine::types::card_type::CoreType; +use engine::types::mana::ManaColor; + +use crate::ability_chain::{collect_scoped_effects, AbilityScope}; +use crate::features::commitment; + +/// Commitment at or above which the deck is genuinely a devotion payoff deck +/// rather than an incidental mono-color splash. Gates `DevotionPolicy::activation`. +pub const DEVOTION_FLOOR: f32 = 0.35; + +/// CR 700.5 + CR 205.2: per-deck devotion classification. +/// +/// Detection is structural over `CardFace.static_abilities`, `.triggers`, +/// `.abilities` and `.mana_cost` — never by card name. +#[derive(Debug, Clone, Default)] +pub struct DevotionFeature { + /// Cards that pay off devotion — a `DevotionGE` gate (gods) or a + /// `QuantityRef::Devotion` read (drains, ramp, X-scaling). + pub payoff_count: u32, + /// The color SET the deck is most devoted to among the sets its payoffs + /// read — one color for a mono god (Erebos), two for a combined god + /// (Athreos W+B, Xenagos R+G). Empty when the deck has no devotion payoff. + /// The policy scores pip contributions against this set, counting a hybrid + /// pip once (CR 700.5). + pub primary_colors: Vec, + /// Colored-pip count toward `primary_colors` across the deck's permanent + /// faces (CR 700.5 counts permanents only). Drives commitment. + pub pip_count: u32, + /// Every DISTINCT `DevotionGE` gate, each keyed on its EXACT color set — a + /// two-color god's threshold is against combined devotion to both colors + /// (CR 700.5), not either component. Each god turns on independently, so the + /// policy rewards a cast that crosses ANY gate. Empty when the deck has no + /// threshold payoff, so a scaling-only deck is never handed a fabricated gate. + pub gates: Vec, + /// `0.0..=1.0` — how central devotion is. Consumed by + /// `DevotionPolicy::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, +} + +/// CR 700.5: a `DevotionGE` gate — a god that becomes a creature once devotion +/// to `colors` (combined, hybrids once) reaches `threshold`. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct DevotionGate { + pub colors: Vec, + pub threshold: u32, +} + +/// CR 700.5: colored pips in `cost` that count toward devotion to `colors` — +/// each mana symbol counted once even when it is hybrid across two of the +/// colors (`{W/B}` counts once for W+B devotion). The single-cost analogue of +/// `engine::game::devotion::count_devotion` (which sums over the battlefield). +pub(crate) fn cost_devotion_pips( + cost: &engine::types::mana::ManaCost, + colors: &[ManaColor], +) -> u32 { + let engine::types::mana::ManaCost::Cost { shards, .. } = cost else { + return 0; + }; + shards + .iter() + .filter(|shard| colors.iter().any(|c| shard.contributes_to(*c))) + .count() as u32 +} + +/// Structural detection over each `DeckEntry`'s `CardFace` AST. +pub fn detect(deck: &[DeckEntry]) -> DevotionFeature { + if deck.is_empty() { + return DevotionFeature::default(); + } + + let mut payoff_count = 0u32; + let mut total_nonland = 0u32; + let mut payoff_names: Vec = Vec::new(); + // Permanent-face mana costs (with copy counts) so candidate-set totals are + // computed SET-WISE below — a hybrid `{W/B}` shard counts once for a W+B set + // (CR 700.5), never once per color as a sum of per-color buckets would. + let mut permanent_costs: Vec<(&engine::types::mana::ManaCost, u32)> = Vec::new(); + // Every color SET a payoff demands, kept whole (a two-color god demands the + // pair, not each color), plus whether a `ChosenColor` payoff makes every + // color eligible (Nykthos). + let mut demanded_sets: Vec> = Vec::new(); + let mut any_chosen = false; + let mut gates: Vec = Vec::new(); + + let mut demand = |set: Vec| { + let set = normalize_colors(set); + if !set.is_empty() && !demanded_sets.contains(&set) { + demanded_sets.push(set); + } + }; + + 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); + } + + // CR 700.5 + CR 110.4: only permanents contribute devotion pips. Keep the + // whole cost so each candidate set is scored set-wise (hybrids once). + if face + .card_type + .core_types + .iter() + .any(|t| t.is_permanent_type()) + { + permanent_costs.push((&face.mana_cost, entry.count)); + } + + let gate = highest_devotion_gate(face); + let scales = reads_devotion(face); + if let Some((colors, threshold)) = &gate { + let colors = normalize_colors(colors.clone()); + demand(colors.clone()); + let candidate = DevotionGate { + colors, + threshold: *threshold, + }; + if !gates.contains(&candidate) { + gates.push(candidate); + } + } + for colors in scaling_payoff_colors(face) { + match colors { + DevotionColors::Fixed(cols) => demand(cols), + DevotionColors::ChosenColor => any_chosen = true, + } + } + + if gate.is_some() || scales { + payoff_count = payoff_count.saturating_add(entry.count); + payoff_names.push(face.name.clone()); + } + } + + // Candidate primary sets: every demanded set, plus each single color when a + // `ChosenColor` payoff (Nykthos) makes any color eligible. The primary is the + // set the deck is most devoted to. + let mut candidates = demanded_sets.clone(); + if any_chosen { + for color in ManaColor::ALL { + let single = vec![color]; + if !candidates.contains(&single) { + candidates.push(single); + } + } + } + // CR 700.5 set-wise: a candidate set's deck devotion is the pips each + // permanent face contributes to the SET as a whole (a hybrid symbol once), + // summed over copies. This is the same counting `count_devotion` performs at + // runtime — never a sum of independent per-color buckets, which would count a + // `{W/B}` shard twice toward a W+B set and inflate commitment. + let set_total = |set: &[ManaColor]| -> u32 { + permanent_costs + .iter() + .map(|&(cost, count)| cost_devotion_pips(cost, set).saturating_mul(count)) + .sum() + }; + let primary_colors = candidates + .into_iter() + .max_by_key(|set| set_total(set)) + .unwrap_or_default(); + let pip_count = set_total(&primary_colors); + + let commitment = compute_commitment(payoff_count, pip_count, total_nonland); + + DevotionFeature { + payoff_count, + primary_colors, + pip_count, + gates, + commitment, + payoff_names, + } +} + +/// CR 105.1: put a color set in canonical WUBRG order and drop duplicates so +/// sets and gates dedup reliably. +fn normalize_colors(mut colors: Vec) -> Vec { + let order = |c: &ManaColor| { + ManaColor::ALL + .iter() + .position(|x| x == c) + .unwrap_or(usize::MAX) + }; + colors.sort_by_key(order); + colors.dedup(); + colors +} + +/// Calibration: Mono-Black Devotion (Gray Merchant ×4, Erebos, ~30 permanents +/// averaging ~1.3 black pips → ~40 pips over 37 nonland) → commitment ≈ 0.90. +/// Anti-calibration: a two-color midrange deck with one off-color god and few +/// pips in its color → below `DEVOTION_FLOOR`; UW control → 0.0. +/// +/// Geometric mean over (payoff, pip): BOTH pillars are mandatory. Pips with no +/// payoff is just a mono-color deck; a payoff with no pips never turns on. +fn compute_commitment(payoff_count: u32, pip_count: u32, total_nonland: u32) -> f32 { + let payoff_density = (commitment::density_per_60(payoff_count, total_nonland) / 6.0).min(1.0); + // ~30 pips per 60 nonland is a fully-committed mono-color devotion deck. + let pip_density = (commitment::density_per_60(pip_count, total_nonland) / 30.0).min(1.0); + commitment::geometric_mean(&[payoff_density, pip_density]) +} + +/// The highest `DevotionGE` gate on the face (the god threshold), with the +/// colors it reads. Walks the static-condition tree so a gate nested under +/// `Not` (Erebos: "isn't a creature unless devotion ≥ 5") is found. Gods carry +/// the gate on a static, never a trigger, so only statics are scanned. +fn highest_devotion_gate(face: &engine::types::card::CardFace) -> Option<(Vec, u32)> { + face.static_abilities + .iter() + .filter_map(|def| def.condition.as_ref()) + .filter_map(static_devotion_gate) + .max_by_key(|(_, threshold)| *threshold) +} + +fn static_devotion_gate(condition: &StaticCondition) -> Option<(Vec, u32)> { + match condition { + StaticCondition::DevotionGE { colors, threshold } => Some((colors.clone(), *threshold)), + StaticCondition::Not { condition } => static_devotion_gate(condition), + StaticCondition::And { conditions } | StaticCondition::Or { conditions } => conditions + .iter() + .filter_map(static_devotion_gate) + .max_by_key(|(_, threshold)| *threshold), + _ => None, + } +} + +/// True when the face reads `QuantityRef::Devotion` anywhere in an ability, +/// trigger, or static magnitude (Gray Merchant, Nykthos, Anax, cost reducers). +fn reads_devotion(face: &engine::types::card::CardFace) -> bool { + !scaling_payoff_colors(face).is_empty() +} + +/// Every `DevotionColors` demand read by a `QuantityRef::Devotion` on the face. +fn scaling_payoff_colors(face: &engine::types::card::CardFace) -> Vec { + let mut out = Vec::new(); + for ability in &face.abilities { + collect_devotion_colors_in_ability(ability, &mut out); + } + for trigger in &face.triggers { + if let Some(execute) = &trigger.execute { + collect_devotion_colors_in_ability(execute, &mut out); + } + } + for def in &face.static_abilities { + collect_devotion_colors_in_static(def, &mut out); + } + out +} + +fn collect_devotion_colors_in_ability(ability: &AbilityDefinition, out: &mut Vec) { + // CR 700.5 deck-time classification: `AbilityScope::Potential` walks the + // `else_ability`/modal branches too, so a card whose devotion payoff lives + // only in one mode (a modal "choose one — ... equal to your devotion") is + // still detected. Mirrors the poison feature's deck-time scan. + for effect in collect_scoped_effects(ability, AbilityScope::Potential) { + collect_devotion_colors_in_effect(effect, out); + } +} + +fn collect_devotion_colors_in_static(def: &StaticDefinition, out: &mut Vec) { + for modification in &def.modifications { + if let Some(expr) = continuous_modification_quantity(modification) { + collect_devotion_colors_in_expr(expr, out); + } + } +} + +fn collect_devotion_colors_in_effect(effect: &Effect, out: &mut Vec) { + // `Effect::count_expr` is the engine's exhaustive authority for an effect's + // magnitude `QuantityExpr` (drain amount, damage, token count, draw count, + // …), so every count/amount-bearing payoff is covered without hand-listing + // effect variants. + if let Some(expr) = effect.count_expr() { + collect_devotion_colors_in_expr(expr, out); + } + // CR 605.1a: `count_expr` deliberately returns `None` for `Effect::Mana` + // because a mana effect has no count/amount magnitude — its `QuantityExpr` + // lives inside the `ManaProduction`. That is the Nykthos / Nyx Lotus / + // Karametra's Acolyte carrier ("add mana equal to your devotion"), so it is + // dug out explicitly here. + if let Effect::Mana { produced, .. } = effect { + if let Some(expr) = mana_production_count(produced) { + collect_devotion_colors_in_expr(expr, out); + } + } +} + +/// CR 605.1a: the `QuantityExpr` count a mana-production carries, if any. +/// Enumerated without a wildcard so a new `ManaProduction` variant forces this +/// devotion scan to be reconsidered. `Fixed` / `Mixed` produce a statically +/// sized bundle and carry no dynamic count. +fn mana_production_count( + production: &engine::types::ability::ManaProduction, +) -> Option<&QuantityExpr> { + use engine::types::ability::ManaProduction as MP; + match production { + MP::Colorless { count } + | MP::AnyOneColor { count, .. } + | MP::AnyCombination { count, .. } + | MP::ChosenColor { count, .. } + | MP::OpponentLandColors { count } + | MP::AnyCombinationOfObjectColors { count, .. } + | MP::AnyTypeProduceableBy { count, .. } + | MP::AnyInCommandersColorIdentity { count, .. } + | MP::AnyOneColorAmongPermanents { count, .. } => Some(count), + // No dynamic count: fixed/statically-sized bundles and choice-set forms. + MP::Fixed { .. } + | MP::Mixed { .. } + | MP::ChoiceAmongExiledColors { .. } + | MP::ChoiceAmongCombinations { .. } + | MP::DistinctColorsAmongPermanents { .. } + | MP::TriggerEventManaType => None, + } +} + +fn collect_devotion_colors_in_expr(expr: &QuantityExpr, out: &mut Vec) { + match expr { + QuantityExpr::Ref { + qty: QuantityRef::Devotion { colors }, + } => out.push(colors.clone()), + QuantityExpr::Ref { .. } | QuantityExpr::Fixed { .. } => {} + QuantityExpr::DivideRounded { inner, .. } + | QuantityExpr::Offset { inner, .. } + | QuantityExpr::ClampMin { inner, .. } + | QuantityExpr::Multiply { inner, .. } => collect_devotion_colors_in_expr(inner, out), + QuantityExpr::UpTo { max } => collect_devotion_colors_in_expr(max, out), + QuantityExpr::Power { exponent, .. } => collect_devotion_colors_in_expr(exponent, out), + QuantityExpr::Difference { left, right } => { + collect_devotion_colors_in_expr(left, out); + collect_devotion_colors_in_expr(right, out); + } + QuantityExpr::Sum { exprs } | QuantityExpr::Max { exprs } => { + for e in exprs { + collect_devotion_colors_in_expr(e, out); + } + } + } +} + +/// The dynamic magnitude carried by a continuous modification, if any. Mirrors +/// `game::quantity::continuous_modification_dynamic_quantity` — enumerated +/// without a wildcard so a new `QuantityExpr`-carrying variant forces this +/// devotion scan to be reconsidered alongside the engine authority. +fn continuous_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), + CM::AddCounterOnEnter { .. } + | CM::SetStartingLoyalty { .. } + | CM::CopyValues { .. } + | CM::CopyChosen + | CM::SetName { .. } + | CM::SetTextName { .. } + | CM::AddPower { .. } + | CM::AddToughness { .. } + | CM::SetPower { .. } + | CM::SetToughness { .. } + | CM::AddKeyword { .. } + | CM::AddKeywordWithDerivedCost { .. } + | CM::RemoveKeyword { .. } + | CM::GrantAbility { .. } + | CM::GrantAllActivatedAbilitiesOf { .. } + | CM::GrantAllTriggeredAbilitiesOf { .. } + | CM::GrantTrigger { .. } + | CM::GrantReplacement { .. } + | CM::RemoveAllAbilities + | CM::AddType { .. } + | CM::RemoveType { .. } + | CM::AddSubtype { .. } + | CM::RemoveSubtype { .. } + | CM::SetCardTypes { .. } + | CM::RemoveAllSubtypes { .. } + | CM::AddAllCreatureTypes + | CM::AddAllBasicLandTypes + | CM::AddAllLandTypes + | CM::AddChosenSubtype { .. } + | CM::AddChosenColor { .. } + | CM::RemoveChosenKeyword + | CM::AddChosenKeyword + | CM::SetColor { .. } + | CM::AddColor { .. } + | CM::AddStaticMode { .. } + | CM::GrantStaticAbility { .. } + | CM::SwitchPowerToughness + | CM::AssignDamageFromToughness + | CM::AssignDamageAsThoughUnblocked + | CM::AssignNoCombatDamage + | CM::ChangeController + | CM::SetBasicLandType { .. } + | CM::SetChosenBasicLandType + | CM::SetChosenName + | CM::RetainPrintedTriggerFromSource { .. } + | CM::RetainPrintedAbilityFromSource { .. } + | CM::RetainAllOtherAbilitiesFromSource + | CM::AddSupertype { .. } + | CM::RemoveSupertype { .. } + | CM::RemoveManaCost => None, + } +} diff --git a/crates/phase-ai/src/features/mod.rs b/crates/phase-ai/src/features/mod.rs index 521b512fd5..f83ed388a2 100644 --- a/crates/phase-ai/src/features/mod.rs +++ b/crates/phase-ai/src/features/mod.rs @@ -12,6 +12,7 @@ pub mod artifacts; pub mod blink; pub mod commitment; pub mod control; +pub mod devotion; pub mod enchantments; pub mod energy; pub mod equipment; @@ -35,6 +36,7 @@ pub use aristocrats::AristocratsFeature; pub use artifacts::ArtifactsFeature; pub use blink::BlinkFeature; pub use control::ControlFeature; +pub use devotion::DevotionFeature; pub use enchantments::EnchantmentsFeature; pub use energy::EnergyFeature; pub use equipment::EquipmentFeature; @@ -81,6 +83,7 @@ pub struct DeckFeatures { pub spellslinger_prowess: SpellslingerProwessFeature, pub reanimator: ReanimatorFeature, pub mill: MillFeature, + pub devotion: DevotionFeature, pub energy: EnergyFeature, /// CR 104.3d: the alternate poison win clock (toxic / infect / proliferate). pub poison: PoisonFeature, @@ -129,6 +132,7 @@ impl DeckFeatures { spellslinger_prowess: spellslinger_prowess::detect(deck), reanimator: reanimator::detect(deck), mill: mill::detect(deck), + devotion: devotion::detect(deck), energy: energy::detect(deck), poison: poison::detect(deck), graveyard_types: graveyard_types::detect(deck), diff --git a/crates/phase-ai/src/features/tests/devotion.rs b/crates/phase-ai/src/features/tests/devotion.rs new file mode 100644 index 0000000000..daf99e504b --- /dev/null +++ b/crates/phase-ai/src/features/tests/devotion.rs @@ -0,0 +1,470 @@ +//! Unit tests for `features::devotion` — structural detection + calibration +//! anchors for the CR 700.5 pip-density axis. No `#[cfg(test)]` in SOURCE +//! files; tests live here. + +use engine::game::DeckEntry; +use engine::types::ability::{ + AbilityDefinition, AbilityKind, ContinuousModification, DevotionColors, Effect, + ManaContribution, ManaProduction, QuantityExpr, QuantityRef, StaticCondition, StaticDefinition, + TargetFilter, TriggerDefinition, +}; +use engine::types::card::CardFace; +use engine::types::card_type::{CardType, CoreType}; +use engine::types::mana::{ManaColor, ManaCost, ManaCostShard}; +use engine::types::triggers::TriggerMode; + +use crate::features::devotion::*; + +fn card(name: &str, core: CoreType, pips: &[ManaCostShard], generic: u32) -> CardFace { + let mut face = CardFace { + name: name.to_string(), + card_type: CardType { + supertypes: Vec::new(), + core_types: vec![core], + subtypes: Vec::new(), + }, + ..Default::default() + }; + face.mana_cost = ManaCost::Cost { + shards: pips.to_vec(), + generic, + }; + face +} + +fn entry(card: CardFace, count: u32) -> DeckEntry { + DeckEntry { card, count } +} + +fn devotion(colors: &[ManaColor]) -> QuantityExpr { + QuantityExpr::Ref { + qty: QuantityRef::Devotion { + colors: DevotionColors::Fixed(colors.to_vec()), + }, + } +} + +/// Erebos shape: a static gated on `Not { DevotionGE { color, threshold } }`, +/// carrying a colored pip in its own cost. +fn god(name: &str, color: ManaColor, threshold: u32, pip: ManaCostShard) -> CardFace { + let mut face = card(name, CoreType::Enchantment, &[pip], 1); + face.static_abilities = vec![StaticDefinition::continuous() + .affected(TargetFilter::SelfRef) + .modifications(vec![ContinuousModification::RemoveType { + core_type: CoreType::Creature, + }]) + .condition(StaticCondition::Not { + condition: Box::new(StaticCondition::DevotionGE { + colors: vec![color], + threshold, + }), + })]; + face +} + +/// Athreos / Xenagos shape: a two-color god gated on COMBINED devotion to both +/// colors (`DevotionGE { colors: [c1, c2], threshold }`), carrying one pip of +/// each color in its own cost. +fn dual_god( + name: &str, + c1: ManaColor, + c2: ManaColor, + threshold: u32, + pips: &[ManaCostShard], +) -> CardFace { + let mut face = card(name, CoreType::Enchantment, pips, 1); + face.static_abilities = vec![StaticDefinition::continuous() + .affected(TargetFilter::SelfRef) + .modifications(vec![ContinuousModification::RemoveType { + core_type: CoreType::Creature, + }]) + .condition(StaticCondition::Not { + condition: Box::new(StaticCondition::DevotionGE { + colors: vec![c1, c2], + threshold, + }), + })]; + face +} + +/// Gray Merchant shape: an ETB trigger draining life equal to devotion, plus +/// two colored pips in its own cost. +fn drain(name: &str, color: ManaColor, pips: &[ManaCostShard]) -> CardFace { + let mut face = card(name, CoreType::Creature, pips, 3); + face.triggers = + vec![ + TriggerDefinition::new(TriggerMode::ChangesZone).execute(AbilityDefinition::new( + AbilityKind::Spell, + Effect::LoseLife { + amount: devotion(&[color]), + target: Some(TargetFilter::Opponent), + }, + )), + ]; + face +} + +/// Anax shape: a static setting P/T equal to devotion (no threshold gate). +fn scaling_pt(name: &str, color: ManaColor, pips: &[ManaCostShard]) -> CardFace { + let mut face = card(name, CoreType::Creature, pips, 1); + face.static_abilities = vec![StaticDefinition::continuous() + .affected(TargetFilter::SelfRef) + .modifications(vec![ContinuousModification::SetDynamicPower { + value: devotion(&[color]), + }])]; + face +} + +/// A vanilla permanent contributing pips but paying off nothing. +fn pip_body(name: &str, pips: &[ManaCostShard]) -> CardFace { + card(name, CoreType::Creature, pips, 1) +} + +const BB: &[ManaCostShard] = &[ManaCostShard::Black, ManaCostShard::Black]; + +#[test] +fn empty_deck_produces_defaults() { + let f = detect(&[]); + assert_eq!(f.payoff_count, 0); + assert!(f.primary_colors.is_empty()); + assert_eq!(f.commitment, 0.0); + assert!(f.payoff_names.is_empty()); +} + +#[test] +fn vanilla_deck_not_registered() { + let f = detect(&[entry(pip_body("Bear", BB), 4)]); + // Pips but no payoff → not a devotion deck. + assert_eq!(f.payoff_count, 0); + assert!(f.primary_colors.is_empty()); + assert_eq!(f.commitment, 0.0); +} + +#[test] +fn detects_god_gate_and_threshold() { + let f = detect(&[entry( + god("Erebos", ManaColor::Black, 5, ManaCostShard::Black), + 1, + )]); + assert_eq!(f.payoff_count, 1); + assert_eq!(f.primary_colors, vec![ManaColor::Black]); + assert_eq!( + f.gates, + vec![DevotionGate { + colors: vec![ManaColor::Black], + threshold: 5, + }] + ); +} + +#[test] +fn detects_scaling_drain_payoff() { + let f = detect(&[entry(drain("Gray Merchant", ManaColor::Black, BB), 4)]); + assert_eq!(f.payoff_count, 4); + assert_eq!(f.primary_colors, vec![ManaColor::Black]); + // A drain has no god threshold. + assert!(f.gates.is_empty()); +} + +#[test] +fn detects_dynamic_pt_payoff() { + let f = detect(&[entry( + scaling_pt("Anax", ManaColor::Red, &[ManaCostShard::Red]), + 4, + )]); + assert_eq!(f.payoff_count, 4); + assert_eq!(f.primary_colors, vec![ManaColor::Red]); +} + +/// CR 700.5 counts permanents only — an instant reading devotion is a payoff, +/// but its own pips never contribute to the deck's devotion. +#[test] +fn instant_pips_do_not_count_toward_devotion() { + let mut spell = card( + "Aspect of Hydra", + CoreType::Instant, + &[ManaCostShard::Green], + 0, + ); + spell.abilities = vec![AbilityDefinition::new( + AbilityKind::Spell, + Effect::GainLife { + amount: devotion(&[ManaColor::Green]), + player: TargetFilter::Controller, + }, + )]; + // Add a green permanent so green is the primary color and has pips. + let f = detect(&[ + entry(spell, 4), + entry(pip_body("Green Body", &[ManaCostShard::Green]), 1), + ]); + assert_eq!(f.primary_colors, vec![ManaColor::Green]); + // The 4 instants contribute 0 pips; only the single permanent's green pip counts. + assert_eq!(f.pip_count, 1); +} + +/// An off-color god is not the primary color when the deck's pips are elsewhere. +#[test] +fn primary_color_follows_pip_density_among_payoff_colors() { + // A black god, but the deck is packed with black pips → black is primary. + let deck = vec![ + entry(god("Erebos", ManaColor::Black, 5, ManaCostShard::Black), 1), + entry(drain("Gray Merchant", ManaColor::Black, BB), 4), + entry(pip_body("Black Body", BB), 10), + ]; + let f = detect(&deck); + assert_eq!(f.primary_colors, vec![ManaColor::Black]); + assert!( + f.pip_count >= 20, + "expected heavy black pip count, got {}", + f.pip_count + ); +} + +/// Calibration: a Mono-Black Devotion shell clears the floor comfortably. +#[test] +fn mono_black_devotion_hits_calibration_floor() { + let deck = vec![ + entry(drain("Gray Merchant", ManaColor::Black, BB), 4), + entry(god("Erebos", ManaColor::Black, 5, ManaCostShard::Black), 1), + entry(pip_body("Double Black Body", BB), 16), + entry(pip_body("Filler", &[ManaCostShard::Black]), 16), + ]; + let f = detect(&deck); + assert_eq!(f.primary_colors, vec![ManaColor::Black]); + assert!( + f.commitment > 0.85, + "mono-black devotion must clear 0.85, got {}", + f.commitment + ); +} + +/// Anti-calibration: a single payoff splashed into an otherwise colorless +/// deck is not a devotion deck — both the payoff and pip pillars are thin, so +/// the geometric mean stays below the floor. (Four double-black drains, by +/// contrast, carry eight black pips themselves and legitimately read as +/// committed — the pillars are only thin when the deck genuinely lacks them.) +#[test] +fn lone_payoff_in_offcolor_deck_below_floor() { + let deck = vec![ + entry(drain("Gray Merchant", ManaColor::Black, BB), 1), + entry(pip_body("Colorless Body", &[]), 36), + ]; + let f = detect(&deck); + assert!( + f.commitment < DEVOTION_FLOOR, + "a lone splashed payoff must stay below floor, got {}", + f.commitment + ); +} + +/// Geometric-mean collapse: heavy pips but no payoff is just a mono deck. +#[test] +fn pips_without_payoff_collapse() { + let deck = vec![entry(pip_body("Black Body", BB), 30)]; + assert_eq!(detect(&deck).commitment, 0.0); +} + +/// A Nykthos-style `ChosenColor` payoff makes every color eligible, so the +/// deck's own densest color becomes primary. +#[test] +fn chosen_color_payoff_uses_deck_densest_color() { + let mut nykthos = card("Nyx Lotus Proxy", CoreType::Artifact, &[], 4); + // A ChosenColor devotion read via a draw count (stand-in for the ramp shape). + nykthos.abilities = vec![AbilityDefinition::new( + AbilityKind::Activated, + Effect::Draw { + count: QuantityExpr::Ref { + qty: QuantityRef::Devotion { + colors: DevotionColors::ChosenColor, + }, + }, + target: TargetFilter::Controller, + }, + )]; + let deck = vec![ + entry(nykthos, 1), + entry( + pip_body("Green Body", &[ManaCostShard::Green, ManaCostShard::Green]), + 8, + ), + entry(pip_body("Red Body", &[ManaCostShard::Red]), 2), + ]; + let f = detect(&deck); + assert_eq!(f.primary_colors, vec![ManaColor::Green]); +} + +/// Two gods at DIFFERENT thresholds in the same color must both be retained — +/// keeping only the maximum would hide the lower god from the policy. +#[test] +fn distinct_thresholds_are_all_retained() { + let deck = vec![ + entry( + god("Small God", ManaColor::Black, 3, ManaCostShard::Black), + 1, + ), + entry(god("Big God", ManaColor::Black, 5, ManaCostShard::Black), 1), + entry(pip_body("Black Body", BB), 8), + ]; + let f = detect(&deck); + assert_eq!(f.primary_colors, vec![ManaColor::Black]); + assert_eq!( + f.gates, + vec![ + DevotionGate { + colors: vec![ManaColor::Black], + threshold: 3, + }, + DevotionGate { + colors: vec![ManaColor::Black], + threshold: 5, + }, + ], + "both distinct thresholds retained" + ); +} + +/// [HIGH] Nykthos-style ramp: a mana ability that produces mana equal to your +/// devotion lives in `Effect::Mana`'s `ManaProduction`, which `count_expr` does +/// not reach — the dedicated mana-production scan must still detect it as a +/// devotion payoff. +#[test] +fn nykthos_mana_production_is_a_payoff() { + let mut nykthos = card("Nykthos", CoreType::Artifact, &[], 0); + nykthos.abilities = vec![AbilityDefinition::new( + AbilityKind::Activated, + Effect::Mana { + produced: ManaProduction::ChosenColor { + count: QuantityExpr::Ref { + qty: QuantityRef::Devotion { + colors: DevotionColors::ChosenColor, + }, + }, + contribution: ManaContribution::Base, + fixed_alternative: None, + }, + restrictions: vec![], + grants: vec![], + expiry: None, + target: None, + }, + )]; + // A ChosenColor payoff makes every color eligible, so the deck's densest + // color becomes primary and Nykthos is counted as a payoff. + let deck = vec![ + entry(nykthos, 1), + entry( + pip_body("Green Body", &[ManaCostShard::Green, ManaCostShard::Green]), + 8, + ), + ]; + let f = detect(&deck); + assert_eq!( + f.payoff_count, 1, + "Nykthos must register as a devotion payoff" + ); + assert_eq!(f.primary_colors, vec![ManaColor::Green]); +} + +/// [HIGH] Athreos (W+B): a two-color god's gate is against COMBINED devotion to +/// both colors, and the deck's primary demand is the whole W+B set — not either +/// color alone. The gate must retain both colors so the policy can count the +/// combined board devotion, hybrids once (CR 700.5). +#[test] +fn dual_color_god_retains_the_whole_color_set() { + let deck = vec![ + entry( + dual_god( + "Athreos", + ManaColor::White, + ManaColor::Black, + 5, + &[ManaCostShard::White, ManaCostShard::Black], + ), + 1, + ), + entry( + pip_body("WB Body", &[ManaCostShard::White, ManaCostShard::Black]), + 8, + ), + ]; + let f = detect(&deck); + // WUBRG-normalized: White precedes Black. + assert_eq!(f.primary_colors, vec![ManaColor::White, ManaColor::Black]); + assert_eq!( + f.gates, + vec![DevotionGate { + colors: vec![ManaColor::White, ManaColor::Black], + threshold: 5, + }], + "a two-color god's gate keeps both colors" + ); +} + +/// Xenagos (R+G): the same combined-set retention for a different color pair, +/// confirming the axis is not White/Black-specific. +#[test] +fn dual_color_god_xenagos_red_green() { + let deck = vec![ + entry( + dual_god( + "Xenagos", + ManaColor::Red, + ManaColor::Green, + 5, + &[ManaCostShard::Red, ManaCostShard::Green], + ), + 1, + ), + entry( + pip_body("RG Body", &[ManaCostShard::Red, ManaCostShard::Green]), + 8, + ), + ]; + let f = detect(&deck); + // WUBRG-normalized: Red precedes Green. + assert_eq!(f.primary_colors, vec![ManaColor::Red, ManaColor::Green]); + assert_eq!( + f.gates, + vec![DevotionGate { + colors: vec![ManaColor::Red, ManaColor::Green], + threshold: 5, + }] + ); +} + +/// [MED] CR 700.5: a hybrid `{W/B}` symbol contributes exactly ONCE to a W+B +/// candidate set, not once to white and once to black. `pip_count` for the +/// combined primary set must be computed set-wise, not as a sum of per-color +/// buckets (which would double-count every hybrid shard). +#[test] +fn hybrid_pips_count_once_for_a_combined_set() { + const WB: ManaCostShard = ManaCostShard::WhiteBlack; + let deck = vec![ + // Athreos makes W+B the demanded (and only) candidate set. Its own cost + // is two unambiguous pips (one white, one black) → 2 toward {W,B}. + entry( + dual_god( + "Athreos", + ManaColor::White, + ManaColor::Black, + 5, + &[ManaCostShard::White, ManaCostShard::Black], + ), + 1, + ), + // Four pure-hybrid `{W/B}{W/B}` bodies: 2 shards each, each counting once + // for {W,B} → 2 per copy, 8 total. A per-color-bucket sum would see each + // shard in BOTH buckets and report 16. + entry(pip_body("WB Hybrid Body", &[WB, WB]), 4), + ]; + let f = detect(&deck); + assert_eq!(f.primary_colors, vec![ManaColor::White, ManaColor::Black]); + // 2 (god) + 4 × 2 (hybrid bodies, each shard once) = 10. Double-counting + // hybrids would yield 2 + 4 × 4 = 18. + assert_eq!( + f.pip_count, 10, + "each hybrid shard must count once for the W+B set, got {}", + f.pip_count + ); +} diff --git a/crates/phase-ai/src/features/tests/mod.rs b/crates/phase-ai/src/features/tests/mod.rs index c22837884e..03acc6775c 100644 --- a/crates/phase-ai/src/features/tests/mod.rs +++ b/crates/phase-ai/src/features/tests/mod.rs @@ -3,6 +3,7 @@ pub mod artifacts; pub mod blink; +pub mod devotion; pub mod enchantments; pub mod energy; pub mod equipment; diff --git a/crates/phase-ai/src/policies/devotion.rs b/crates/phase-ai/src/policies/devotion.rs new file mode 100644 index 0000000000..01f771bdf2 --- /dev/null +++ b/crates/phase-ai/src/policies/devotion.rs @@ -0,0 +1,153 @@ +//! `DevotionPolicy` — makes CR 700.5 pip density a resource the AI can see. +//! +//! ## The defect this closes +//! +//! CR 700.5: devotion to a color is the number of that color's mana symbols +//! among the mana costs of permanents you control. It is the payoff currency +//! for the Theros gods (not creatures below their threshold), Gray Merchant +//! drains, and X = devotion scalers. The AI's evaluation models mana value and +//! board presence but not pip density, so between two comparable permanents it +//! could not prefer the double-pip one, and it could not see that casting one +//! more colored permanent flips a dormant god into a lethal beater. +//! +//! ## Why the god-threshold crossing is a distinct branch +//! +//! Below a god's threshold the god is not a creature; the cast that reaches the +//! threshold turns a dead enchantment into a large indestructible body — a +//! multi-card swing, not the marginal +1 that the previous pip was. That +//! discontinuity is scored as its own term (`devotion_god_activation`), the same +//! "last missing piece" structure `graveyard_types` uses for the fourth card +//! type. Every other pip is a smooth preference (`devotion_pip_progress`). +//! +//! ## Performance +//! +//! `verdict()` runs per candidate per search node. The card-local check — pips +//! in the candidate's own mana cost, a handful of shard matches — runs FIRST +//! and rejects every non-permanent and off-color cast. Only a confirmed +//! primary-color permanent pays for `count_devotion`, one pass over the AI's +//! battlefield permanents (the CR 700.5 runtime authority). No affordability +//! sweep, no `find_legal_targets`. + +use engine::game::devotion::count_devotion; +use engine::types::actions::GameAction; +use engine::types::game_state::GameState; +use engine::types::mana::ManaColor; +use engine::types::player::PlayerId; + +use crate::features::devotion::{cost_devotion_pips, DEVOTION_FLOOR}; +use crate::features::DeckFeatures; + +use super::context::PolicyContext; +use super::registry::{DecisionKind, PolicyId, PolicyReason, PolicyVerdict, TacticalPolicy}; + +pub struct DevotionPolicy; + +impl TacticalPolicy for DevotionPolicy { + fn id(&self) -> PolicyId { + PolicyId::Devotion + } + + fn decision_kinds(&self) -> &'static [DecisionKind] { + &[DecisionKind::CastSpell] + } + + fn activation( + &self, + features: &DeckFeatures, + _state: &GameState, + _player: PlayerId, + ) -> Option { + if features.devotion.commitment < DEVOTION_FLOOR { + None + } else { + Some(features.devotion.commitment) + } + } + + fn verdict(&self, ctx: &PolicyContext<'_>) -> PolicyVerdict { + let feature = match ctx.context.session.features.get(&ctx.ai_player) { + Some(f) => &f.devotion, + None => return PolicyVerdict::neutral(PolicyReason::new("devotion_na")), + }; + // No payoff colors ⇒ nothing to score against. + if feature.primary_colors.is_empty() && feature.gates.is_empty() { + return PolicyVerdict::neutral(PolicyReason::new("devotion_na")); + } + + // Card-local first: the cast must be a permanent (CR 110.4 — only a + // permanent contributes devotion). + let GameAction::CastSpell { object_id, .. } = &ctx.candidate.action else { + return PolicyVerdict::neutral(PolicyReason::new("devotion_na")); + }; + let Some(obj) = ctx.state.objects.get(object_id) else { + return PolicyVerdict::neutral(PolicyReason::new("devotion_na")); + }; + if !obj + .card_types + .core_types + .iter() + .any(|t| t.is_permanent_type()) + { + return PolicyVerdict::neutral(PolicyReason::new("devotion_na")); + } + + // Cheapest board-free discriminator: does the cast add any pip toward a + // color set the deck actually cares about (primary demand or any gate)? + // Skip the battlefield scans for every off-color permanent. + let mut relevant: Vec = feature.primary_colors.clone(); + for gate in &feature.gates { + for c in &gate.colors { + if !relevant.contains(c) { + relevant.push(*c); + } + } + } + if cost_devotion_pips(&obj.mana_cost, &relevant) == 0 { + return PolicyVerdict::neutral(PolicyReason::new("devotion_off_color")); + } + + let pip_scalar = ctx.config.policy_penalties.devotion_pip_progress; + + // CR 700.5: evaluate each god's gate against its OWN color set — + // combined devotion for a two-color god (Athreos W+B, Xenagos R+G), + // hybrids counted once. A cast that reaches a gate's combined threshold + // turns that god on; several can flip at once, and a cast crossing a + // lower gate matters even when a higher one it does not reach exists. + let crossed = feature + .gates + .iter() + .filter(|gate| { + let current = count_devotion(ctx.state, ctx.ai_player, &gate.colors); + let added = cost_devotion_pips(&obj.mana_cost, &gate.colors); + current < gate.threshold && current + added >= gate.threshold + }) + .count() as u32; + + // The smooth-pip component is measured against the deck's primary demand. + let primary_added = cost_devotion_pips(&obj.mana_cost, &feature.primary_colors); + let primary_devotion = count_devotion(ctx.state, ctx.ai_player, &feature.primary_colors); + + if crossed > 0 { + let activation = ctx.config.policy_penalties.devotion_god_activation; + return PolicyVerdict::score( + activation * f64::from(crossed) + pip_scalar * f64::from(primary_added), + PolicyReason::new("devotion_god_activation") + .with_fact("devotion", primary_devotion as i64) + .with_fact("gods_activated", crossed as i64), + ); + } + + if primary_added == 0 { + // Pips only toward an already-met or off-primary gate: no smooth + // progress to score once no gate crosses. + return PolicyVerdict::neutral(PolicyReason::new("devotion_off_color")); + } + + PolicyVerdict::score( + pip_scalar * f64::from(primary_added), + PolicyReason::new("devotion_pip_progress") + .with_fact("devotion", primary_devotion as i64) + .with_fact("added", primary_added as i64), + ) + } +} diff --git a/crates/phase-ai/src/policies/mod.rs b/crates/phase-ai/src/policies/mod.rs index e645221242..baf4ccde31 100644 --- a/crates/phase-ai/src/policies/mod.rs +++ b/crates/phase-ai/src/policies/mod.rs @@ -14,6 +14,7 @@ pub(crate) mod context; mod control_change_awareness; pub(crate) mod copy_value; mod cycling_discipline; +mod devotion; mod downside_awareness; pub(crate) mod effect_classify; mod effect_timing; diff --git a/crates/phase-ai/src/policies/registry.rs b/crates/phase-ai/src/policies/registry.rs index f36b43e734..71c949b389 100644 --- a/crates/phase-ai/src/policies/registry.rs +++ b/crates/phase-ai/src/policies/registry.rs @@ -11,6 +11,7 @@ use super::chalice_avoidance::ChaliceAvoidancePolicy; use super::context::{PolicyContext, PriorsEnv}; use super::copy_value::CopyValuePolicy; use super::cycling_discipline::CyclingDisciplinePolicy; +use super::devotion::DevotionPolicy; use super::effect_timing::EffectTimingPolicy; use super::etb_value::EtbValuePolicy; use super::evasion_removal_priority::EvasionRemovalPriorityPolicy; @@ -131,6 +132,8 @@ pub enum PolicyId { SeparatePilesTiming, XCastGate, LoopShortcut, + /// CR 700.5: mono-color devotion pip density. + Devotion, /// CR 104.3d: the alternate poison win clock. PoisonClock, /// CR 207.2c + CR 205.2a: delirium / descend graveyard type-diversity. @@ -326,6 +329,7 @@ impl Default for PolicyRegistry { Box::new(PayoffPolicy::new(&ARTIFACT_SYNERGY)), Box::new(BoardDevelopmentPolicy), Box::new(EtbValuePolicy), + Box::new(DevotionPolicy), Box::new(PoisonClockPolicy), Box::new(GraveyardTypesPolicy), Box::new(PayoffPolicy::new(&ENCHANTMENTS_PAYOFF)), diff --git a/crates/phase-ai/src/policies/tests/devotion.rs b/crates/phase-ai/src/policies/tests/devotion.rs new file mode 100644 index 0000000000..ead38a6c8e --- /dev/null +++ b/crates/phase-ai/src/policies/tests/devotion.rs @@ -0,0 +1,382 @@ +//! Unit tests for `policies::devotion` — the CR 700.5 pip-density 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 `graveyard_types` policy-test shape: +//! current devotion comes from real battlefield permanents (the `count_devotion` +//! authority) and the cast's pips from a real hand object's mana cost. + +use std::sync::Arc; + +use engine::ai_support::{ActionMetadata, AiDecisionContext, CandidateAction, TacticalClass}; +use engine::game::zones::create_object; +use engine::types::actions::GameAction; +use engine::types::card_type::{CardType, CoreType}; +use engine::types::format::FormatConfig; +use engine::types::game_state::{CastPaymentMode, GameState, WaitingFor}; +use engine::types::identifiers::{CardId, ObjectId}; +use engine::types::mana::{ManaColor, ManaCost, ManaCostShard}; +use engine::types::player::PlayerId; +use engine::types::zones::Zone; + +use crate::config::AiConfig; +use crate::context::AiContext; +use crate::features::devotion::{DevotionFeature, DevotionGate, DEVOTION_FLOOR}; +use crate::features::DeckFeatures; +use crate::policies::context::{PolicyContext, SearchDepth}; +use crate::policies::devotion::*; +use crate::policies::registry::{PolicyReason, PolicyVerdict, TacticalPolicy}; +use crate::session::AiSession; + +const AI: PlayerId = PlayerId(0); + +fn config() -> AiConfig { + AiConfig::default() +} + +fn state() -> GameState { + GameState::new(FormatConfig::standard(), 2, 42) +} + +/// Single-black `DevotionGate`s at the given thresholds — the common mono-black +/// god shape used by most policy tests. +fn black_gates(thresholds: &[u32]) -> Vec { + thresholds + .iter() + .map(|&threshold| DevotionGate { + colors: vec![ManaColor::Black], + threshold, + }) + .collect() +} + +/// An `AiContext` whose cached devotion feature carries the given primary color +/// and god threshold, so `verdict` reads them the way it would in a real game. +fn context_with( + config: &AiConfig, + primary_colors: Vec, + gates: Vec, +) -> AiContext { + let features = DeckFeatures { + devotion: DevotionFeature { + payoff_count: 8, + primary_colors, + pip_count: 30, + gates, + 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, + } +} + +/// Put a permanent on the AI's battlefield carrying `pips` colored symbols, so +/// `count_devotion` sees them. +fn battlefield_permanent(state: &mut GameState, idx: u64, pips: &[ManaCostShard]) { + let oid = create_object( + state, + CardId(2000 + idx), + AI, + format!("Devout {idx}"), + Zone::Battlefield, + ); + let object = state.objects.get_mut(&oid).unwrap(); + object.card_types = CardType { + supertypes: Vec::new(), + core_types: vec![CoreType::Creature], + subtypes: Vec::new(), + }; + object.mana_cost = ManaCost::Cost { + shards: pips.to_vec(), + generic: 1, + }; +} + +/// A hand object of `core` type with `pips` colored symbols — the cast candidate. +fn hand_card(state: &mut GameState, idx: u64, core: CoreType, pips: &[ManaCostShard]) -> ObjectId { + let oid = create_object(state, CardId(idx), AI, format!("Cast {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![core], + subtypes: Vec::new(), + }; + object.mana_cost = ManaCost::Cost { + shards: pips.to_vec(), + generic: 1, + }; + 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 score_of(verdict: PolicyVerdict) -> (f64, PolicyReason) { + match verdict { + PolicyVerdict::Score { delta, reason } => (delta, reason), + PolicyVerdict::Reject { reason } => panic!("unexpected Reject: {reason:?}"), + } +} + +const W: ManaCostShard = ManaCostShard::White; +const B: ManaCostShard = ManaCostShard::Black; +const R: ManaCostShard = ManaCostShard::Red; +const G: ManaCostShard = ManaCostShard::Green; + +// ─── activation ────────────────────────────────────────────────────────────── + +#[test] +fn activation_opts_out_below_floor() { + let mut features = DeckFeatures::default(); + features.devotion.commitment = DEVOTION_FLOOR - 0.01; + assert!(DevotionPolicy.activation(&features, &state(), AI).is_none()); +} + +#[test] +fn activation_opts_in_above_floor() { + let mut features = DeckFeatures::default(); + features.devotion.commitment = 0.9; + assert_eq!( + DevotionPolicy.activation(&features, &state(), AI), + Some(0.9) + ); +} + +// ─── verdict ───────────────────────────────────────────────────────────────── + +#[test] +fn verdict_scores_pips_added_when_no_threshold() { + let config = config(); + let context = context_with(&config, vec![ManaColor::Black], Vec::new()); + let mut state = state(); + let oid = hand_card(&mut state, 1, CoreType::Creature, &[B, B]); + let decision = priority_decision(); + let candidate = cast_candidate(oid); + let (delta, reason) = + score_of(DevotionPolicy.verdict(&ctx(&state, &candidate, &decision, &context, &config))); + assert_eq!(reason.kind, "devotion_pip_progress"); + // Two black pips at the default 0.35/pip scalar. + assert!((delta - 0.7).abs() < 1e-6, "expected 0.7, got {delta}"); +} + +#[test] +fn verdict_god_activation_when_cast_crosses_threshold() { + let config = config(); + let context = context_with(&config, vec![ManaColor::Black], black_gates(&[5])); + let mut state = state(); + // Four black pips already on board → devotion 4, one below the threshold. + battlefield_permanent(&mut state, 1, &[B, B]); + battlefield_permanent(&mut state, 2, &[B, B]); + let oid = hand_card(&mut state, 1, CoreType::Enchantment, &[B]); + let decision = priority_decision(); + let candidate = cast_candidate(oid); + let (delta, reason) = + score_of(DevotionPolicy.verdict(&ctx(&state, &candidate, &decision, &context, &config))); + assert_eq!(reason.kind, "devotion_god_activation"); + // Crossing spike (2.5) + one pip (0.35). + assert!( + delta > 2.5, + "crossing must exceed the god-activation floor, got {delta}" + ); +} + +#[test] +fn verdict_below_threshold_without_crossing_is_pip_progress() { + let config = config(); + let context = context_with(&config, vec![ManaColor::Black], black_gates(&[5])); + let mut state = state(); + // Devotion 1; casting one more pip reaches 2, still short of 5. + battlefield_permanent(&mut state, 1, &[B]); + let oid = hand_card(&mut state, 1, CoreType::Creature, &[B]); + let decision = priority_decision(); + let candidate = cast_candidate(oid); + let (_, reason) = + score_of(DevotionPolicy.verdict(&ctx(&state, &candidate, &decision, &context, &config))); + assert_eq!(reason.kind, "devotion_pip_progress"); +} + +#[test] +fn verdict_off_color_cast_is_neutral() { + let config = config(); + let context = context_with(&config, vec![ManaColor::Black], Vec::new()); + let mut state = state(); + let oid = hand_card(&mut state, 1, CoreType::Creature, &[R, R]); + let decision = priority_decision(); + let candidate = cast_candidate(oid); + let (delta, reason) = + score_of(DevotionPolicy.verdict(&ctx(&state, &candidate, &decision, &context, &config))); + assert_eq!(reason.kind, "devotion_off_color"); + assert_eq!(delta, 0.0); +} + +/// CR 700.5: an instant contributes no devotion even with colored pips. +#[test] +fn verdict_instant_is_neutral() { + let config = config(); + let context = context_with(&config, vec![ManaColor::Black], Vec::new()); + let mut state = state(); + let oid = hand_card(&mut state, 1, CoreType::Instant, &[B, B]); + let decision = priority_decision(); + let candidate = cast_candidate(oid); + let (delta, reason) = + score_of(DevotionPolicy.verdict(&ctx(&state, &candidate, &decision, &context, &config))); + assert_eq!(reason.kind, "devotion_na"); + assert_eq!(delta, 0.0); +} + +/// [MED] The bug the review caught: with gods at 3 and 5 and devotion at 2, a +/// cast that reaches 3 turns on the smaller god. The old max-only logic scored +/// this as mere pip progress; it must now be a god activation. +#[test] +fn verdict_crossing_lower_threshold_activates_that_god() { + let config = config(); + let context = context_with(&config, vec![ManaColor::Black], black_gates(&[3, 5])); + let mut state = state(); + // Devotion 2 (one below the lower gate). + battlefield_permanent(&mut state, 1, &[B, B]); + let oid = hand_card(&mut state, 1, CoreType::Enchantment, &[B]); + let decision = priority_decision(); + let candidate = cast_candidate(oid); + let (_, reason) = + score_of(DevotionPolicy.verdict(&ctx(&state, &candidate, &decision, &context, &config))); + assert_eq!(reason.kind, "devotion_god_activation"); + assert!( + reason + .facts + .iter() + .any(|(k, v)| *k == "gods_activated" && *v == 1), + "exactly one god crossed, got {:?}", + reason.facts + ); +} + +/// One cast can flip two gods at once when it clears both gates. +#[test] +fn verdict_crossing_two_thresholds_activates_both() { + let config = config(); + let context = context_with(&config, vec![ManaColor::Black], black_gates(&[3, 5])); + let mut state = state(); + // Devotion 2; a {B}{B}{B} cast reaches 5, clearing both the 3 and the 5 gate. + battlefield_permanent(&mut state, 1, &[B, B]); + let oid = hand_card(&mut state, 1, CoreType::Enchantment, &[B, B, B]); + let decision = priority_decision(); + let candidate = cast_candidate(oid); + let (_, reason) = + score_of(DevotionPolicy.verdict(&ctx(&state, &candidate, &decision, &context, &config))); + assert_eq!(reason.kind, "devotion_god_activation"); + assert!( + reason + .facts + .iter() + .any(|(k, v)| *k == "gods_activated" && *v == 2), + "both gods crossed, got {:?}", + reason.facts + ); +} + +/// [HIGH] Athreos (W+B) crossing: the gate is against COMBINED devotion. The +/// board carries two white and two black pips across separate permanents — +/// combined devotion 4. Neither single-color count (2 white, 2 black) is within +/// one pip of the 5-gate, so the OLD single-color logic could never see this +/// crossing. Casting one more white permanent reaches combined 5 and flips the +/// god. Regression against collapsing a two-color gate to one color. +#[test] +fn verdict_dual_color_god_crosses_on_combined_devotion() { + let config = config(); + let gates = vec![DevotionGate { + colors: vec![ManaColor::White, ManaColor::Black], + threshold: 5, + }]; + let context = context_with(&config, vec![ManaColor::White, ManaColor::Black], gates); + let mut state = state(); + // Combined W+B devotion 4: two white here, two black there. + battlefield_permanent(&mut state, 1, &[W, W]); + battlefield_permanent(&mut state, 2, &[B, B]); + let oid = hand_card(&mut state, 1, CoreType::Enchantment, &[W]); + let decision = priority_decision(); + let candidate = cast_candidate(oid); + let (_, reason) = + score_of(DevotionPolicy.verdict(&ctx(&state, &candidate, &decision, &context, &config))); + assert_eq!(reason.kind, "devotion_god_activation"); + assert!( + reason + .facts + .iter() + .any(|(k, v)| *k == "devotion" && *v == 4), + "combined W+B devotion must read 4, got {:?}", + reason.facts + ); +} + +/// Xenagos (R+G): the same combined-crossing on a different color pair, and the +/// added pip is the OFF-primary component (green) — combined counting still +/// reaches the gate. Confirms the axis is not White/Black-specific. +#[test] +fn verdict_dual_color_god_xenagos_red_green() { + let config = config(); + let gates = vec![DevotionGate { + colors: vec![ManaColor::Red, ManaColor::Green], + threshold: 5, + }]; + let context = context_with(&config, vec![ManaColor::Red, ManaColor::Green], gates); + let mut state = state(); + // Combined R+G devotion 4: two red, two green. + battlefield_permanent(&mut state, 1, &[R, R]); + battlefield_permanent(&mut state, 2, &[G, G]); + let oid = hand_card(&mut state, 1, CoreType::Enchantment, &[G]); + let decision = priority_decision(); + let candidate = cast_candidate(oid); + let (_, reason) = + score_of(DevotionPolicy.verdict(&ctx(&state, &candidate, &decision, &context, &config))); + assert_eq!(reason.kind, "devotion_god_activation"); + assert!( + reason + .facts + .iter() + .any(|(k, v)| *k == "gods_activated" && *v == 1), + "one god crossed on combined R+G, got {:?}", + reason.facts + ); +} diff --git a/crates/phase-ai/src/policies/tests/mod.rs b/crates/phase-ai/src/policies/tests/mod.rs index ecaaa98afb..e4db8735e5 100644 --- a/crates/phase-ai/src/policies/tests/mod.rs +++ b/crates/phase-ai/src/policies/tests/mod.rs @@ -3,6 +3,7 @@ pub mod activation_marker_lint; pub mod artifact_synergy; pub mod blink_payoff; +pub mod devotion; pub mod effect_classify_snapshot; pub mod enchantments_payoff; pub mod energy_payoff;