From 6f3f7fa1d30c2e56da61a0912334bad1957f6601 Mon Sep 17 00:00:00 2001 From: minion1227 Date: Fri, 24 Jul 2026 11:22:35 -0700 Subject: [PATCH 1/4] feat(phase-ai): add devotion deck-feature axis + DevotionPolicy MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 DevotionGE threshold), Gray Merchant drains, and X = devotion scalers — 43 cards in the corpus read it. The AI 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 body. Feature (features/devotion.rs) — structural, no card names: * threshold payoffs: StaticCondition::DevotionGE { colors, threshold }, walked through the Not/And/Or tree (Erebos gates its "isn't a creature" on Not{DevotionGE}). Every DISTINCT threshold in the primary color is retained (Vec), since each god turns on independently. * scaling payoffs: QuantityRef::Devotion anywhere in an effect's magnitude, reached via the engine's own Effect::count_expr authority (drains, damage, token counts, draw counts, dynamic P/T) or a continuous modification * pip density: ManaCost::count_colored_pips, the single CR 700.5 counting authority (hybrid {G/W}{G/W} = 2), over permanent faces only (CR 110.4 permanent-type test via CoreType::is_permanent_type) primary_color is the deck's most-devoted color among the colors its payoffs read; a ChosenColor payoff (Nykthos) makes every color eligible so the deck's own densest color wins. Commitment is a geometric mean over (payoff, pip): both mandatory — pips with no payoff is just a mono deck, a payoff with no pips never turns on. Calibrated to Mono-Black Devotion > 0.85, with a lone-splashed-payoff anti-calibration below the floor. Policy scores CastSpell of a permanent by primary-color pips added, with a god- activation spike for every DevotionGE threshold the cast newly crosses (a cast can flip several gods at once). The card-local pip check runs first; only a confirmed primary-color permanent pays for the count_devotion battlefield scan. No affordability sweep, no find_legal_targets. Both penalty fields carry #[serde(default = ...)] backed by shared default functions the Default impl also calls, so older policy_penalties artifacts that omit them still deserialize; both are registered UNTUNED pending a paired-seed calibration. Known safe undercounts (documented): mana-production ramp (Nykthos lives in a mana-ability production shape, not an Effect magnitude) and a spell's own "costs {X} less where X is devotion" self-discount (already applied by the engine). Both only lower commitment. Boundary with tribal/mana_ramp: devotion counts colored pips, not creatures of a type or mana sources. Co-Authored-By: Claude Opus 4.8 (1M context) --- crates/phase-ai/src/config.rs | 60 +++ crates/phase-ai/src/features/devotion.rs | 381 ++++++++++++++++++ crates/phase-ai/src/features/mod.rs | 4 + .../phase-ai/src/features/tests/devotion.rs | 284 +++++++++++++ crates/phase-ai/src/features/tests/mod.rs | 1 + crates/phase-ai/src/policies/devotion.rs | 131 ++++++ crates/phase-ai/src/policies/mod.rs | 1 + crates/phase-ai/src/policies/registry.rs | 4 + .../phase-ai/src/policies/tests/devotion.rs | 303 ++++++++++++++ crates/phase-ai/src/policies/tests/mod.rs | 1 + 10 files changed, 1170 insertions(+) create mode 100644 crates/phase-ai/src/features/devotion.rs create mode 100644 crates/phase-ai/src/features/tests/devotion.rs create mode 100644 crates/phase-ai/src/policies/devotion.rs create mode 100644 crates/phase-ai/src/policies/tests/devotion.rs 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..d0514a5fe2 --- /dev/null +++ b/crates/phase-ai/src/features/devotion.rs @@ -0,0 +1,381 @@ +//! 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), Nykthos ramp, and X-cost reductions. +//! - `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 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_chain_effects; +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 the deck is most devoted to among the colors its payoffs care + /// about. `None` when the deck has no devotion payoff. The single color the + /// policy scores pip contributions in. + pub primary_color: Option, + /// Raw colored-pip count in `primary_color` across the deck's permanent + /// faces (CR 700.5 counts permanents only). + pub pip_count: u32, + /// Every DISTINCT `DevotionGE` threshold read in `primary_color`, sorted + /// ascending; empty when no threshold payoff reads it. Each entry is one + /// god that turns on independently, so the policy rewards a cast that + /// crosses ANY of them — not just the maximum. Absence is an empty vec, so a + /// scaling-only deck is never handed a fabricated ceiling. + pub thresholds: 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, +} + +/// A payoff's color demand: a fixed color set, or "whatever color you are most +/// devoted to" (Nykthos' `ChosenColor`), which makes every color relevant. +struct PayoffColors { + fixed: Vec, + any_chosen: bool, +} + +/// 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(); + // Per-color deck pip totals across permanent faces (index by ManaColor). + let mut pip_totals = ColorTotals::default(); + // Which colors any payoff cares about, and the god thresholds per color. + let mut demanded = PayoffColors { + fixed: Vec::new(), + any_chosen: false, + }; + let mut thresholds = ColorThresholds::default(); + + 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. + if face + .card_type + .core_types + .iter() + .any(|t| t.is_permanent_type()) + { + for color in ManaColor::ALL { + let pips = face.mana_cost.count_colored_pips(Some(color)).max(0) as u32; + pip_totals.add(color, pips.saturating_mul(entry.count)); + } + } + + let gate = highest_devotion_gate(face); + let scales = reads_devotion(face); + if let Some((colors, threshold)) = &gate { + for color in colors { + thresholds.insert(*color, *threshold); + demanded.fixed.push(*color); + } + } + for colors in scaling_payoff_colors(face) { + match colors { + DevotionColors::Fixed(cols) => demanded.fixed.extend(cols), + DevotionColors::ChosenColor => demanded.any_chosen = true, + } + } + + if gate.is_some() || scales { + payoff_count = payoff_count.saturating_add(entry.count); + payoff_names.push(face.name.clone()); + } + } + + // The primary color is the deck's most-devoted color among the colors its + // payoffs read. A `ChosenColor` payoff (Nykthos) makes every color eligible, + // so the deck's own densest color wins. + let primary_color = ManaColor::ALL + .iter() + .copied() + .filter(|color| demanded.any_chosen || demanded.fixed.contains(color)) + .max_by_key(|color| pip_totals.get(*color)); + + let (pip_count, thresholds) = match primary_color { + Some(color) => (pip_totals.get(color), thresholds.get(color)), + None => (0, Vec::new()), + }; + + let commitment = compute_commitment(payoff_count, pip_count, total_nonland); + + DevotionFeature { + payoff_count, + primary_color, + pip_count, + thresholds, + commitment, + payoff_names, + } +} + +/// 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) { + for effect in collect_chain_effects(ability) { + 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. Mana-production reads (Nykthos ramp) and cost-reduction + // self-discounts are intentionally NOT reached — see the module limitation. + if let Some(expr) = effect.count_expr() { + collect_devotion_colors_in_expr(expr, out); + } +} + +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`. +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), + _ => None, + } +} + +/// Fixed-size per-color accumulator — avoids a `HashMap` in a hot deck scan. +#[derive(Default)] +struct ColorTotals { + white: u32, + blue: u32, + black: u32, + red: u32, + green: u32, +} + +impl ColorTotals { + fn add(&mut self, color: ManaColor, n: u32) { + let slot = self.slot(color); + *slot = slot.saturating_add(n); + } + fn get(&self, color: ManaColor) -> u32 { + match color { + ManaColor::White => self.white, + ManaColor::Blue => self.blue, + ManaColor::Black => self.black, + ManaColor::Red => self.red, + ManaColor::Green => self.green, + } + } + fn slot(&mut self, color: ManaColor) -> &mut u32 { + match color { + ManaColor::White => &mut self.white, + ManaColor::Blue => &mut self.blue, + ManaColor::Black => &mut self.black, + ManaColor::Red => &mut self.red, + ManaColor::Green => &mut self.green, + } + } +} + +/// Per-color set of DISTINCT god thresholds. Each Theros god turns on +/// independently at its own `DevotionGE` threshold, so a cast crossing a lower +/// gate (activating a smaller god) matters even when a higher gate exists — +/// keeping only the maximum would miss that. An empty slot distinguishes "no +/// gate in this color" from a real threshold, so a scaling-only deck is never +/// handed a fabricated ceiling. +#[derive(Default)] +struct ColorThresholds { + white: Vec, + blue: Vec, + black: Vec, + red: Vec, + green: Vec, +} + +impl ColorThresholds { + fn insert(&mut self, color: ManaColor, threshold: u32) { + let slot = self.slot(color); + if !slot.contains(&threshold) { + slot.push(threshold); + } + } + /// The distinct thresholds in this color, sorted ascending. + fn get(&self, color: ManaColor) -> Vec { + let mut out = self.slot_ref(color).clone(); + out.sort_unstable(); + out + } + fn slot(&mut self, color: ManaColor) -> &mut Vec { + match color { + ManaColor::White => &mut self.white, + ManaColor::Blue => &mut self.blue, + ManaColor::Black => &mut self.black, + ManaColor::Red => &mut self.red, + ManaColor::Green => &mut self.green, + } + } + fn slot_ref(&self, color: ManaColor) -> &Vec { + match color { + ManaColor::White => &self.white, + ManaColor::Blue => &self.blue, + ManaColor::Black => &self.black, + ManaColor::Red => &self.red, + ManaColor::Green => &self.green, + } + } +} 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..2ad8b32d14 --- /dev/null +++ b/crates/phase-ai/src/features/tests/devotion.rs @@ -0,0 +1,284 @@ +//! 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, 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 +} + +/// 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_eq!(f.primary_color, None); + 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_eq!(f.primary_color, None); + 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_color, Some(ManaColor::Black)); + assert_eq!(f.thresholds, vec![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_color, Some(ManaColor::Black)); + // A drain has no god threshold. + assert!(f.thresholds.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_color, Some(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_color, Some(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_color, Some(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_color, Some(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_color, Some(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_color, Some(ManaColor::Black)); + assert_eq!( + f.thresholds, + vec![3, 5], + "both distinct thresholds retained" + ); +} 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..a025975fbc --- /dev/null +++ b/crates/phase-ai/src/policies/devotion.rs @@ -0,0 +1,131 @@ +//! `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::player::PlayerId; + +use crate::features::devotion::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")), + }; + // `activation` already gated on the floor, but the color is what the + // whole verdict keys on — no primary color, nothing to score. + let Some(color) = feature.primary_color else { + return PolicyVerdict::neutral(PolicyReason::new("devotion_na")); + }; + + // Card-local first: how many primary-color pips does THIS cast add, and + // is it even a permanent? CR 700.5 — only permanents contribute. + 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")); + }; + // CR 110.4: only a permanent contributes devotion. + if !obj + .card_types + .core_types + .iter() + .any(|t| t.is_permanent_type()) + { + return PolicyVerdict::neutral(PolicyReason::new("devotion_na")); + } + let added = obj.mana_cost.count_colored_pips(Some(color)).max(0) as u32; + if added == 0 { + return PolicyVerdict::neutral(PolicyReason::new("devotion_off_color")); + } + + // Only now pay for the battlefield devotion scan (CR 700.5 authority). + let current = count_devotion(ctx.state, ctx.ai_player, &[color]); + let pip_scalar = ctx.config.policy_penalties.devotion_pip_progress; + + // CR 700.5 + each god's own `DevotionGE` gate: count every DISTINCT + // threshold this cast newly crosses. Each Theros god turns on + // independently, so a cast can activate more than one at once — and a + // cast that crosses a lower gate matters even when a higher gate it + // does not reach also exists. + let crossed = feature + .thresholds + .iter() + .filter(|&&t| current < t && current + added >= t) + .count() as u32; + if crossed > 0 { + let activation = ctx.config.policy_penalties.devotion_god_activation; + return PolicyVerdict::score( + activation * f64::from(crossed) + pip_scalar * f64::from(added), + PolicyReason::new("devotion_god_activation") + .with_fact("devotion", current as i64) + .with_fact("gods_activated", crossed as i64), + ); + } + + // Otherwise a smooth preference proportional to the pips added. + PolicyVerdict::score( + pip_scalar * f64::from(added), + PolicyReason::new("devotion_pip_progress") + .with_fact("devotion", current as i64) + .with_fact("added", 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..0dee0078ad --- /dev/null +++ b/crates/phase-ai/src/policies/tests/devotion.rs @@ -0,0 +1,303 @@ +//! 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, 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) +} + +/// 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_color: Option, + thresholds: Vec, +) -> AiContext { + let features = DeckFeatures { + devotion: DevotionFeature { + payoff_count: 8, + primary_color, + pip_count: 30, + thresholds, + 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 B: ManaCostShard = ManaCostShard::Black; +const R: ManaCostShard = ManaCostShard::Red; + +// ─── 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, Some(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, Some(ManaColor::Black), vec![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, Some(ManaColor::Black), vec![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, Some(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, Some(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, Some(ManaColor::Black), vec![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, Some(ManaColor::Black), vec![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 + ); +} 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; From 2c6e8c83cd72159d98c012305241836d35e3dc44 Mon Sep 17 00:00:00 2001 From: minion1227 Date: Sun, 26 Jul 2026 01:13:36 -0700 Subject: [PATCH 2/4] fix(phase-ai): detect Nykthos mana-production devotion + exhaustive modification scan MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Addresses matthewevans' CHANGES_REQUESTED on #6602. [HIGH] Detect the mana-production devotion payoff path. `Effect::count_expr` returns None for `Effect::Mana` (a mana effect has no count/amount magnitude — its QuantityExpr lives in the ManaProduction), so a Nykthos / Nyx Lotus / Karametra's Acolyte deck ("add mana equal to your devotion") was classified as having no payoff, leaving commitment at zero and the policy inert. Detection now digs the count out of the ManaProduction via `mana_production_count`, enumerated without a wildcard over all 15 variants. The docstring no longer claims the cost-reduction self-discount carrier, which is genuinely not detected — the "known limitation" is narrowed to exactly that one shape (a StaticMode cost modifier the engine already applies, with no per-cast decision to build toward). Adds `nykthos_mana_production_is_a_payoff`. [MED] Make `continuous_modification_quantity` exhaustive. Replaced the `_ => None` wildcard with the full non-quantity variant list, mirroring the engine authority `game::quantity::continuous_modification_dynamic_quantity`, so a future dynamic ContinuousModification forces this devotion scan to be reconsidered. Co-Authored-By: Claude Opus 4.8 (1M context) --- crates/phase-ai/src/features/devotion.rs | 102 ++++++++++++++++-- .../phase-ai/src/features/tests/devotion.rs | 47 +++++++- 2 files changed, 141 insertions(+), 8 deletions(-) diff --git a/crates/phase-ai/src/features/devotion.rs b/crates/phase-ai/src/features/devotion.rs index d0514a5fe2..dd4587717d 100644 --- a/crates/phase-ai/src/features/devotion.rs +++ b/crates/phase-ai/src/features/devotion.rs @@ -8,7 +8,8 @@ //! `Not{DevotionGE}`). //! - `QuantityRef::Devotion { colors: DevotionColors }` at //! `crates/engine/src/types/ability.rs:5574` — scaling payoffs (Gray -//! Merchant's drain, Anax's power), Nykthos ramp, and X-cost reductions. +//! 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`), @@ -21,7 +22,8 @@ //! 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 ramp — 43 cards in the corpus read it. +//! 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. @@ -248,11 +250,48 @@ fn collect_devotion_colors_in_effect(effect: &Effect, out: &mut Vec 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) { @@ -280,7 +319,9 @@ fn collect_devotion_colors_in_expr(expr: &QuantityExpr, out: &mut Vec Option<&QuantityExpr> { @@ -293,7 +334,56 @@ fn continuous_modification_quantity( | CM::AddDynamicPower { value } | CM::AddDynamicToughness { value } | CM::AddDynamicKeyword { value, .. } => Some(value), - _ => None, + 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/tests/devotion.rs b/crates/phase-ai/src/features/tests/devotion.rs index 2ad8b32d14..739b0a5ad0 100644 --- a/crates/phase-ai/src/features/tests/devotion.rs +++ b/crates/phase-ai/src/features/tests/devotion.rs @@ -4,8 +4,9 @@ use engine::game::DeckEntry; use engine::types::ability::{ - AbilityDefinition, AbilityKind, ContinuousModification, DevotionColors, Effect, QuantityExpr, - QuantityRef, StaticCondition, StaticDefinition, TargetFilter, TriggerDefinition, + 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}; @@ -282,3 +283,45 @@ fn distinct_thresholds_are_all_retained() { "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_color, Some(ManaColor::Green)); +} From 31091564806f57e275404630b4052327347c9350 Mon Sep 17 00:00:00 2001 From: minion1227 Date: Sun, 26 Jul 2026 03:31:42 -0700 Subject: [PATCH 3/4] fix(phase-ai): key devotion gates on exact color sets + walk modal payoff branches MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Round-3 review (#6602): two devotion-policy correctness gaps. [HIGH] Multi-color devotion gates collapsed to a single color, so a two-color god (Athreos W+B, Xenagos R+G) — which gates on COMBINED devotion to both colors (CR 700.5) — was scored against only one component and never valued its activation. - DevotionFeature.primary_color: Option -> primary_colors: Vec; thresholds: Vec -> gates: Vec, each gate keyed on its exact DevotionColors::Fixed set. - detect() tracks whole color-set demands, normalized to WUBRG order, and picks primary_colors as the demanded set with the densest pips. - new cost_devotion_pips() counts a candidate's pips toward a color set, hybrids once (contributes_to) — the single-cost analogue of the engine's count_devotion. - DevotionPolicy evaluates every gate against its own combined set: count_devotion(&gate.colors) vs gate.threshold + cost_devotion_pips. - Athreos/Xenagos threshold-crossing regressions at feature + policy. [MED] Deck-time payoff detection now walks otherwise/modal branches via collect_scoped_effects(ability, AbilityScope::Potential), so a payoff living only in one mode is still classified (mirrors the poison scan). Tests: 27 devotion + full 1499-test phase-ai lib suite pass; clippy -p phase-ai --all-targets -D warnings clean. Co-Authored-By: Claude Opus 4.8 --- crates/phase-ai/src/features/devotion.rs | 191 +++++++++--------- .../phase-ai/src/features/tests/devotion.rs | 137 +++++++++++-- crates/phase-ai/src/policies/devotion.rs | 72 ++++--- .../phase-ai/src/policies/tests/devotion.rs | 103 ++++++++-- 4 files changed, 359 insertions(+), 144 deletions(-) diff --git a/crates/phase-ai/src/features/devotion.rs b/crates/phase-ai/src/features/devotion.rs index dd4587717d..a230613450 100644 --- a/crates/phase-ai/src/features/devotion.rs +++ b/crates/phase-ai/src/features/devotion.rs @@ -43,7 +43,7 @@ use engine::types::ability::{ use engine::types::card_type::CoreType; use engine::types::mana::ManaColor; -use crate::ability_chain::collect_chain_effects; +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 @@ -59,19 +59,21 @@ 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 the deck is most devoted to among the colors its payoffs care - /// about. `None` when the deck has no devotion payoff. The single color the - /// policy scores pip contributions in. - pub primary_color: Option, - /// Raw colored-pip count in `primary_color` across the deck's permanent - /// faces (CR 700.5 counts permanents only). + /// 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` threshold read in `primary_color`, sorted - /// ascending; empty when no threshold payoff reads it. Each entry is one - /// god that turns on independently, so the policy rewards a cast that - /// crosses ANY of them — not just the maximum. Absence is an empty vec, so a - /// scaling-only deck is never handed a fabricated ceiling. - pub thresholds: Vec, + /// 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, @@ -80,11 +82,29 @@ pub struct DevotionFeature { pub payoff_names: Vec, } -/// A payoff's color demand: a fixed color set, or "whatever color you are most -/// devoted to" (Nykthos' `ChosenColor`), which makes every color relevant. -struct PayoffColors { - fixed: Vec, - any_chosen: bool, +/// 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. @@ -98,12 +118,19 @@ pub fn detect(deck: &[DeckEntry]) -> DevotionFeature { let mut payoff_names: Vec = Vec::new(); // Per-color deck pip totals across permanent faces (index by ManaColor). let mut pip_totals = ColorTotals::default(); - // Which colors any payoff cares about, and the god thresholds per color. - let mut demanded = PayoffColors { - fixed: Vec::new(), - any_chosen: false, + // 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); + } }; - let mut thresholds = ColorThresholds::default(); for entry in deck { let face = &entry.card; @@ -127,15 +154,20 @@ pub fn detect(deck: &[DeckEntry]) -> DevotionFeature { let gate = highest_devotion_gate(face); let scales = reads_devotion(face); if let Some((colors, threshold)) = &gate { - for color in colors { - thresholds.insert(*color, *threshold); - demanded.fixed.push(*color); + 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) => demanded.fixed.extend(cols), - DevotionColors::ChosenColor => demanded.any_chosen = true, + DevotionColors::Fixed(cols) => demand(cols), + DevotionColors::ChosenColor => any_chosen = true, } } @@ -145,32 +177,51 @@ pub fn detect(deck: &[DeckEntry]) -> DevotionFeature { } } - // The primary color is the deck's most-devoted color among the colors its - // payoffs read. A `ChosenColor` payoff (Nykthos) makes every color eligible, - // so the deck's own densest color wins. - let primary_color = ManaColor::ALL - .iter() - .copied() - .filter(|color| demanded.any_chosen || demanded.fixed.contains(color)) - .max_by_key(|color| pip_totals.get(*color)); - - let (pip_count, thresholds) = match primary_color { - Some(color) => (pip_totals.get(color), thresholds.get(color)), - None => (0, Vec::new()), - }; + // 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, approximated by summed per-color pips — + // exact combined devotion is computed at runtime per gate. + 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); + } + } + } + let primary_colors = candidates + .into_iter() + .max_by_key(|set| set.iter().map(|c| pip_totals.get(*c)).sum::()) + .unwrap_or_default(); + let pip_count = primary_colors.iter().map(|c| pip_totals.get(*c)).sum(); let commitment = compute_commitment(payoff_count, pip_count, total_nonland); DevotionFeature { payoff_count, - primary_color, + primary_colors, pip_count, - thresholds, + 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 @@ -233,7 +284,11 @@ fn scaling_payoff_colors(face: &engine::types::card::CardFace) -> Vec) { - for effect in collect_chain_effects(ability) { + // 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); } } @@ -421,51 +476,3 @@ impl ColorTotals { } } } - -/// Per-color set of DISTINCT god thresholds. Each Theros god turns on -/// independently at its own `DevotionGE` threshold, so a cast crossing a lower -/// gate (activating a smaller god) matters even when a higher gate exists — -/// keeping only the maximum would miss that. An empty slot distinguishes "no -/// gate in this color" from a real threshold, so a scaling-only deck is never -/// handed a fabricated ceiling. -#[derive(Default)] -struct ColorThresholds { - white: Vec, - blue: Vec, - black: Vec, - red: Vec, - green: Vec, -} - -impl ColorThresholds { - fn insert(&mut self, color: ManaColor, threshold: u32) { - let slot = self.slot(color); - if !slot.contains(&threshold) { - slot.push(threshold); - } - } - /// The distinct thresholds in this color, sorted ascending. - fn get(&self, color: ManaColor) -> Vec { - let mut out = self.slot_ref(color).clone(); - out.sort_unstable(); - out - } - fn slot(&mut self, color: ManaColor) -> &mut Vec { - match color { - ManaColor::White => &mut self.white, - ManaColor::Blue => &mut self.blue, - ManaColor::Black => &mut self.black, - ManaColor::Red => &mut self.red, - ManaColor::Green => &mut self.green, - } - } - fn slot_ref(&self, color: ManaColor) -> &Vec { - match color { - ManaColor::White => &self.white, - ManaColor::Blue => &self.blue, - ManaColor::Black => &self.black, - ManaColor::Red => &self.red, - ManaColor::Green => &self.green, - } - } -} diff --git a/crates/phase-ai/src/features/tests/devotion.rs b/crates/phase-ai/src/features/tests/devotion.rs index 739b0a5ad0..7311da596e 100644 --- a/crates/phase-ai/src/features/tests/devotion.rs +++ b/crates/phase-ai/src/features/tests/devotion.rs @@ -62,6 +62,31 @@ fn god(name: &str, color: ManaColor, threshold: u32, pip: ManaCostShard) -> Card 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 { @@ -101,7 +126,7 @@ const BB: &[ManaCostShard] = &[ManaCostShard::Black, ManaCostShard::Black]; fn empty_deck_produces_defaults() { let f = detect(&[]); assert_eq!(f.payoff_count, 0); - assert_eq!(f.primary_color, None); + assert!(f.primary_colors.is_empty()); assert_eq!(f.commitment, 0.0); assert!(f.payoff_names.is_empty()); } @@ -111,7 +136,7 @@ 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_eq!(f.primary_color, None); + assert!(f.primary_colors.is_empty()); assert_eq!(f.commitment, 0.0); } @@ -122,17 +147,23 @@ fn detects_god_gate_and_threshold() { 1, )]); assert_eq!(f.payoff_count, 1); - assert_eq!(f.primary_color, Some(ManaColor::Black)); - assert_eq!(f.thresholds, vec![5]); + 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_color, Some(ManaColor::Black)); + assert_eq!(f.primary_colors, vec![ManaColor::Black]); // A drain has no god threshold. - assert!(f.thresholds.is_empty()); + assert!(f.gates.is_empty()); } #[test] @@ -142,7 +173,7 @@ fn detects_dynamic_pt_payoff() { 4, )]); assert_eq!(f.payoff_count, 4); - assert_eq!(f.primary_color, Some(ManaColor::Red)); + assert_eq!(f.primary_colors, vec![ManaColor::Red]); } /// CR 700.5 counts permanents only — an instant reading devotion is a payoff, @@ -167,7 +198,7 @@ fn instant_pips_do_not_count_toward_devotion() { entry(spell, 4), entry(pip_body("Green Body", &[ManaCostShard::Green]), 1), ]); - assert_eq!(f.primary_color, Some(ManaColor::Green)); + 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); } @@ -182,7 +213,7 @@ fn primary_color_follows_pip_density_among_payoff_colors() { entry(pip_body("Black Body", BB), 10), ]; let f = detect(&deck); - assert_eq!(f.primary_color, Some(ManaColor::Black)); + assert_eq!(f.primary_colors, vec![ManaColor::Black]); assert!( f.pip_count >= 20, "expected heavy black pip count, got {}", @@ -200,7 +231,7 @@ fn mono_black_devotion_hits_calibration_floor() { entry(pip_body("Filler", &[ManaCostShard::Black]), 16), ]; let f = detect(&deck); - assert_eq!(f.primary_color, Some(ManaColor::Black)); + assert_eq!(f.primary_colors, vec![ManaColor::Black]); assert!( f.commitment > 0.85, "mono-black devotion must clear 0.85, got {}", @@ -260,7 +291,7 @@ fn chosen_color_payoff_uses_deck_densest_color() { entry(pip_body("Red Body", &[ManaCostShard::Red]), 2), ]; let f = detect(&deck); - assert_eq!(f.primary_color, Some(ManaColor::Green)); + assert_eq!(f.primary_colors, vec![ManaColor::Green]); } /// Two gods at DIFFERENT thresholds in the same color must both be retained — @@ -276,10 +307,19 @@ fn distinct_thresholds_are_all_retained() { entry(pip_body("Black Body", BB), 8), ]; let f = detect(&deck); - assert_eq!(f.primary_color, Some(ManaColor::Black)); + assert_eq!(f.primary_colors, vec![ManaColor::Black]); assert_eq!( - f.thresholds, - vec![3, 5], + f.gates, + vec![ + DevotionGate { + colors: vec![ManaColor::Black], + threshold: 3, + }, + DevotionGate { + colors: vec![ManaColor::Black], + threshold: 5, + }, + ], "both distinct thresholds retained" ); } @@ -323,5 +363,72 @@ fn nykthos_mana_production_is_a_payoff() { f.payoff_count, 1, "Nykthos must register as a devotion payoff" ); - assert_eq!(f.primary_color, Some(ManaColor::Green)); + 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, + }] + ); } diff --git a/crates/phase-ai/src/policies/devotion.rs b/crates/phase-ai/src/policies/devotion.rs index a025975fbc..01f771bdf2 100644 --- a/crates/phase-ai/src/policies/devotion.rs +++ b/crates/phase-ai/src/policies/devotion.rs @@ -31,9 +31,10 @@ 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::DEVOTION_FLOOR; +use crate::features::devotion::{cost_devotion_pips, DEVOTION_FLOOR}; use crate::features::DeckFeatures; use super::context::PolicyContext; @@ -68,21 +69,19 @@ impl TacticalPolicy for DevotionPolicy { Some(f) => &f.devotion, None => return PolicyVerdict::neutral(PolicyReason::new("devotion_na")), }; - // `activation` already gated on the floor, but the color is what the - // whole verdict keys on — no primary color, nothing to score. - let Some(color) = feature.primary_color else { + // 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: how many primary-color pips does THIS cast add, and - // is it even a permanent? CR 700.5 — only permanents contribute. + // 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")); }; - // CR 110.4: only a permanent contributes devotion. if !obj .card_types .core_types @@ -91,41 +90,64 @@ impl TacticalPolicy for DevotionPolicy { { return PolicyVerdict::neutral(PolicyReason::new("devotion_na")); } - let added = obj.mana_cost.count_colored_pips(Some(color)).max(0) as u32; - if added == 0 { + + // 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")); } - // Only now pay for the battlefield devotion scan (CR 700.5 authority). - let current = count_devotion(ctx.state, ctx.ai_player, &[color]); let pip_scalar = ctx.config.policy_penalties.devotion_pip_progress; - // CR 700.5 + each god's own `DevotionGE` gate: count every DISTINCT - // threshold this cast newly crosses. Each Theros god turns on - // independently, so a cast can activate more than one at once — and a - // cast that crosses a lower gate matters even when a higher gate it - // does not reach also exists. + // 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 - .thresholds + .gates .iter() - .filter(|&&t| current < t && current + added >= t) + .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(added), + activation * f64::from(crossed) + pip_scalar * f64::from(primary_added), PolicyReason::new("devotion_god_activation") - .with_fact("devotion", current as i64) + .with_fact("devotion", primary_devotion as i64) .with_fact("gods_activated", crossed as i64), ); } - // Otherwise a smooth preference proportional to the pips added. + 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(added), + pip_scalar * f64::from(primary_added), PolicyReason::new("devotion_pip_progress") - .with_fact("devotion", current as i64) - .with_fact("added", added as i64), + .with_fact("devotion", primary_devotion as i64) + .with_fact("added", primary_added as i64), ) } } diff --git a/crates/phase-ai/src/policies/tests/devotion.rs b/crates/phase-ai/src/policies/tests/devotion.rs index 0dee0078ad..ead38a6c8e 100644 --- a/crates/phase-ai/src/policies/tests/devotion.rs +++ b/crates/phase-ai/src/policies/tests/devotion.rs @@ -21,7 +21,7 @@ use engine::types::zones::Zone; use crate::config::AiConfig; use crate::context::AiContext; -use crate::features::devotion::{DevotionFeature, DEVOTION_FLOOR}; +use crate::features::devotion::{DevotionFeature, DevotionGate, DEVOTION_FLOOR}; use crate::features::DeckFeatures; use crate::policies::context::{PolicyContext, SearchDepth}; use crate::policies::devotion::*; @@ -38,19 +38,31 @@ 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_color: Option, - thresholds: Vec, + primary_colors: Vec, + gates: Vec, ) -> AiContext { let features = DeckFeatures { devotion: DevotionFeature { payoff_count: 8, - primary_color, + primary_colors, pip_count: 30, - thresholds, + gates, commitment: 0.9, payoff_names: Vec::new(), }, @@ -148,8 +160,10 @@ fn score_of(verdict: PolicyVerdict) -> (f64, PolicyReason) { } } +const W: ManaCostShard = ManaCostShard::White; const B: ManaCostShard = ManaCostShard::Black; const R: ManaCostShard = ManaCostShard::Red; +const G: ManaCostShard = ManaCostShard::Green; // ─── activation ────────────────────────────────────────────────────────────── @@ -175,7 +189,7 @@ fn activation_opts_in_above_floor() { #[test] fn verdict_scores_pips_added_when_no_threshold() { let config = config(); - let context = context_with(&config, Some(ManaColor::Black), Vec::new()); + 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(); @@ -190,7 +204,7 @@ fn verdict_scores_pips_added_when_no_threshold() { #[test] fn verdict_god_activation_when_cast_crosses_threshold() { let config = config(); - let context = context_with(&config, Some(ManaColor::Black), vec![5]); + 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]); @@ -211,7 +225,7 @@ fn verdict_god_activation_when_cast_crosses_threshold() { #[test] fn verdict_below_threshold_without_crossing_is_pip_progress() { let config = config(); - let context = context_with(&config, Some(ManaColor::Black), vec![5]); + 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]); @@ -226,7 +240,7 @@ fn verdict_below_threshold_without_crossing_is_pip_progress() { #[test] fn verdict_off_color_cast_is_neutral() { let config = config(); - let context = context_with(&config, Some(ManaColor::Black), Vec::new()); + 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(); @@ -241,7 +255,7 @@ fn verdict_off_color_cast_is_neutral() { #[test] fn verdict_instant_is_neutral() { let config = config(); - let context = context_with(&config, Some(ManaColor::Black), Vec::new()); + 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(); @@ -258,7 +272,7 @@ fn verdict_instant_is_neutral() { #[test] fn verdict_crossing_lower_threshold_activates_that_god() { let config = config(); - let context = context_with(&config, Some(ManaColor::Black), vec![3, 5]); + 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]); @@ -282,7 +296,7 @@ fn verdict_crossing_lower_threshold_activates_that_god() { #[test] fn verdict_crossing_two_thresholds_activates_both() { let config = config(); - let context = context_with(&config, Some(ManaColor::Black), vec![3, 5]); + 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]); @@ -301,3 +315,68 @@ fn verdict_crossing_two_thresholds_activates_both() { 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 + ); +} From fa5d91c5a96e49f42825ece269b9a3b3515e7de9 Mon Sep 17 00:00:00 2001 From: minion1227 Date: Sun, 26 Jul 2026 03:55:15 -0700 Subject: [PATCH 4/4] fix(phase-ai): count devotion pips set-wise so hybrids count once for a color set MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Round-4 review (#6602): the deck-calibration scan built per-color pip buckets with count_colored_pips(Some(color)) and then summed those buckets for a multi-color primary set. A hybrid {W/B} shard lands in both the white and black buckets, so a W+B set counted it twice — inflating pip_count and commitment and over-activating the policy on hybrid-heavy multi-color devotion decks. CR 700.5 counts each symbol once per set. detect() now keeps each permanent face's mana cost and computes every candidate set's deck total SET-WISE via cost_devotion_pips(cost, set) * count — the same one-symbol-per-set counting count_devotion performs at runtime — for both primary-set selection and pip_count. The per-color ColorTotals accumulator is removed. Mono-color totals are unchanged (a single-color set counts the same shards either way); only multi-color hybrid accounting is corrected. Adds hybrid_pips_count_once_for_a_combined_set: an Athreos W+B deck with four {W/B}{W/B} bodies must read pip_count 10 (2 god + 4×2), not the 18 a per-color-bucket sum would report. Tests: 28 devotion + full 1500-test phase-ai lib suite pass; clippy -p phase-ai --all-targets -D warnings clean. Co-Authored-By: Claude Opus 4.8 --- crates/phase-ai/src/features/devotion.rs | 67 ++++++------------- .../phase-ai/src/features/tests/devotion.rs | 36 ++++++++++ 2 files changed, 57 insertions(+), 46 deletions(-) diff --git a/crates/phase-ai/src/features/devotion.rs b/crates/phase-ai/src/features/devotion.rs index a230613450..8d7e7505cb 100644 --- a/crates/phase-ai/src/features/devotion.rs +++ b/crates/phase-ai/src/features/devotion.rs @@ -116,8 +116,10 @@ pub fn detect(deck: &[DeckEntry]) -> DevotionFeature { let mut payoff_count = 0u32; let mut total_nonland = 0u32; let mut payoff_names: Vec = Vec::new(); - // Per-color deck pip totals across permanent faces (index by ManaColor). - let mut pip_totals = ColorTotals::default(); + // 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). @@ -138,17 +140,15 @@ pub fn detect(deck: &[DeckEntry]) -> DevotionFeature { total_nonland = total_nonland.saturating_add(entry.count); } - // CR 700.5 + CR 110.4: only permanents contribute devotion pips. + // 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()) { - for color in ManaColor::ALL { - let pips = face.mana_cost.count_colored_pips(Some(color)).max(0) as u32; - pip_totals.add(color, pips.saturating_mul(entry.count)); - } + permanent_costs.push((&face.mana_cost, entry.count)); } let gate = highest_devotion_gate(face); @@ -179,8 +179,7 @@ pub fn detect(deck: &[DeckEntry]) -> DevotionFeature { // 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, approximated by summed per-color pips — - // exact combined devotion is computed at runtime per gate. + // set the deck is most devoted to. let mut candidates = demanded_sets.clone(); if any_chosen { for color in ManaColor::ALL { @@ -190,11 +189,22 @@ pub fn detect(deck: &[DeckEntry]) -> DevotionFeature { } } } + // 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.iter().map(|c| pip_totals.get(*c)).sum::()) + .max_by_key(|set| set_total(set)) .unwrap_or_default(); - let pip_count = primary_colors.iter().map(|c| pip_totals.get(*c)).sum(); + let pip_count = set_total(&primary_colors); let commitment = compute_commitment(payoff_count, pip_count, total_nonland); @@ -441,38 +451,3 @@ fn continuous_modification_quantity( | CM::RemoveManaCost => None, } } - -/// Fixed-size per-color accumulator — avoids a `HashMap` in a hot deck scan. -#[derive(Default)] -struct ColorTotals { - white: u32, - blue: u32, - black: u32, - red: u32, - green: u32, -} - -impl ColorTotals { - fn add(&mut self, color: ManaColor, n: u32) { - let slot = self.slot(color); - *slot = slot.saturating_add(n); - } - fn get(&self, color: ManaColor) -> u32 { - match color { - ManaColor::White => self.white, - ManaColor::Blue => self.blue, - ManaColor::Black => self.black, - ManaColor::Red => self.red, - ManaColor::Green => self.green, - } - } - fn slot(&mut self, color: ManaColor) -> &mut u32 { - match color { - ManaColor::White => &mut self.white, - ManaColor::Blue => &mut self.blue, - ManaColor::Black => &mut self.black, - ManaColor::Red => &mut self.red, - ManaColor::Green => &mut self.green, - } - } -} diff --git a/crates/phase-ai/src/features/tests/devotion.rs b/crates/phase-ai/src/features/tests/devotion.rs index 7311da596e..daf99e504b 100644 --- a/crates/phase-ai/src/features/tests/devotion.rs +++ b/crates/phase-ai/src/features/tests/devotion.rs @@ -432,3 +432,39 @@ fn dual_color_god_xenagos_red_green() { }] ); } + +/// [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 + ); +}