Skip to content
Merged
Show file tree
Hide file tree
Changes from 4 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
26 changes: 26 additions & 0 deletions crates/engine/src/game/ability_utils.rs
Original file line number Diff line number Diff line change
Expand Up @@ -1311,6 +1311,32 @@ pub fn simple_legal_target_assignment_exists_for_ability(
))
}

/// CR 603.3d: could `execute` — a trigger's ability, resolving from `source` —
/// either need no target at all, or find a legal target right now? A
/// mandatory-target trigger with no legal choice is removed rather than
/// producing an effect, so a payoff-eligibility preflight must not credit it.
/// Complex/undecidable target shapes return `true` (don't under-credit — the
/// caller is asking "could this possibly produce value").
pub fn execute_targets_satisfiable(
state: &GameState,
source: &crate::game::game_object::GameObject,
execute: &AbilityDefinition,
) -> bool {
let resolved = ResolvedAbility::new(
execute.effect.as_ref().clone(),
Vec::new(),
source.id,
source.controller,
);
let specs = target_slot_specs(state, &resolved);
if specs.is_empty() {
return true; // the effect requires no target
}
// `Some(false)` = a mandatory target with no legal choice; `Some(true)` =
// legal or optional; `None` = a shape this cheap check can't decide.
simple_legal_target_assignment_exists_for_ability(state, &resolved, &[]).unwrap_or(true)
}

