Skip to content
Merged
Show file tree
Hide file tree
Changes from all 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
107 changes: 107 additions & 0 deletions crates/engine/src/game/ability_utils.rs
Original file line number Diff line number Diff line change
Expand Up @@ -1312,6 +1312,113 @@ 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 from the stack
/// rather than producing its effect, so a payoff-eligibility preflight must not
/// credit it.
///
/// Answers only from *confirmed* legality — never from an "unknown" shape. The
/// cheap single-slot check is tried first as a guard; every shape it cannot
/// decide (multi-slot, relative-controller, distribution, `PairWith`, …) falls
/// through to [`has_legal_target_assignment_for_ability`], the same full
/// legal-assignment authority the interactive target walk uses, so a
/// two-mandatory-target trigger with no legal assignment is correctly rejected.
/// A slot-building error leaves legality unproven and is likewise not credited.
pub fn execute_targets_satisfiable(
state: &GameState,
source: &crate::game::game_object::GameObject,
execute: &AbilityDefinition,
) -> bool {
// CR 603.3c: a MODAL execute carries a placeholder root and its targets in
// `mode_abilities` (which the root slot walk does not descend). Mirror the
// live trigger dispatch: filter each mode by its own target legality, then
// require a legal modal choice — a required "choose one/two …" whose modes
// are all target-unavailable is dropped (`DroppedNoLegalMode`), so it is not
// a live payoff.
if let Some(modal) = &execute.modal {
let mut unavailable_modes = Vec::new();
filter_modes_by_target_legality(
state,
source.id,
source.controller,
&execute.mode_abilities,
modal,
&mut unavailable_modes,
);
if unavailable_modes.len() >= modal.mode_count {
return false; // CR 603.3c: no legal mode
}
// CR 603.3d: the required choose-count must be satisfiable with legal
// target assignments across the surviving modes.
return modal_choice_with_target_assignment_limit(
state,
source.id,
source.controller,
modal,
&execute.mode_abilities,
&unavailable_modes,
)
.is_some();
}
// CR 603.3d: build the ability the same way the live trigger pipeline does
// (`build_resolved_from_def`) so a sub-ability chain's own target slots are
// preflighted too — not just the root effect's.
let resolved = build_resolved_from_def(execute, source.id, source.controller);
if target_slot_specs(state, &resolved).is_empty() {
return true; // the effect requires no target
}
// CR 115.1 + CR 601.2c: preflight against the SAME cross-target constraints
// the live trigger carries (`PendingTrigger::target_constraints`), so a
// constrained multi-target execute is not judged against a broader target
// space than it will actually receive.
let constraints = execute.target_constraints.as_slice();
// Cheap guard: `Some(false)` = a mandatory target with no legal choice;
// `Some(true)` = legal or optional; `None` = a shape this cheap check
// cannot decide (incl. any constrained set), which the full authority
// below resolves exactly.
if let Some(decided) =
simple_legal_target_assignment_exists_for_ability(state, &resolved, constraints)
{
return decided;
}
build_target_slots(state, &resolved).is_ok_and(|slots| {
has_legal_target_assignment_for_ability(state, &resolved, &slots, constraints)
})
}

/// True when `def`'s entire ability tree is engine-supported — no
/// `Effect::Unimplemented` gap node at the root or in any nested sub-ability,
/// else-branch, or mode. The live trigger builder converts a `None` execute /
/// unsupported effect into an `Effect::Unimplemented` (`TriggerNoExecute`) no-op
/// that produces no payoff, so payoff eligibility (both the live fireability
/// preflight and the deck-feature classifier) must not credit such a trigger.
/// The single shared support authority both consult.
pub fn ability_definition_supported(def: &AbilityDefinition) -> bool {
// CR 700.2: a modal ability carries a placeholder `Effect::Unimplemented`
// (`modal_placeholder`) root — its real effects live in `mode_abilities`, so
// the placeholder is NOT a gap. Only an `Unimplemented` root on a
// non-modal ability is a true unsupported node.
if matches!(*def.effect, Effect::Unimplemented { .. }) && def.modal.is_none() {
return false;
}
if def
.sub_ability
.as_deref()
.is_some_and(|sub| !ability_definition_supported(sub))
{
return false;
}
if def
.else_ability
.as_deref()
.is_some_and(|els| !ability_definition_supported(els))
{
return false;
}
def.mode_abilities.iter().all(ability_definition_supported)
}

