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;