Skip to content
Merged
Show file tree
Hide file tree
Changes from 2 commits
Commits
Show all changes
19 commits
Select commit Hold shift + click to select a range
7952325
feat(phase-ai): add draw-matters deck-feature axis + DrawPayoffPolicy
minion1227 Jul 27, 2026
09edbb1
fix(phase-ai): gate draw payoff on live per-turn trigger eligibility
minion1227 Jul 27, 2026
8af6c31
fix(phase-ai): scope live draw detection to unconditional effects + c…
minion1227 Jul 27, 2026
db3fe36
fix(phase-ai): engine-owned trigger fireability for draw-payoff (roun…
minion1227 Jul 27, 2026
36a6373
fix(phase-ai): gate draw-payoff on draw-delivery + full multi-target …
minion1227 Jul 27, 2026
dd82d32
fix(engine): preserve source-sensitive constraints in hypothetical tr…
minion1227 Jul 27, 2026
83bb923
Merge branch 'main' into minion_draw_matters_axis
matthewevans Jul 27, 2026
329f4fb
refactor(engine): align execute_targets_satisfiable with trigger-pipe…
minion1227 Jul 27, 2026
1a606bf
fix(phase-ai): gate draw-payoff on library delivery + correct draw CR…
minion1227 Jul 27, 2026
a129a92
fix(engine+phase-ai): reject unsupported/no-execute payoffs + honor e…
minion1227 Jul 27, 2026
a5e2dfe
fix(phase-ai): replacement-aware draw preflight + registry-routed act…
minion1227 Jul 27, 2026
8b4c620
fix(PR-6688): freeze draw replacement test producer
matthewevans Jul 27, 2026
3c86863
fix(engine+phase-ai): route draw preflight through the live replaceme…
minion1227 Jul 27, 2026
7c2f9fc
fix(engine+phase-ai): share the draw-substitution classifier with the…
minion1227 Jul 27, 2026
fd61fd7
fix(engine): factor the test replacement-shape tuple into a type alias
minion1227 Jul 27, 2026
1a411e1
fix(phase-ai): card-local gate first + exhaustive action match + boun…
minion1227 Jul 27, 2026
e7bcdea
Merge remote-tracking branch 'upstream/main' into minion_draw_matters…
minion1227 Jul 27, 2026
49e22f5
fix(phase-ai): classify GameAction::EndContinuousEffect in the draw-p…
minion1227 Jul 27, 2026
7b8bdd3
fix(phase-ai): require a positive resolved draw quantity on live cand…
minion1227 Jul 27, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
12 changes: 12 additions & 0 deletions crates/phase-ai/src/config.rs
Original file line number Diff line number Diff line change
Expand Up @@ -499,6 +499,10 @@ pub struct PolicyPenalties {
/// threshold, turning a non-creature enchantment into a body.
#[serde(default = "default_devotion_god_activation")]
pub devotion_god_activation: f64,
/// CR 121.1: card-equivalent value of drawing into one active "whenever you
/// draw" engine (preference band, per engine).
#[serde(default = "default_draw_payoff_bonus")]
pub draw_payoff_bonus: f64,
}

impl Default for PolicyPenalties {
Expand Down Expand Up @@ -572,6 +576,7 @@ impl Default for PolicyPenalties {
graveyard_types_progress: default_graveyard_types_progress(),
devotion_pip_progress: default_devotion_pip_progress(),
devotion_god_activation: default_devotion_god_activation(),
draw_payoff_bonus: default_draw_payoff_bonus(),
}
}
}
Expand Down Expand Up @@ -666,6 +671,9 @@ fn default_devotion_pip_progress() -> f64 {
fn default_devotion_god_activation() -> f64 {
2.5
}
fn default_draw_payoff_bonus() -> f64 {
0.6
}
fn default_sacrifice_token_cost() -> f64 {
0.5
}
Expand Down Expand Up @@ -810,6 +818,10 @@ pub const UNTUNED_POLICY_PENALTY_FIELDS: &[(&str, &str)] = &[
"devotion_god_activation",
"CR 700.5 god-threshold-crossing swing weight — awaiting a paired-seed ai-gate calibration.",
),
(
"draw_payoff_bonus",
"CR 121.1 per-engine draw-payoff weight — awaiting a paired-seed ai-gate calibration.",
),
(
"poison_clock_pressure",
"CR 104.3d win-detector weight — a critical-band term whose magnitude is \
Expand Down
161 changes: 161 additions & 0 deletions crates/phase-ai/src/features/draw_matters.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,161 @@
//! Draw-matters feature — structural detection of a "whenever you draw" engine
//! deck.
//!
//! Parser AST verification — VERIFIED against engine source:
//! - `Effect::Draw { count, target }` at `crates/engine/src/types/ability.rs:10108`
//! — the card-draw enablers (scoped to `TargetFilter::Controller`, i.e. "you
//! draw").
//! - `TriggerMode::Drawn` at `crates/engine/src/types/triggers.rs:319`
//! (CR 121.1: a card was drawn) — the payoffs.
//! - `TriggerDefinition.valid_target` (`Option<TargetFilter>`) at `ability.rs`
//! — used to keep only YOUR-draw engines (`None`/`Controller`), excluding the
//! "whenever an opponent draws" punisher shape.
//!
//! No parser remediation required — every axis is expressible over existing
//! typed AST.
//!
//! ## Why this axis exists
//!
//! A deck built around a "whenever you draw a card" engine — The Locust God
//! (make an Insect), Psychosis Crawler / Niv-Mizzet (ping), Chulane — turns
//! every extra draw into a repeatable value trigger (CR 121.1). `card_advantage`
//! values *having* cards, but nothing values *triggering* the engine, so the AI
//! will not lean into extra draws when it has a payoff on the battlefield. This
//! axis lets a policy see that engine.
//!
//! ## Boundary with `card_advantage` / `spellslinger_prowess`
//!
//! `card_advantage` scores the card itself (~1 card-equivalent per draw);
//! this axis adds the *extra* value a draw carries when it also fires an engine
//! — the same split as `CyclingDisciplinePolicy` (patience) vs a payoff policy.
//! `spellslinger_prowess` counts spell-cast triggers; a draw event (CR 121.1) is
//! a disjoint trigger. A card can read on both axes — the overlap is intentional
//! and the axes stay independent.

use engine::game::DeckEntry;
use engine::types::ability::{AbilityDefinition, Effect, TargetFilter, TriggerDefinition};
use engine::types::card_type::CoreType;
use engine::types::triggers::TriggerMode;

use crate::ability_chain::{collect_scoped_effects, AbilityScope};
use crate::features::commitment;

/// Commitment at or above which "drawing matters" is a real plan for this deck
/// rather than incidental card advantage. Gates `DrawPayoffPolicy::activation`.
pub const DRAW_MATTERS_FLOOR: f32 = 0.35;

/// CR 121.1: per-deck draw-matters classification.
///
/// Populated once per game from `DeckEntry` data. Detection is structural over
/// `CardFace.abilities` and `CardFace.triggers` — never by card name.
#[derive(Debug, Clone, Default)]
pub struct DrawMattersFeature {
/// Cards that draw you extra cards (an `Effect::Draw` scoped to the
/// controller) — the enablers that feed the payoff engine.
pub source_count: u32,
/// Permanents carrying a "whenever you draw a card" engine trigger
/// (CR 121.1), controller-scoped and not self-referential — the payoffs that
/// make extra draws actively good.
pub payoff_count: u32,
/// `0.0..=1.0` — how central drawing-as-a-payoff is to this deck. Consumed by
/// `DrawPayoffPolicy::activation` as the single scaling knob.
pub commitment: f32,
}

/// Structural detection over each `DeckEntry`'s `CardFace` AST.
pub fn detect(deck: &[DeckEntry]) -> DrawMattersFeature {
if deck.is_empty() {
return DrawMattersFeature::default();
}

let mut source_count = 0u32;
let mut payoff_count = 0u32;
let mut total_nonland = 0u32;

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);
}

if is_draw_source_parts(&face.abilities) {
source_count = source_count.saturating_add(entry.count);
}
if is_draw_payoff_parts(&face.triggers) {
payoff_count = payoff_count.saturating_add(entry.count);
}
}