/// 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
35 changes: 35 additions & 0 deletions crates/engine/src/game/effects/draw.rs
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,41 @@ use crate::types::statics::StaticMode;
#[cfg(test)]
use crate::types::zones::Zone;

/// CR 121.1 + CR 704.5b + CR 614.6: would drawing a card actually put a card into
/// `player_id`'s hand right now, emitting a `GameEvent::CardDrawn`? False when:
/// - a `CantDraw` static applies or a `PerTurnDrawLimit` is exhausted (no draw
/// permitted); or
/// - the library is empty — an empty-library draw only records an attempted
/// draw (CR 704.5b) and delivers no card; or
/// - the replacement pipeline removes the draw before it happens (CR 614.6) —
/// prevented, substituted with a non-Draw chain, or rescaled to zero.
///
/// In each case the draw fires no "whenever you draw" trigger. Every leg delegates
/// to the authority that owns it rather than re-deriving it: `allowed_draw_count`
/// for draw restrictions, `select_cards_to_draw` for library delivery, and
/// `replacement::proposed_draw_survives_replacement` — which shares its
/// applicability and substitution classifiers with the live pipeline — for the
/// replacement leg. The individual draw is modeled as the same
/// `ProposedEvent::Draw` shape `draw_through_replacement_with_applied` proposes,
/// so the preflight and the resolver ask the identical question.
///
/// The single engine authority an AI draw-payoff preflight consults so it never
/// credits a no-op draw.
pub fn can_draw_at_least_one(state: &GameState, player_id: crate::types::player::PlayerId) -> bool {
let allowed = allowed_draw_count(state, player_id, 1);
if select_cards_to_draw(state, player_id, allowed as usize).is_empty() {
return false;
}
// CR 121.2: the individual draw the payoff would ride on — the same event
// shape `draw_through_replacement_with_applied` proposes for one card.
let proposed = ProposedEvent::Draw {
player_id,
count: 1,
applied: HashSet::new(),
};
replacement::proposed_draw_survives_replacement(state, &proposed)
}

pub(crate) fn allowed_draw_count(
state: &GameState,
player_id: crate::types::player::PlayerId,
Expand Down
164 changes: 134 additions & 30 deletions crates/engine/src/game/replacement.rs
Original file line number Diff line number Diff line change
Expand Up @@ -2840,6 +2840,53 @@ fn draw_replacement_count(
}
}

/// CR 614.6 + CR 614.11: does the branch being applied substitute the proposed
/// draw with a NON-draw chain, so the original draw never happens and no
/// `GameEvent::CardDrawn` is emitted?
///
/// `branch_ability` is the AST of the branch the pipeline is applying (`execute`
/// on mandatory/accept, `decline` on decline), so an optional replacement's
/// decline is never classified against the accept-side AST.
///
/// A one-shot draw replacement (Words of Worship / Wilding) carries its
/// substitute in `runtime_execute` while `execute` is `None`, so `branch_ability`
/// is `None` for those; a non-Draw, non-event-modifier substitute there (GainLife
/// / Token) must still count, or the card would be drawn AND the substitute would
/// run. Damage / Jace's WinTheGame / Abundance shapes carry theirs in `execute`,
/// so `branch_ability` is `Some` and the `runtime_execute` leg never engages.
///
/// The `draw_replacement_count` guard preserves the count-modifier path
/// (Alhammarret's Archive: count -> 2*count, CR 614.11a) — a rescaled draw is a
/// surviving draw, not a substitution.
///
/// Single authority: the live pipeline (`apply_single_replacement`) calls this to
/// decide whether to pre-zero the proposed count, and the read-only preflight
/// (`proposed_draw_survives_replacement`) calls it to decide whether a draw can
/// still deliver a card. Neither may re-derive this classification independently
/// — a preflight that mirrors the pipeline instead of sharing it will drift.
fn draw_is_substituted_away(
state: &GameState,
rid: ReplacementId,
repl_def: &ReplacementDefinition,
branch_ability: Option<&AbilityDefinition>,
proposed: &ProposedEvent,
) -> bool {
if !matches!(proposed, ProposedEvent::Draw { .. }) {
return false;
}
match branch_ability {
Some(def) => {
!matches!(*def.effect, Effect::Draw { .. })
&& !EventModifiers::has_only_event_modifier(Some(def))
&& draw_replacement_count(state, rid, proposed).is_none()
}
None => repl_def.runtime_execute.as_deref().is_some_and(|runtime| {
!matches!(runtime.effect, Effect::Draw { .. })
&& !EventModifiers::is_event_modifier_effect(&runtime.effect)
}),
}
}

