Skip to content
Open
Show file tree
Hide file tree
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
7 changes: 6 additions & 1 deletion crates/engine/src/game/coverage.rs
Original file line number Diff line number Diff line change
Expand Up @@ -44,7 +44,12 @@ use std::collections::{BTreeMap, HashMap, HashSet};
fn is_data_carrying_static(mode: &StaticMode) -> bool {
matches!(
mode,
StaticMode::ReduceAbilityCost { .. }
// CR 514.2: nullary marker static — runtime enforcement is the cleanup
// turn-based action in turns.rs::execute_cleanup, which skips removing
// marked damage from permanents matching an active such static's
// `affected` filter. Not registry-keyed (mirrors the marker cluster).
StaticMode::DamageNotRemovedDuringCleanup
| StaticMode::ReduceAbilityCost { .. }
| StaticMode::ModifyActivationLimit { .. }
| StaticMode::AdditionalLandDrop { .. }
| StaticMode::ModifyCost { .. }
Expand Down
161 changes: 161 additions & 0 deletions crates/engine/src/game/turns.rs
Original file line number Diff line number Diff line change
Expand Up @@ -1359,11 +1359,46 @@ pub fn execute_cleanup(state: &mut GameState, events: &mut Vec<GameEvent>) -> Op
}
}

// CR 514.2: An active "Damage isn't removed from [filter] during cleanup
// steps" static (Ancient Adamantoise, Patient Zero, Uthgardt Fury, …)
// suppresses removal for the permanents it matches; their marked damage
// persists across turns. Gather that protected set first.
let damage_persists: std::collections::HashSet<ObjectId> = {
use crate::game::filter::{matches_target_filter_including_phased_out, FilterContext};
use crate::types::ability::TargetFilter;

let sources: Vec<(ObjectId, PlayerId, TargetFilter)> =
super::functioning_abilities::battlefield_active_statics(state)
.filter(|(_, def)| matches!(def.mode, StaticMode::DamageNotRemovedDuringCleanup))
.filter_map(|(obj, def)| {
def.affected
.as_ref()
.map(|f| (obj.id, obj.controller, f.clone()))
})
.collect();

// CR 514.2 + CR 702.26b: removing marked damage is a turn-based cleanup
// action over the whole battlefield, including phased-out permanents — it
// is not targeting — so the protected membership is evaluated with the
// phased-out-aware matcher to mirror the unconditional removal below.
let mut protected = std::collections::HashSet::new();
for (source_id, source_controller, filter) in sources {
let ctx = FilterContext::from_source_with_controller(source_id, source_controller);
for id in state.battlefield.iter().copied() {
if matches_target_filter_including_phased_out(state, id, &filter, &ctx) {
protected.insert(id);
}
}
}
protected
};