let commitment = compute_commitment(source_count, payoff_count, total_nonland);

DrawMattersFeature {
source_count,
payoff_count,
commitment,
}
}

/// CR 121.1: the abilities draw YOU one or more cards — a repeatable enabler for
/// the payoff engine. `AbilityScope::Potential` walks modal / else branches so a
/// draw mode of a modal spell still counts. Parts-based so it classifies both a
/// deck-time `CardFace.abilities` slice and the action's runtime effect chain
/// (`CastFacts::primary_effects` / the activated ability).
pub(crate) fn is_draw_source_parts<'a>(
abilities: impl IntoIterator<Item = &'a AbilityDefinition>,
) -> bool {
abilities.into_iter().any(|ability| {
collect_scoped_effects(ability, AbilityScope::Potential)
.iter()
.any(|effect| matches!(effect, Effect::Draw { target, .. } if draws_controller(target)))
})
}

/// CR 121.1: the triggers carry a "whenever you draw a card" engine — a
/// repeatable payoff. Parts-based so it classifies both a deck-time
/// `CardFace.triggers` slice and a live `GameObject.trigger_definitions` iterator
/// (the runtime trigger authority).
pub(crate) fn is_draw_payoff_parts<'a>(
triggers: impl IntoIterator<Item = &'a TriggerDefinition>,
) -> bool {
triggers.into_iter().any(is_draw_payoff_trigger)
}