// --- 4b. Scry ---

// CR 614.6: A replacement effect applies only once to a given event. The
Expand Down Expand Up @@ -7909,34 +7956,14 @@ fn apply_single_replacement(
// draw with a non-Draw chain (Jace's WinTheGame, Abundance's
// reveal-until), zero the count here so `draw_applier` and
// `apply_draw_after_replacement` see a no-op draw — the original draw
// never happens (CR 614.6). Branch-aware via the `ability` binding
// above, so an optional replacement's decline never pre-zeros against
// the accept-side AST. The `draw_replacement_count` guard preserves
// the count-modifier path (Alhammarret's Archive: count -> 2*count).
if matches!(proposed, ProposedEvent::Draw { .. }) {
// CR 614.6 + CR 614.11: A one-shot draw replacement
// (Words of Worship/Wilding) carries its substitute in
// `runtime_execute` (`execute` is `None`), so the `ability`
// binding above is `None`. Inspect that slot too — a non-Draw,
// non-event-modifier substitute (GainLife / Token) must still
// pre-zero the draw, or the card is drawn AND the substitute
// runs (double). Damage/Jace/Abundance use `execute`, so
// `ability` is `Some` and this `runtime` branch never engages.
let is_non_draw_substitute = match ability {
Some(def) => {
!matches!(*def.effect, Effect::Draw { .. })
&& !EventModifiers::has_only_event_modifier(Some(def))
&& draw_replacement_count(state, rid, &proposed).is_none()
}
None => repl_def.runtime_execute.as_deref().is_some_and(|runtime| {
!matches!(runtime.effect, Effect::Draw { .. })
&& !EventModifiers::is_event_modifier_effect(&runtime.effect)
}),
};
if is_non_draw_substitute {
if let ProposedEvent::Draw { count, .. } = &mut proposed {
*count = 0;
}
// never happens (CR 614.6). The classification itself lives in
// `draw_is_substituted_away`, which is SHARED with the read-only
// preflight `proposed_draw_survives_replacement`: an AI preflight
// therefore cannot disagree with this pipeline about whether a draw
// survives, because both ask the same function.
if draw_is_substituted_away(state, rid, repl_def, ability, &proposed) {
if let ProposedEvent::Draw { count, .. } = &mut proposed {
*count = 0;
}
}
// CR 614.6 + CR 111.1: A CreateToken replacement whose execute is
Expand Down Expand Up @@ -8697,16 +8724,93 @@ fn is_counter_placement_event(event: &ProposedEvent) -> bool {
)
}

fn counter_placement_prevention_applies(state: &GameState, candidates: &[ReplacementId]) -> bool {
/// CR 614.6: does any already-applicable candidate obligatorily replace the
/// event away? A `QuantityModification::Prevent` definition kills the event only
/// when it is MANDATORY — an optional one is offered to a player as an
/// accept/decline choice (`replacement_mode_is_optional`), so it cannot be
/// assumed to apply. `events` scopes the check to the
/// replacement events that actually govern the proposed event, so a differently
/// evented `Prevent` sibling on the same source can never suppress it.
///
/// `candidates` must come from the live applicability authority
/// (`find_applicable_replacements`), which has already enforced the handler
/// matcher, source/player scope, condition, and optional-decline gates. Virtual
/// rules-source candidates carry no definition and are never preventive here.
fn mandatory_prevention_applies(
state: &GameState,
candidates: &[ReplacementId],
events: &[ReplacementEvent],
) -> bool {
candidates.iter().any(|rid| {
replacement_definition_for_id(state, *rid).is_some_and(|def| {
def.event == ReplacementEvent::AddCounter
events.contains(&def.event)
&& def.quantity_modification == Some(QuantityModification::Prevent)
&& !replacement_mode_is_optional(&def.mode)
})
})
}

fn counter_placement_prevention_applies(state: &GameState, candidates: &[ReplacementId]) -> bool {
mandatory_prevention_applies(state, candidates, &[ReplacementEvent::AddCounter])
}

/// CR 121.1 + CR 614.6 + CR 614.11: pure preflight — does a proposed draw survive
/// the replacement effects currently applicable to it as a *real* draw, one that
/// puts a card into its player's hand and emits `GameEvent::CardDrawn`?
///
/// Three legs of the live pipeline remove a proposed draw, and each is answered
/// here by the same authority that owns it in the pipeline, never by a
/// re-derived structural scan:
/// - a mandatory `QuantityModification::Prevent` — `draw_applier` returns
/// `ApplyResult::Prevented`, so the replaced event never happens (CR 614.6,
/// Living Conundrum). Shared via `mandatory_prevention_applies`.
/// - a mandatory non-Draw substitute carried in `execute` or `runtime_execute` —
/// `apply_single_replacement` zeroes the proposed count so the original draw is
/// a no-op and the substitute runs instead (CR 614.11: Words of Worship,
/// Abundance's reveal-until, Jace's WinTheGame). Shared via
/// `draw_is_substituted_away`.
/// - a mandatory count modification that resolves to zero — `draw_applier`
/// returns `Modified` with `count: 0`, and `apply_draw_after_replacement`
/// emits `CardDrawn` only inside its per-delivered-card loop, so a zero-count
/// draw emits none (CR 614.11a). Shared via `draw_replacement_count`.
///
/// An OPTIONAL replacement (CR 614.6: "you may") is never assumed to apply — the
/// player is offered an accept/decline choice, so the draw is still deliverable
/// and the payoff still stands. A count modification that resolves positive
/// (Alhammarret's Archive: count -> 2*count) is likewise a surviving draw.
///
/// `find_applicable_replacements` is the live applicability authority, so an
/// unrelated or opponent-scoped source (CR 614.1a), a false conditional
/// (CR 614.1d), and a recognized-but-stub replacement event are already excluded
/// before anything is classified here.
///
/// Read-only: it consults applicability and definition shape without running any
/// applier, so preflights (AI candidate scoring) can call it without mutating
/// state. Non-`Draw` events are outside its remit and always report surviving.
pub fn proposed_draw_survives_replacement(state: &GameState, event: &ProposedEvent) -> bool {
if !matches!(event, ProposedEvent::Draw { .. }) {
return true;
}
let registry = replacement_registry();
let candidates = find_applicable_replacements(state, event, registry);
let events = replacement_event_keys_for_event(event);
if mandatory_prevention_applies(state, &candidates, &events) {
return false;
}
!candidates.iter().any(|rid| {
replacement_definition_for_id(state, *rid).is_some_and(|def| {
// CR 614.6: only a MANDATORY branch is certain to apply, and the live
// pipeline resolves it to `ReplacementBranch::Execute` — so `execute`
// is the branch AST to classify, exactly as `apply_single_replacement`
// binds it.
events.contains(&def.event)
&& !replacement_mode_is_optional(&def.mode)
&& (draw_is_substituted_away(state, *rid, def, def.execute.as_deref(), event)
|| draw_replacement_count(state, *rid, event) == Some(0))
})
})
}

fn replacement_definition_for_id(
state: &GameState,
rid: ReplacementId,
Expand Down
Loading
Loading