Skip to content
Merged
Changes from all commits
Commits
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
176 changes: 171 additions & 5 deletions crates/engine/src/game/stack.rs
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,9 @@ use crate::types::identifiers::ObjectId;
use crate::types::player::PlayerId;
use crate::types::zones::Zone;

use super::ability_utils::{flatten_targets_in_chain, validate_targets_in_chain};
use super::ability_utils::{
build_target_slots, flatten_targets_in_chain, validate_targets_in_chain,
};
use super::effects;
use super::targeting;
use super::zone_pipeline::{self, ZoneMoveRequest, ZoneMoveResult};
Expand Down Expand Up @@ -62,6 +64,34 @@ fn spell_in_zone(state: &GameState, id: ObjectId, zone: Zone) -> bool {
state.objects.get(&id).is_some_and(|obj| obj.zone == zone)
}

fn has_missing_required_stack_targets(state: &GameState, ability: &ResolvedAbility) -> bool {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

[MEDIUM] Missing CR annotation on rules-touching function has_missing_required_stack_targets.\n\nWhy it matters: Every rules-touching line of engine code must carry a comment of the form CR <number>: <description> per the repository style guide (Rule R6).\n\nSuggested fix: Add a doc comment with the relevant CR citations (CR 603.3d and CR 608.2b).

/// CR 603.3d + CR 608.2b: Checks if a triggered ability or resolving spell is missing required stack-time targets.
fn has_missing_required_stack_targets(state: &GameState, ability: &ResolvedAbility) -> bool {
References
  1. Every rules-touching line of engine code must carry a comment of the form CR : . (link)

if !flatten_targets_in_chain(ability).is_empty() {
return false;
}

match build_target_slots(state, ability) {
Ok(slots) => slots.iter().any(|slot| !slot.optional),
Err(_) => true,
}
}

fn top_pending_trigger_has_no_legal_required_targets(

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

[MEDIUM] Missing CR annotation on rules-touching function top_pending_trigger_has_no_legal_required_targets.\n\nWhy it matters: Every rules-touching line of engine code must carry a comment of the form CR <number>: <description> per the repository style guide (Rule R6).\n\nSuggested fix: Add a doc comment with the relevant CR citation (CR 603.3d).

Suggested change
fn top_pending_trigger_has_no_legal_required_targets(
/// CR 603.3d: Checks if the top pending trigger has no legal required targets.
fn top_pending_trigger_has_no_legal_required_targets(
References
  1. Every rules-touching line of engine code must carry a comment of the form CR : . (link)

state: &GameState,
pending_id: ObjectId,
) -> bool {
let Some(entry) = state.stack.back().filter(|entry| entry.id == pending_id) else {
return false;
};
let Some(ability) = entry.ability() else {
return false;
};
if !flatten_targets_in_chain(ability).is_empty() {
return false;
}

build_target_slots(state, ability).is_err()
}

/// CR 614.1a + CR 608.2n + CR 607.2b: The per-object linked source is also the
/// exile-instead marker for Rod of Absorption's resolving-spell rider.
fn stack_exile_linked_source(state: &GameState, object_id: ObjectId) -> Option<ObjectId> {
Expand Down Expand Up @@ -102,7 +132,15 @@ pub fn resolve_top(state: &mut GameState, events: &mut Vec<GameEvent>) {
// permitted.
if let Some(pending_id) = state.pending_trigger_entry {
if state.stack.back().map(|e| e.id) == Some(pending_id) {
return;
if !top_pending_trigger_has_no_legal_required_targets(state, pending_id) {
return;
}
// CR 603.3d: A stale construction cursor on a malformed trigger
// with no legal required targets cannot keep a triggered ability
// suspended forever.
state.pending_trigger_entry = None;
state.pending_trigger = None;
state.pending_trigger_event_batch.clear();
}
}

Expand Down Expand Up @@ -241,6 +279,24 @@ pub fn resolve_top(state: &mut GameState, events: &mut Vec<GameEvent>) {
}
}

if ability
.as_ref()
.is_some_and(|ability| has_missing_required_stack_targets(state, ability))
{
// CR 603.3d: If a triggered ability needs a stack-time target choice and
// no legal choice was made, remove it from the stack.
// CR 608.2b: A resolving spell or ability with no legal targets does not
// resolve.
events.push(GameEvent::StackResolved {
object_id: entry.id,
});
state.current_trigger_event = None;
state.current_trigger_events.clear();
state.current_trigger_match_count = None;
state.die_result_this_resolution = None;
return;
}

// Capture targets for Aura attachment after resolution
let spell_targets = ability
.as_ref()
Expand Down Expand Up @@ -2175,13 +2231,14 @@ pub(crate) fn create_warp_delayed_trigger(
mod tests {
use super::*;
use crate::game::game_object::BackFaceData;
use crate::game::triggers::check_delayed_triggers;
use crate::game::triggers::{check_delayed_triggers, PendingTrigger};
use crate::game::zones::{self, create_object, move_to_zone};
use crate::types::ability::{
CastingPermission, CostPaidObjectSnapshot, Effect, QuantityExpr, ResolvedAbility,
TargetFilter, TargetRef, TypedFilter,
CastingPermission, ControllerRef, CostPaidObjectSnapshot, Effect, QuantityExpr,
ResolvedAbility, TargetFilter, TargetRef, TypeFilter, TypedFilter,
};
use crate::types::card_type::CoreType;
use crate::types::game_state::{MayTriggerOrigin, WaitingFor};
use crate::types::identifiers::CardId;
use crate::types::keywords::Keyword;
use crate::types::mana::ManaCost;
Expand Down Expand Up @@ -2266,6 +2323,115 @@ mod tests {
aura_id
}

#[test]
fn targetless_damage_trigger_with_stale_pending_entry_is_removed() {
let mut state = setup();
let predator = create_object(
&mut state,
CardId(100),
PlayerId(0),
"Trygon Predator".to_string(),
Zone::Battlefield,
);
state
.objects
.get_mut(&predator)
.unwrap()
.card_types
.core_types
.push(CoreType::Creature);

let target = TargetFilter::Or {
filters: vec![
TargetFilter::Typed(
TypedFilter::default()
.with_type(TypeFilter::Artifact)
.controller(ControllerRef::TargetPlayer),
),
TargetFilter::Typed(
TypedFilter::default()
.with_type(TypeFilter::Enchantment)
.controller(ControllerRef::TargetPlayer),
),
],
};
let mut ability = ResolvedAbility::new(
Effect::Destroy {
target,
cant_regenerate: false,
},
vec![],
predator,
PlayerId(0),
);
ability.optional = true;
ability
.set_source_incarnation_recursive(state.objects.get(&predator).map(|o| o.incarnation));

let trigger_event = GameEvent::DamageDealt {
source_id: predator,
target: TargetRef::Player(PlayerId(1)),
amount: 2,
is_combat: true,
excess: 0,
};
let description =
"Whenever this creature deals combat damage to a player, you may destroy target artifact or enchantment that player controls."
.to_string();
let entry_id = ObjectId(state.next_object_id);
state.next_object_id += 1;
state.stack.push_back(StackEntry {
id: entry_id,
source_id: predator,
controller: PlayerId(0),
kind: StackEntryKind::TriggeredAbility {
source_id: predator,
ability: Box::new(ability),
condition: None,
trigger_event: Some(trigger_event.clone()),
description: Some(description.clone()),
source_name: "Trygon Predator".to_string(),
subject_match_count: None,
die_result: None,
},
});
state.pending_trigger_entry = Some(entry_id);
state.pending_trigger_event_batch = vec![trigger_event.clone()];
state.pending_trigger = Some(PendingTrigger {
source_id: predator,
controller: PlayerId(0),
condition: None,
ability: state.stack.back().unwrap().ability().unwrap().clone(),
timestamp: state.turn_number,
target_constraints: Vec::new(),
distribute: None,
trigger_event: Some(trigger_event),
modal: None,
mode_abilities: Vec::new(),
description: Some(description),
may_trigger_origin: Some(MayTriggerOrigin::Printed { trigger_index: 0 }),
subject_match_count: None,
die_result: None,
});
state.waiting_for = WaitingFor::Priority {
player: PlayerId(0),
};

let mut events = Vec::new();
resolve_top(&mut state, &mut events);

assert!(state.stack.is_empty());
assert!(state.pending_trigger_entry.is_none());
assert!(state.pending_trigger.is_none());
assert!(!matches!(
state.waiting_for,
WaitingFor::OptionalEffectChoice { .. }
));
assert!(events
.iter()
.any(|event| matches!(event, GameEvent::StackResolved { object_id } if *object_id == entry_id)));
}

#[test]
fn permanent_spell_resolution_links_exiled_cost_paid_object() {
let mut state = setup();
Expand Down
Loading