/// Single-trigger structural classifier (mode + scope), exposed so the policy
/// can pair it with live per-turn firing eligibility per trigger entry.
pub(crate) fn is_draw_payoff_trigger(t: &TriggerDefinition) -> bool {
// 1. Mode fires on a draw event (CR 121.1).
if !matches!(t.mode, TriggerMode::Drawn) {
return false;
}
// 2. Your-draw only: "whenever an opponent draws" is a punisher for a
// different deck, not a reason for YOU to draw more.
if !matches!(&t.valid_target, None | Some(TargetFilter::Controller)) {
return false;
}
// 3. Exclude a self-referential "when this is drawn" trigger — that fires
// from hand on the card itself, not a battlefield engine.
!matches!(&t.valid_card, Some(TargetFilter::SelfRef))
}

/// True when the draw effect draws the controller cards (you), not an opponent.
fn draws_controller(target: &TargetFilter) -> bool {
matches!(target, TargetFilter::Controller)
}

/// Calibration: a dedicated draw engine deck (e.g. Izzet "draw-two": ~20 card-
/// draw sources + ~5 engines like The Locust God / Niv-Mizzet over ~36 nonland)
/// → commitment ≈ 0.85. Anti-calibration: a blue midrange deck that runs card
/// draw but no engine → below `DRAW_MATTERS_FLOOR`; an engine with no extra draw,
/// or draw with no engine → 0.0.
///
/// Geometric mean over (source, payoff): BOTH pillars are mandatory. Card draw
/// with no engine is just card advantage (`card_advantage` governs it); an engine
/// with no way to draw extra only triggers on the natural draw for turn.
fn compute_commitment(source_count: u32, payoff_count: u32, total_nonland: u32) -> f32 {
// ~20 draw sources per 60 nonland is a fully-committed draw shell (card draw
// is common, so this pillar saturates later than a keyword pillar).
let source_density = (commitment::density_per_60(source_count, total_nonland) / 20.0).min(1.0);
// ~5 engine payoffs per 60 nonland is a fully-committed payoff base.
let payoff_density = (commitment::density_per_60(payoff_count, total_nonland) / 5.0).min(1.0);
commitment::geometric_mean(&[source_density, payoff_density])
}
5 changes: 5 additions & 0 deletions crates/phase-ai/src/features/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@ pub mod blink;
pub mod commitment;
pub mod control;
pub mod devotion;
pub mod draw_matters;
pub mod enchantments;
pub mod energy;
pub mod equipment;
Expand All @@ -37,6 +38,7 @@ pub use artifacts::ArtifactsFeature;
pub use blink::BlinkFeature;
pub use control::ControlFeature;
pub use devotion::DevotionFeature;
pub use draw_matters::DrawMattersFeature;
pub use enchantments::EnchantmentsFeature;
pub use energy::EnergyFeature;
pub use equipment::EquipmentFeature;
Expand Down Expand Up @@ -89,6 +91,8 @@ pub struct DeckFeatures {
pub poison: PoisonFeature,
/// CR 207.2c + CR 205.2a: delirium / descend graveyard type-diversity.
pub graveyard_types: GraveyardTypesFeature,
/// CR 121.1: "whenever you draw" payoff density (draw sources + engines).
pub draw_matters: DrawMattersFeature,
/// Declaration-derived: the deck's declared bracket tier. Unlike the
/// other fields here, this is not structurally detected from card text —
/// it is a per-deck declaration set at deck-analysis time from deck
Expand Down Expand Up @@ -136,6 +140,7 @@ impl DeckFeatures {
energy: energy::detect(deck),
poison: poison::detect(deck),
graveyard_types: graveyard_types::detect(deck),
draw_matters: draw_matters::detect(deck),
bracket_tier: tier,
}
}
Expand Down
Loading
Loading