From 47ca1da822eb81b5319330643e072b817bd92906 Mon Sep 17 00:00:00 2001 From: minion1227 Date: Fri, 24 Jul 2026 01:02:43 -0700 Subject: [PATCH 1/2] 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}) * scaling payoffs: QuantityRef::Devotion anywhere in an effect's magnitude, reached via the engine's own Effect::count_expr authority (so drains, damage, token counts, draw counts, dynamic P/T are all covered without hand-listing effect variants) 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 `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. `highest_threshold` is Option so a scaling-only deck is never handed a fabricated god ceiling (mirrors graveyard_types). 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 distinct god-activation spike when the cast crosses a DevotionGE threshold (turning a non-creature enchantment into a body — the "last missing piece" structure graveyard_types uses). 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 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, no per-cast decision to make). Both only lower commitment. Boundary with tribal/mana_ramp: 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} Gray Merchant deck is the reverse. Co-Authored-By: Claude Opus 4.8 (1M context) --- crates/phase-ai/src/config.rs | 16 + crates/phase-ai/src/features/devotion.rs | 385 ++++++++++++++++++ crates/phase-ai/src/features/mod.rs | 4 + .../phase-ai/src/features/tests/devotion.rs | 263 ++++++++++++ crates/phase-ai/src/features/tests/mod.rs | 1 + crates/phase-ai/src/policies/devotion.rs | 136 +++++++ crates/phase-ai/src/policies/mod.rs | 1 + crates/phase-ai/src/policies/registry.rs | 4 + .../phase-ai/src/policies/tests/devotion.rs | 253 ++++++++++++ crates/phase-ai/src/policies/tests/mod.rs | 1 + 10 files changed, 1064 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..003c7f427c 100644 --- a/crates/phase-ai/src/config.rs +++ b/crates/phase-ai/src/config.rs @@ -473,6 +473,12 @@ 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). + 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. + pub devotion_god_activation: f64, } impl Default for PolicyPenalties { @@ -539,6 +545,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: 0.35, + devotion_god_activation: 2.5, } } } @@ -749,6 +757,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 \ diff --git a/crates/phase-ai/src/features/devotion.rs b/crates/phase-ai/src/features/devotion.rs new file mode 100644 index 0000000000..122588c902 --- /dev/null +++ b/crates/phase-ai/src/features/devotion.rs @@ -0,0 +1,385 @@ +//! 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, + /// The highest `DevotionGE` threshold any god/gate reads in `primary_color`, + /// or `None` when no threshold payoff reads it. Drives the policy's + /// "this cast turns the god on" spike. Absence is modelled distinctly from a + /// fabricated ceiling, mirroring `graveyard_types`. + pub highest_threshold: Option, + /// `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: only permanents contribute devotion pips. + if is_permanent_face(&face.card_type.core_types) { + 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.raise(*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, highest_threshold) = match primary_color { + Some(color) => (pip_totals.get(color), thresholds.get(color)), + None => (0, None), + }; + + let commitment = compute_commitment(payoff_count, pip_count, total_nonland); + + DevotionFeature { + payoff_count, + primary_color, + pip_count, + highest_threshold, + 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]) +} + +/// CR 205.2a: a face that can enter the battlefield contributes devotion. +/// Instants and sorceries never do (their pips are not "permanents you control"). +fn is_permanent_face(core_types: &[CoreType]) -> bool { + core_types.iter().any(|t| { + matches!( + t, + CoreType::Creature + | CoreType::Artifact + | CoreType::Enchantment + | CoreType::Planeswalker + | CoreType::Land + | CoreType::Battle + ) + }) +} + +/// 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 god-threshold maxima. `None` slots distinguish "no gate in this +/// color" from a real threshold, so a scaling-only deck is never handed a +/// fabricated ceiling. +#[derive(Default)] +struct ColorThresholds { + totals: ColorTotals, + seen: ColorSeen, +} + +#[derive(Default)] +struct ColorSeen { + white: bool, + blue: bool, + black: bool, + red: bool, + green: bool, +} + +impl ColorThresholds { + fn raise(&mut self, color: ManaColor, threshold: u32) { + let current = self.totals.get(color); + *self.totals.slot(color) = current.max(threshold); + match color { + ManaColor::White => self.seen.white = true, + ManaColor::Blue => self.seen.blue = true, + ManaColor::Black => self.seen.black = true, + ManaColor::Red => self.seen.red = true, + ManaColor::Green => self.seen.green = true, + } + } + fn get(&self, color: ManaColor) -> Option { + let seen = match color { + ManaColor::White => self.seen.white, + ManaColor::Blue => self.seen.blue, + ManaColor::Black => self.seen.black, + ManaColor::Red => self.seen.red, + ManaColor::Green => self.seen.green, + }; + seen.then(|| self.totals.get(color)) + } +} 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..1750323da0 --- /dev/null +++ b/crates/phase-ai/src/features/tests/devotion.rs @@ -0,0 +1,263 @@ +//! 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.highest_threshold, Some(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_eq!(f.highest_threshold, None); +} + +#[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)); +} 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..f509d654bb --- /dev/null +++ b/crates/phase-ai/src/policies/devotion.rs @@ -0,0 +1,136 @@ +//! `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::card_type::CoreType; +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")); + }; + if !is_permanent(&obj.card_types.core_types) { + 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 + the god's `DevotionGE` gate: does this cast cross a god's + // threshold, turning it from a non-creature into a body? + if let Some(threshold) = feature.highest_threshold { + if current < threshold && current + added >= threshold { + let activation = ctx.config.policy_penalties.devotion_god_activation; + return PolicyVerdict::score( + activation + pip_scalar * f64::from(added), + PolicyReason::new("devotion_god_activation") + .with_fact("devotion", current as i64) + .with_fact("threshold", threshold 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), + ) + } +} + +/// CR 205.2a: a permanent can enter the battlefield and thus contribute +/// devotion; an instant or sorcery never does. +fn is_permanent(core_types: &[CoreType]) -> bool { + core_types.iter().any(|t| { + matches!( + t, + CoreType::Creature + | CoreType::Artifact + | CoreType::Enchantment + | CoreType::Planeswalker + | CoreType::Land + | CoreType::Battle + ) + }) +} 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..d52059f3c5 --- /dev/null +++ b/crates/phase-ai/src/policies/tests/devotion.rs @@ -0,0 +1,253 @@ +//! 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, + highest_threshold: Option, +) -> AiContext { + let features = DeckFeatures { + devotion: DevotionFeature { + payoff_count: 8, + primary_color, + pip_count: 30, + highest_threshold, + commitment: 0.9, + payoff_names: Vec::new(), + }, + ..Default::default() + }; + let mut session = AiSession::empty(); + session.features.insert(AI, features); + let mut context = AiContext::empty(&config.weights); + context.session = Arc::new(session); + context.player = AI; + context +} + +fn priority_decision() -> AiDecisionContext { + AiDecisionContext { + waiting_for: WaitingFor::Priority { player: AI }, + candidates: Vec::new(), + } +} + +fn ctx<'a>( + state: &'a GameState, + candidate: &'a CandidateAction, + decision: &'a AiDecisionContext, + context: &'a AiContext, + config: &'a AiConfig, +) -> PolicyContext<'a> { + PolicyContext { + state, + decision, + candidate, + ai_player: AI, + config, + context, + cast_facts: None, + search_depth: SearchDepth::Root, + } +} + +/// 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), None); + 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), Some(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), Some(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), None); + 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), None); + 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); +} 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 bf9be8997ffea0153e465c136da499f2415d21db Mon Sep 17 00:00:00 2001 From: minion1227 Date: Fri, 24 Jul 2026 10:03:29 -0700 Subject: [PATCH 2/2] =?UTF-8?q?fix(phase-ai):=20address=20review=20?= =?UTF-8?q?=E2=80=94=20serde=20defaults,=20distinct=20devotion=20threshold?= =?UTF-8?q?s,=20permanent-type=20authority?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Addresses matthewevans' CHANGES_REQUESTED on #6590. [HIGH] Persisted-artifact compatibility. `devotion_pip_progress` and `devotion_god_activation` now carry `#[serde(default = "...")]` backed by shared `default_*` functions that the `Default` impl also calls, so an older `policy_penalties` payload that omits them deserializes with the 0.35/2.5 fallbacks instead of failing. Adds `policy_penalties_load_pre_devotion_artifact` mirroring the pre-poison / pre-graveyard artifact tests. [MED] Retain every distinct devotion threshold. `DevotionFeature` now carries `thresholds: Vec` (deduplicated, sorted) instead of a single maximum, and the policy rewards a cast that crosses ANY not-yet-met threshold — each Theros god turns on independently, so a cast reaching a lower gate activates that god even when a higher gate it does not reach also exists. A cast clearing several gates at once activates all of them (`gods_activated` fact). Two feature/policy tests pin the lower-gate-crossing and double-crossing cases. [LOW] Reuse the engine permanent-type authority. Both the feature and the policy now call `CoreType::is_permanent_type` (which itself cites CR 110.4) instead of re-implementing the match, and the annotations cite CR 110.4 (the permanent-type rule) rather than CR 205.2a (which only lists card types). Co-Authored-By: Claude Opus 4.8 (1M context) --- crates/phase-ai/src/config.rs | 48 +++++++- crates/phase-ai/src/features/devotion.rs | 112 +++++++++--------- .../phase-ai/src/features/tests/devotion.rs | 25 +++- crates/phase-ai/src/policies/devotion.rs | 55 ++++----- .../phase-ai/src/policies/tests/devotion.rs | 64 ++++++++-- 5 files changed, 205 insertions(+), 99 deletions(-) diff --git a/crates/phase-ai/src/config.rs b/crates/phase-ai/src/config.rs index 003c7f427c..fe4194cd35 100644 --- a/crates/phase-ai/src/config.rs +++ b/crates/phase-ai/src/config.rs @@ -475,9 +475,11 @@ pub struct PolicyPenalties { 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, } @@ -545,8 +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: 0.35, - devotion_god_activation: 2.5, + devotion_pip_progress: default_devotion_pip_progress(), + devotion_god_activation: default_devotion_god_activation(), } } } @@ -621,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 } @@ -1608,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 index 122588c902..d0514a5fe2 100644 --- a/crates/phase-ai/src/features/devotion.rs +++ b/crates/phase-ai/src/features/devotion.rs @@ -64,11 +64,12 @@ pub struct DevotionFeature { /// Raw colored-pip count in `primary_color` across the deck's permanent /// faces (CR 700.5 counts permanents only). pub pip_count: u32, - /// The highest `DevotionGE` threshold any god/gate reads in `primary_color`, - /// or `None` when no threshold payoff reads it. Drives the policy's - /// "this cast turns the god on" spike. Absence is modelled distinctly from a - /// fabricated ceiling, mirroring `graveyard_types`. - pub highest_threshold: Option, + /// 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, @@ -108,8 +109,13 @@ pub fn detect(deck: &[DeckEntry]) -> DevotionFeature { total_nonland = total_nonland.saturating_add(entry.count); } - // CR 700.5: only permanents contribute devotion pips. - if is_permanent_face(&face.card_type.core_types) { + // 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)); @@ -120,7 +126,7 @@ pub fn detect(deck: &[DeckEntry]) -> DevotionFeature { let scales = reads_devotion(face); if let Some((colors, threshold)) = &gate { for color in colors { - thresholds.raise(*color, *threshold); + thresholds.insert(*color, *threshold); demanded.fixed.push(*color); } } @@ -146,9 +152,9 @@ pub fn detect(deck: &[DeckEntry]) -> DevotionFeature { .filter(|color| demanded.any_chosen || demanded.fixed.contains(color)) .max_by_key(|color| pip_totals.get(*color)); - let (pip_count, highest_threshold) = match primary_color { + let (pip_count, thresholds) = match primary_color { Some(color) => (pip_totals.get(color), thresholds.get(color)), - None => (0, None), + None => (0, Vec::new()), }; let commitment = compute_commitment(payoff_count, pip_count, total_nonland); @@ -157,7 +163,7 @@ pub fn detect(deck: &[DeckEntry]) -> DevotionFeature { payoff_count, primary_color, pip_count, - highest_threshold, + thresholds, commitment, payoff_names, } @@ -177,22 +183,6 @@ fn compute_commitment(payoff_count: u32, pip_count: u32, total_nonland: u32) -> commitment::geometric_mean(&[payoff_density, pip_density]) } -/// CR 205.2a: a face that can enter the battlefield contributes devotion. -/// Instants and sorceries never do (their pips are not "permanents you control"). -fn is_permanent_face(core_types: &[CoreType]) -> bool { - core_types.iter().any(|t| { - matches!( - t, - CoreType::Creature - | CoreType::Artifact - | CoreType::Enchantment - | CoreType::Planeswalker - | CoreType::Land - | CoreType::Battle - ) - }) -} - /// 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 @@ -342,44 +332,50 @@ impl ColorTotals { } } -/// Per-color god-threshold maxima. `None` slots distinguish "no gate in this -/// color" from a real threshold, so a scaling-only deck is never handed a -/// fabricated ceiling. +/// 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 { - totals: ColorTotals, - seen: ColorSeen, -} - -#[derive(Default)] -struct ColorSeen { - white: bool, - blue: bool, - black: bool, - red: bool, - green: bool, + white: Vec, + blue: Vec, + black: Vec, + red: Vec, + green: Vec, } impl ColorThresholds { - fn raise(&mut self, color: ManaColor, threshold: u32) { - let current = self.totals.get(color); - *self.totals.slot(color) = current.max(threshold); + 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 => self.seen.white = true, - ManaColor::Blue => self.seen.blue = true, - ManaColor::Black => self.seen.black = true, - ManaColor::Red => self.seen.red = true, - ManaColor::Green => self.seen.green = true, + 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 get(&self, color: ManaColor) -> Option { - let seen = match color { - ManaColor::White => self.seen.white, - ManaColor::Blue => self.seen.blue, - ManaColor::Black => self.seen.black, - ManaColor::Red => self.seen.red, - ManaColor::Green => self.seen.green, - }; - seen.then(|| self.totals.get(color)) + 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 1750323da0..2ad8b32d14 100644 --- a/crates/phase-ai/src/features/tests/devotion.rs +++ b/crates/phase-ai/src/features/tests/devotion.rs @@ -122,7 +122,7 @@ fn detects_god_gate_and_threshold() { )]); assert_eq!(f.payoff_count, 1); assert_eq!(f.primary_color, Some(ManaColor::Black)); - assert_eq!(f.highest_threshold, Some(5)); + assert_eq!(f.thresholds, vec![5]); } #[test] @@ -131,7 +131,7 @@ fn detects_scaling_drain_payoff() { assert_eq!(f.payoff_count, 4); assert_eq!(f.primary_color, Some(ManaColor::Black)); // A drain has no god threshold. - assert_eq!(f.highest_threshold, None); + assert!(f.thresholds.is_empty()); } #[test] @@ -261,3 +261,24 @@ fn chosen_color_payoff_uses_deck_densest_color() { 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/policies/devotion.rs b/crates/phase-ai/src/policies/devotion.rs index f509d654bb..a025975fbc 100644 --- a/crates/phase-ai/src/policies/devotion.rs +++ b/crates/phase-ai/src/policies/devotion.rs @@ -30,7 +30,6 @@ use engine::game::devotion::count_devotion; use engine::types::actions::GameAction; -use engine::types::card_type::CoreType; use engine::types::game_state::GameState; use engine::types::player::PlayerId; @@ -83,7 +82,13 @@ impl TacticalPolicy for DevotionPolicy { let Some(obj) = ctx.state.objects.get(object_id) else { return PolicyVerdict::neutral(PolicyReason::new("devotion_na")); }; - if !is_permanent(&obj.card_types.core_types) { + // 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; @@ -95,18 +100,24 @@ impl TacticalPolicy for DevotionPolicy { let current = count_devotion(ctx.state, ctx.ai_player, &[color]); let pip_scalar = ctx.config.policy_penalties.devotion_pip_progress; - // CR 700.5 + the god's `DevotionGE` gate: does this cast cross a god's - // threshold, turning it from a non-creature into a body? - if let Some(threshold) = feature.highest_threshold { - if current < threshold && current + added >= threshold { - let activation = ctx.config.policy_penalties.devotion_god_activation; - return PolicyVerdict::score( - activation + pip_scalar * f64::from(added), - PolicyReason::new("devotion_god_activation") - .with_fact("devotion", current as i64) - .with_fact("threshold", threshold as i64), - ); - } + // 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. @@ -118,19 +129,3 @@ impl TacticalPolicy for DevotionPolicy { ) } } - -/// CR 205.2a: a permanent can enter the battlefield and thus contribute -/// devotion; an instant or sorcery never does. -fn is_permanent(core_types: &[CoreType]) -> bool { - core_types.iter().any(|t| { - matches!( - t, - CoreType::Creature - | CoreType::Artifact - | CoreType::Enchantment - | CoreType::Planeswalker - | CoreType::Land - | CoreType::Battle - ) - }) -} diff --git a/crates/phase-ai/src/policies/tests/devotion.rs b/crates/phase-ai/src/policies/tests/devotion.rs index d52059f3c5..0dee0078ad 100644 --- a/crates/phase-ai/src/policies/tests/devotion.rs +++ b/crates/phase-ai/src/policies/tests/devotion.rs @@ -43,14 +43,14 @@ fn state() -> GameState { fn context_with( config: &AiConfig, primary_color: Option, - highest_threshold: Option, + thresholds: Vec, ) -> AiContext { let features = DeckFeatures { devotion: DevotionFeature { payoff_count: 8, primary_color, pip_count: 30, - highest_threshold, + thresholds, commitment: 0.9, payoff_names: Vec::new(), }, @@ -175,7 +175,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), None); + 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(); @@ -190,7 +190,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), Some(5)); + 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]); @@ -211,7 +211,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), Some(5)); + 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]); @@ -226,7 +226,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), None); + 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(); @@ -241,7 +241,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), None); + 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(); @@ -251,3 +251,53 @@ fn verdict_instant_is_neutral() { 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 + ); +}