/// CR 115.1 + CR 701.9b: Resolve a `Random`-mode ability's target slots by
/// uniformly choosing from each slot's legal-target set using the engine's
/// seeded RNG (`state.rng`). The game (not the controller) makes the selection;
Expand Down
63 changes: 54 additions & 9 deletions crates/engine/src/game/triggers.rs
Original file line number Diff line number Diff line change
Expand Up @@ -1450,7 +1450,7 @@ fn collect_matching_triggers_inner(
definition_ref.as_ref(),
Some(&source_context),
controller,
event,
Some(event),
) {
continue;
}
Expand Down Expand Up @@ -2629,7 +2629,7 @@ fn collect_latched_batched_zone_triggers(
Some(&latched.definition_ref),
Some(source_context),
source_context.lki.controller,
event,
Some(event),
)
|| !latched
.definition
Expand Down Expand Up @@ -8006,13 +8006,17 @@ fn delayed_zone_change_filter_matches(
///
/// `event` is the triggering event — needed by `NthSpellThisTurn` to identify
/// the caster and count their per-player spell total (not the global count).
/// `event` is `Some` for a real trigger evaluation and `None` for a hypothetical
/// preflight (e.g. an AI payoff-eligibility query). In the hypothetical case the
/// event-dependent constraints — which can only be judged against a concrete
/// triggering event — conservatively report NOT satisfied rather than guess.
fn check_trigger_constraint_with_ref(
state: &GameState,
trig_def: &TriggerDefinition,
definition_ref: Option<&TriggerDefinitionRef>,
source_context: Option<&TriggerSourceContext>,
controller: PlayerId,
event: &GameEvent,
event: Option<&GameEvent>,
) -> bool {
use crate::types::ability::TriggerConstraint;

Expand All @@ -8038,7 +8042,7 @@ fn check_trigger_constraint_with_ref(
// CR 603.2: The trigger event only matches the first life-loss event
// during that opponent's own turn.
let opponent_id = match event {
GameEvent::LifeChanged { player_id, .. } => *player_id,
Some(GameEvent::LifeChanged { player_id, .. }) => *player_id,
_ => return false,
};
if opponent_id == controller || state.active_player != opponent_id {
Expand All @@ -8064,10 +8068,10 @@ fn check_trigger_constraint_with_ref(
controller: ctrl_ref,
} => {
let event_source = match event {
GameEvent::Discarded {
Some(GameEvent::Discarded {
source_id: Some(source_id),
..
} => *source_id,
}) => *source_id,
_ => return false,
};
let Some(event_source_controller) = state
Expand All @@ -8091,7 +8095,7 @@ fn check_trigger_constraint_with_ref(
// When `filter` contains `TypeFilter::Non(Creature)`, use the noncreature counter.
TriggerConstraint::NthSpellThisTurn { n, filter } => {
let caster = match event {
GameEvent::SpellCast { controller: c, .. } => *c,
Some(GameEvent::SpellCast { controller: c, .. }) => *c,
_ => return false,
};
let spells = state.spells_cast_this_turn_by_player.get(&caster);
Expand Down Expand Up @@ -8127,7 +8131,7 @@ fn check_trigger_constraint_with_ref(
// rather than the final per-turn count after a multi-card draw batch.
TriggerConstraint::NthDrawThisTurn { n } => {
let nth_in_turn = match event {
GameEvent::CardDrawn { nth_in_turn, .. } => *nth_in_turn,
Some(GameEvent::CardDrawn { nth_in_turn, .. }) => *nth_in_turn,
_ => return false,
};
nth_in_turn == *n
Expand All @@ -8148,6 +8152,47 @@ fn check_trigger_constraint_with_ref(
}
}

/// CR 603.2-603.4 + CR 603.3d: could `entry`'s trigger on `source` still fire
/// AND resolve to an effect if its triggering event happened right now? The
/// single authority an AI policy uses to ask "is this on-battlefield payoff
/// live?" — reusing the same constraint check the live trigger pipeline runs.
///
/// Conservative by construction: an intervening-if `condition` (CR 603.4, not
/// evaluated in this preflight) and any event-dependent constraint whose
/// triggering event is unknown at this decision point are treated as NOT
/// established, so a payoff is never credited value it cannot actually produce.
pub fn hypothetical_trigger_fireable(
state: &GameState,
source: &GameObject,
entry: &TriggerEntry,
) -> bool {
let def = &entry.definition;
// CR 603.4 intervening-if: not preflighted here — treat a conditional
// trigger as not-live rather than assume it fires.
if def.condition.is_some() {
return false;
}
let definition_ref = source.trigger_definition_ref(entry);
// CR 603.2-603.4: the trigger's own constraint, in hypothetical (no-event)
// mode — the shared authority the live pipeline also uses.
if !check_trigger_constraint_with_ref(
state,
def,
Some(&definition_ref),
None,
source.controller,
None,
) {
return false;
}
// CR 603.3d: a mandatory-target execute with no legal target is removed from
// the stack rather than producing its effect.
match def.execute.as_deref() {
Some(execute) => super::ability_utils::execute_targets_satisfiable(state, source, execute),
None => true,
}
}

/// Evaluates the cast-payment facts carried either by the event subject or by
/// the exact trigger source. Keeping this value-level avoids a source-id
/// fallback that could bind a later incarnation during an intervening-if
Expand Down Expand Up @@ -9844,7 +9889,7 @@ fn check_trigger_constraint(
.map(|source| trigger_source_context_for_latch(state, source))
.as_ref(),
controller,
event,
Some(event),
)
}

Expand Down
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
190 changes: 190 additions & 0 deletions crates/phase-ai/src/features/draw_matters.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,190 @@
//! 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 engine::types::zones::Zone;

use crate::ability_chain::collect_scoped_effects;
pub(crate) use crate::ability_chain::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);
}

// Deck-time: a modal card whose draw lives in a branch still counts as a
// draw enabler for the archetype, so scan the full potential tree — plus
// ETB "cantrip" triggers (Elvish Visionary), which the live policy also
// credits via `CastFacts::immediate_etb_triggers`.
if is_draw_source_parts(&face.abilities, AbilityScope::Potential)
|| is_etb_draw_source(&face.triggers)
{
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. 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). The caller chooses the
/// `scope`: `Potential` for deck-time (a modal draw mode still marks the card),
/// `Unconditional` for a live candidate before its mode is selected (CR 700.2 —
/// a modal "choose one — draw / …" must NOT be credited a draw until the draw
/// mode is actually chosen).
pub(crate) fn is_draw_source_parts<'a>(
abilities: impl IntoIterator<Item = &'a AbilityDefinition>,
scope: AbilityScope,
) -> bool {
abilities.into_iter().any(|ability| {
collect_scoped_effects(ability, scope)
.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)
}

/// CR 603.6a: the face carries a self-ETB "when this enters, draw a card"
/// trigger (Elvish Visionary) — the live policy credits these via
/// `CastFacts::immediate_etb_triggers`, so deck-time detection must count them
/// as draw sources too, or an ETB-cantrip deck is undercounted.
fn is_etb_draw_source(triggers: &[TriggerDefinition]) -> bool {
triggers.iter().any(|t| {
t.mode == TriggerMode::ChangesZone
&& t.destination == Some(Zone::Battlefield)
&& matches!(t.valid_card, Some(TargetFilter::SelfRef))
&& t.execute.as_deref().is_some_and(|execute| {
collect_scoped_effects(execute, AbilityScope::Potential)
.iter()
.any(|e| matches!(e, Effect::Draw { target, .. } if draws_controller(target)))
})
})
}

/// 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])
}
Loading
Loading