// CR 514.2: Damage on creatures is removed at cleanup.
let to_clear: Vec<_> = state
.battlefield
.iter()
.copied()
.filter(|id| !damage_persists.contains(id))
.filter(|id| {
state
.objects
Expand Down Expand Up @@ -4589,6 +4624,132 @@ mod tests {
assert_eq!(state.objects[&id].damage_marked, 0);
}

#[test]
fn execute_cleanup_preserves_damage_under_damage_not_removed_static() {
use crate::types::card_type::CoreType;

let mut state = setup();

// Ancient-Adamantoise-style permanent: its own damage isn't removed at
// cleanup. CR 514.2 — the static suppresses the turn-based removal.
let protected = create_object(
&mut state,
CardId(1),
PlayerId(0),
"Ancient Adamantoise".to_string(),
Zone::Battlefield,
);
{
let defs = crate::parser::oracle_static::parse_static_line_multi(
"Damage isn't removed from this creature during cleanup steps.",
);
assert!(
defs.iter()
.any(|d| d.mode == StaticMode::DamageNotRemovedDuringCleanup),
"static must parse to DamageNotRemovedDuringCleanup, got {:?}",
defs.iter().map(|d| &d.mode).collect::<Vec<_>>()
);
let obj = state.objects.get_mut(&protected).unwrap();
obj.card_types.core_types.push(CoreType::Creature);
obj.base_card_types = obj.card_types.clone();
obj.damage_marked = 4;
for def in defs.iter().cloned() {
obj.static_definitions.push(def);
}
Arc::make_mut(&mut obj.base_static_definitions).extend(defs);
}

// A normal creature: its damage IS removed at cleanup (control).
let normal = create_object(
&mut state,
CardId(2),
PlayerId(0),
"Grizzly Bears".to_string(),
Zone::Battlefield,
);
{
let obj = state.objects.get_mut(&normal).unwrap();
obj.card_types.core_types.push(CoreType::Creature);
obj.base_card_types = obj.card_types.clone();
obj.damage_marked = 3;
}

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

assert_eq!(
state.objects[&protected].damage_marked, 4,
"damage must persist under DamageNotRemovedDuringCleanup"
);
assert_eq!(
state.objects[&normal].damage_marked, 0,
"a normal creature's damage is still removed at cleanup"
);
}

#[test]
fn execute_cleanup_preserves_phased_out_creature_damage_under_static() {
use crate::game::game_object::{PhaseOutCause, PhaseStatus};
use crate::types::card_type::CoreType;

let mut state = setup();
state.active_player = PlayerId(0);

// Patient Zero: "Damage isn't removed from creatures your opponents
// control during cleanup steps." — the static source (controlled by P0).
let source = create_object(
&mut state,
CardId(1),
PlayerId(0),
"Patient Zero".to_string(),
Zone::Battlefield,
);
{
let defs = crate::parser::oracle_static::parse_static_line_multi(
"Damage isn't removed from creatures your opponents control during cleanup steps.",
);
assert!(defs
.iter()
.any(|d| d.mode == StaticMode::DamageNotRemovedDuringCleanup));
let obj = state.objects.get_mut(&source).unwrap();
obj.card_types.core_types.push(CoreType::Creature);
obj.base_card_types = obj.card_types.clone();
for def in defs.iter().cloned() {
obj.static_definitions.push(def);
}
Arc::make_mut(&mut obj.base_static_definitions).extend(defs);
}

// A phased-out opponent creature with marked damage. CR 514.2 + CR
// 702.26b: damage removal at cleanup is a turn-based action over the
// whole battlefield (including phased-out permanents), so the static must
// preserve this creature's damage even while it is phased out.
let phased = create_object(
&mut state,
CardId(2),
PlayerId(1),
"Grizzly Bears".to_string(),
Zone::Battlefield,
);
{
let obj = state.objects.get_mut(&phased).unwrap();
obj.card_types.core_types.push(CoreType::Creature);
obj.base_card_types = obj.card_types.clone();
obj.damage_marked = 3;
obj.phase_status = PhaseStatus::PhasedOut {
cause: PhaseOutCause::Directly,
};
}

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

assert_eq!(
state.objects[&phased].damage_marked, 3,
"a phased-out opponent creature's damage must persist under the static"
);
}

/// CR 117.1c + CR 503.2: After Untap (no priority), the engine must hand
/// the active player priority during Upkeep — even when no triggers fired.
/// Previously `auto_advance` skipped past empty Upkeep/Draw windows, which
Expand Down
48 changes: 48 additions & 0 deletions crates/engine/src/parser/oracle_static/dispatch.rs
Original file line number Diff line number Diff line change
Expand Up @@ -223,6 +223,50 @@ fn parse_reveal_hand_static(tp: &TextPair<'_>, text: &str) -> Option<StaticDefin
)
}

/// CR 514.2: "Damage isn't removed from [subject] during cleanup steps."
/// Builds a `StaticMode::DamageNotRemovedDuringCleanup` static whose `affected`
/// filter is the subject — `~`/`this creature`/`this permanent` map to SelfRef
/// (Ancient Adamantoise, Uthgardt Fury), and any other subject ("creatures",
/// "creatures your opponents control") is parsed as a typed filter (Patient
/// Zero, Case of the Market Melee). The cleanup turn-based action skips removing
/// damage from permanents matching an active such static.
pub(crate) fn parse_damage_not_removed_during_cleanup(
tp: &TextPair,
text: &str,
) -> Option<StaticDefinition> {
// Composed grammar (CR 514.2):
// "damage isn't removed from " <subject> " during cleanup steps" [.] EOF
// where <subject> is a self-reference or a type phrase. The cleanup-step
// suffix is anchored at end-of-sentence (nothing but an optional period may
// follow), so a sentence that merely mentions "cleanup" later is rejected
// and the subject must parse to completion.
let body = nom_tag_lower(tp.lower, tp.lower, "damage isn't removed from ")?;

let (affected, after_subject) = if let Some(rest) = nom_tag_lower(body, body, "~")
.or_else(|| nom_tag_lower(body, body, "this creature"))
.or_else(|| nom_tag_lower(body, body, "this permanent"))
{
(TargetFilter::SelfRef, rest)
} else {
let (filter, rest) = parse_type_phrase(body);
if matches!(&filter, TargetFilter::Any) {
return None;
}
(filter, rest)
};

let tail = nom_tag_lower(after_subject, after_subject, " during cleanup steps")?;
if !tail.trim_end_matches('.').trim().is_empty() {
return None;
}

Some(
StaticDefinition::new(StaticMode::DamageNotRemovedDuringCleanup)
.affected(affected)
.description(text.to_string()),
)
}

pub(crate) fn parse_static_line_inner(
text: &str,
inverted: InvertedAsLongAs,
Expand All @@ -234,6 +278,10 @@ pub(crate) fn parse_static_line_inner(
if let Some(def) = parse_arcane_adaptation_chosen_type_static(&tp, &text) {
return Some(def);
}
// CR 514.2: "Damage isn't removed from [subject] during cleanup steps."
if let Some(def) = parse_damage_not_removed_during_cleanup(&tp, &text) {
return Some(def);
}
// CR 101.2 + CR 109.5: "Each opponent who [did X] this turn can't [Y]" —
// per-affected-player conditional prohibition (Angelic Arbiter). Must run
// BEFORE the generic "can't attack" arm and the `parse_cant_cast_type_spells`
Expand Down
43 changes: 43 additions & 0 deletions crates/engine/src/parser/oracle_static/tests.rs
Original file line number Diff line number Diff line change
Expand Up @@ -18091,3 +18091,46 @@ fn granted_quoted_ability_with_internal_period_bypasses_split() {
"expected a GrantAbility from the quoted ability, got {mods:?}"
);
}

#[test]
fn damage_not_removed_during_cleanup_self_subject() {
// CR 514.2: "this creature" subject → SelfRef-affected DamageNotRemoved static.
let def = parse_static_line("Damage isn't removed from this creature during cleanup steps.")
.expect("self-subject cleanup-damage static");
assert_eq!(def.mode, StaticMode::DamageNotRemovedDuringCleanup);
assert_eq!(def.affected, Some(TargetFilter::SelfRef));
}

#[test]
fn damage_not_removed_during_cleanup_filtered_subject() {
// CR 514.2: a typed subject ("creatures your opponents control") is carried
// as the affected filter (Patient Zero), not collapsed to SelfRef.
let def = parse_static_line(
"Damage isn't removed from creatures your opponents control during cleanup steps.",
)
.expect("filtered cleanup-damage static");
assert_eq!(def.mode, StaticMode::DamageNotRemovedDuringCleanup);
assert!(
!matches!(def.affected, Some(TargetFilter::SelfRef) | None),
"affected must be the typed opponent-creatures filter, got {:?}",
def.affected
);
}

#[test]
fn damage_not_removed_during_cleanup_rejects_non_cleanup_during() {
// CR 514.2: the grammar anchors on the "during cleanup steps" suffix, so a
// "during <something-else>" sentence that merely mentions cleanup elsewhere
// must NOT be classified as a cleanup-damage static.
let def = parse_static_line(
"Damage isn't removed from creatures during combat this turn. Skip your cleanup step.",
);
assert!(
!matches!(
def.as_ref().map(|d| &d.mode),
Some(StaticMode::DamageNotRemovedDuringCleanup)
),
"a non-\"during cleanup steps\" sentence must not be a cleanup-damage static, got {:?}",
def.map(|d| d.mode)
);
}
11 changes: 11 additions & 0 deletions crates/engine/src/types/statics.rs
Original file line number Diff line number Diff line change
Expand Up @@ -611,6 +611,13 @@ pub enum CombatAloneRequirement {
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub enum StaticMode {
Continuous,
/// CR 514.2: Normally all damage marked on permanents is removed as a
/// turn-based action during the cleanup step. This static suppresses that
/// removal for the permanents matched by the definition's `affected` filter
/// ("Damage isn't removed from [filter] during cleanup steps" — Ancient
/// Adamantoise, Patient Zero, Uthgardt Fury, …), so their marked damage
/// persists across turns.
DamageNotRemovedDuringCleanup,
CantAttack,
CantBlock,
CantAttackOrBlock,
Expand Down Expand Up @@ -1693,6 +1700,7 @@ impl StaticMode {
| StaticMode::MaxUntapPerType { .. }
| StaticMode::EntersWithAdditionalCounters { .. }
| StaticMode::LinkedCollectionCounterPlayPermission
| StaticMode::DamageNotRemovedDuringCleanup
| StaticMode::Other(_) => None,
}
}
Expand All @@ -1702,6 +1710,9 @@ impl fmt::Display for StaticMode {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
match self {
StaticMode::Continuous => write!(f, "Continuous"),
StaticMode::DamageNotRemovedDuringCleanup => {
write!(f, "DamageNotRemovedDuringCleanup")
}
StaticMode::CantAttack => write!(f, "CantAttack"),
StaticMode::CantBlock => write!(f, "CantBlock"),
StaticMode::CantAttackOrBlock => write!(f, "CantAttackOrBlock"),
Expand Down
10 changes: 10 additions & 0 deletions data/engine-inventory.json
Original file line number Diff line number Diff line change
Expand Up @@ -29278,6 +29278,16 @@
"doc": "",
"cr_refs": []
},
{
"name": "DamageNotRemovedDuringCleanup",
"line": 620,
"kind": "unit",
"field_names": [],
"doc": "CR 514.2: Normally all damage marked on permanents is removed as a turn-based action during the cleanup step. This static suppresses that removal for the permanents matched by the definition's `affected` filter (\"Damage isn't removed from [filter] during cleanup steps\" — Ancient Adamantoise, Patient Zero, Uthgardt Fury, …), so their marked damage persists across turns.",
"cr_refs": [
"CR 514.2"
]
},
{
"name": "CantAttack",
"line": 614,
Expand Down
Loading