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
62 changes: 62 additions & 0 deletions crates/engine/src/game/casting.rs
Original file line number Diff line number Diff line change
Expand Up @@ -2225,6 +2225,68 @@ pub(super) fn selected_static_permission_enters_with_counter(
})
}

// CR 614.1a + CR 607.1: Shared single-authority extractor for the granted
// leave-the-battlefield-exile replacement rider carried on a
// `GraveyardCastPermission` / `ExileCastPermission` static. Mirrors the
// `permission_counter` inner fn shape. Returns the cloned boxed def (`None` when
// the permission carries no rider).
fn permission_granted_replacement(
def: &StaticDefinition,
) -> Option<Box<crate::types::ability::ReplacementDefinition>> {
match &def.mode {
StaticMode::GraveyardCastPermission {
granted_replacement,
..
} => granted_replacement.clone(),
// `ExileCastPermission` carries no `granted_replacement` field today; if a
// printed exile-cast carrier ships, the field + arm slot in here.
_ => None,
}
}

// CR 614.1a + CR 608.2c + CR 607.1: Spell path — read the granted leave-the-
// battlefield-exile replacement rider from the *selected* cast permission (the
// one authorizing THIS cast, embedded in `casting_variant`) so a non-consumed
// sibling permission's rider cannot leak (CR 608.2c). Mirrors
// `selected_static_permission_enters_with_counter`, including its CR 607.1
// self-granting `.or_else` fallback (a self-granting source is the cast object,
// now on the Stack, so its Graveyard-scoped permission no longer functions in
// zone — read the rider directly from the committed authorizing definition).
pub(super) fn selected_static_permission_granted_replacement(
state: &GameState,
casting_variant: &crate::types::game_state::CastingVariant,
) -> Option<Box<crate::types::ability::ReplacementDefinition>> {
use crate::types::game_state::CastingVariant;
let source = match casting_variant {
CastingVariant::GraveyardPermission { source, .. }
| CastingVariant::ExilePermission { source, .. } => *source,
_ => return None,
};
let source_obj = state.objects.get(&source)?;
active_static_definitions(state, source_obj)
.find_map(permission_granted_replacement)
.or_else(|| {
source_obj
.static_definitions
.iter_all()
.find_map(permission_granted_replacement)
})
}

// CR 305.1 + CR 614.1a + CR 607.1: Land path — read the granted leave-the-
// battlefield-exile replacement rider from the graveyard-play permission on the
// `source` permanent (The Eighth Doctor). A played land never self-grants this
// permission, so the spell-only self-granting fallback is omitted; routing
// through the shared `permission_granted_replacement` extractor keeps rider
// reading single-authority.
pub(super) fn graveyard_permission_granted_replacement(
state: &GameState,
source_id: ObjectId,
) -> Option<Box<crate::types::ability::ReplacementDefinition>> {
let source_obj = state.objects.get(&source_id)?;
active_static_definitions(state, source_obj).find_map(permission_granted_replacement)
}

// CR 205.1b + CR 613.1d: read the enters-with type-grant rider ("… is a [type]
// in addition to its other types") from the *consumed* cast-this-way permission
// only (the one supporting THIS cast), not any permission carrying modifications,
Expand Down
14 changes: 14 additions & 0 deletions crates/engine/src/game/casting_costs.rs
Original file line number Diff line number Diff line change
Expand Up @@ -6983,6 +6983,20 @@ fn finalize_cast_with_phyrexian_choices_inner(
.push((object_id, counter_type, 1));
}

// CR 614.1a + CR 603.6c + CR 607.1: install the granted "if this permanent
// would leave the battlefield, exile it instead" rider from the consumed
// permission (The Eighth Doctor). Queued now (spell on the stack); applied
// when the permanent enters the battlefield (shared `pending_etb_replacements`
// drain in `zone_pipeline::apply_zone_delivery_tail`). Read from the SELECTED
// permission (CR 608.2c) so a sibling permission's rider cannot leak.
let static_perm_granted_replacement =
super::casting::selected_static_permission_granted_replacement(state, &casting_variant);
if let Some(replacement) = static_perm_granted_replacement {
state
.pending_etb_replacements
.push((object_id, replacement));
}

// CR 205.1b + CR 613.1d: A `CastFromZone` grant whose rider was "… is a
// [type] in addition to its other types" (The Tomb of Aclazotz) records the
// additive type-changing modifications on the granted `ExileWithAltCost`.
Expand Down
39 changes: 27 additions & 12 deletions crates/engine/src/game/effects/add_target_replacement.rs
Original file line number Diff line number Diff line change
Expand Up @@ -211,13 +211,32 @@ pub fn resolve(
// layer reset (evaluate_layers rebuilds live
// replacement_definitions from base — layers.rs). The base
// store is otherwise the printed baseline (CR 613.1,
// game_object.rs); this is a deliberate, prune-bounded
// exception: the three lapse prunes (control swap, source
// leave-play, host leave-play) remove this def on every
// CR 611.2b lapse, so base never accumulates a stale runtime
// rider. printed_cards.rs is the only intrinsic base-write
// precedent; there is no additive-runtime base-push
// precedent, so this exception is documented here. This
// game_object.rs). Three additive base-write sites bear on
// this invariant (two production, one test-only), documented
// together so this note does not drift:
// (1) THIS `ControllerControlsSource`-gated push — the only
// *prune-bounded* additive base-write: its three CR 611.2b
// lapse prunes (control swap, source leave-play, host
// leave-play) remove this def on every lapse, so base never
// accumulates a stale runtime rider.
// (2) The granted-ETB-replacement drain
// (`zone_pipeline::apply_zone_delivery_tail`, The Eighth
// Doctor) — an *ungated* base-write (`condition: None`, no
// lapse prune) that relies for cleanup solely on "a
// battlefield exit mints a new object whose base is rebuilt
// from the printed card"; correct because that granted
// replacement is itself self-exiling.
// (3) `morph.rs` (test scaffolding only, under `#[cfg(test)]`)
// manually base+live-pushes a `TurnFaceUp` replacement to
// exercise the base→live layer-reset survival path
// (CR 614.1e + CR 708.11 — "As [this] is turned face up").
// It is NOT a production runtime rider; it has NOT been
// migrated and remains a raw
// `Arc::make_mut(&mut base_replacement_definitions).push`.
// Only production sites (1) and (2) route through
// `GameObject::install_replacement` — its single authority is
// scoped to exactly those two callers (see its doc); the
// morph.rs test push stays a bare manual write. This
// gate-scoping keeps transient riders (die-exile, Crafty
// Cutpurse, end-of-turn) live-only and untouched.
//
Expand All @@ -234,11 +253,7 @@ pub fn resolve(
Some(ReplacementCondition::ControllerControlsSource { .. })
);
if let Some(obj) = state.objects.get_mut(&obj_id) {
if install_to_base {
std::sync::Arc::make_mut(&mut obj.base_replacement_definitions)
.push(replacement.clone());
}
obj.replacement_definitions.push(replacement);
obj.install_replacement(replacement, install_to_base);
attached += 1;
}
}
Expand Down
1 change: 1 addition & 0 deletions crates/engine/src/game/effects/create_emblem.rs
Original file line number Diff line number Diff line change
Expand Up @@ -159,6 +159,7 @@ mod tests {
graveyard_destination_replacement: None,
extra_cost: None,
enters_with_counter: None,
granted_replacement: None,
})
.affected(TargetFilter::Typed(TypedFilter::new(
crate::types::ability::TypeFilter::Land,
Expand Down
18 changes: 18 additions & 0 deletions crates/engine/src/game/engine.rs
Original file line number Diff line number Diff line change
Expand Up @@ -7616,6 +7616,24 @@ fn handle_play_land(
}
}

// CR 305.1 + CR 614.1a + CR 603.6c: a land played from the graveyard via a
// once-per-turn permission inherits that permission's granted "if this
// permanent would leave the battlefield, exile it instead" rider, installed
// as it enters the battlefield (same `pending_etb_replacements` queue and
// `apply_zone_delivery_tail` drain as the spell path). The land keeps its
// storage ObjectId across the graveyard→battlefield move, so keying the queue
// on `object_id` at play time drains against the same id at entry. Net-new
// path: no counter rider populates this queue for a land today.
if let Some(source) = gy_permission_source {
if let Some(replacement) =
super::casting::graveyard_permission_granted_replacement(state, source)
{
state
.pending_etb_replacements
.push((object_id, replacement));
}
}

match super::replacement::replace_event(state, proposed, events) {
super::replacement::ReplacementResult::Execute(event) => {
if let crate::types::proposed_event::ProposedEvent::ZoneChange { object_id, .. } = event
Expand Down
1 change: 1 addition & 0 deletions crates/engine/src/game/engine_tests.rs
Original file line number Diff line number Diff line change
Expand Up @@ -9202,6 +9202,7 @@ fn grant_graveyard_creature_cast_and_bury(
graveyard_destination_replacement: None,
extra_cost: None,
enters_with_counter: None,
granted_replacement: None,
})
.affected(TargetFilter::Typed(
TypedFilter::creature().controller(ControllerRef::You),
Expand Down
20 changes: 20 additions & 0 deletions crates/engine/src/game/game_object.rs
Original file line number Diff line number Diff line change
Expand Up @@ -1191,6 +1191,26 @@ pub(crate) fn chosen_card_type_of(attrs: &[ChosenAttribute]) -> Option<CoreType>
}

impl GameObject {
/// CR 613.1 + CR 614.1a: install a replacement definition onto this object.
/// `to_base = true` also writes it to the printed-characteristics base store
/// (`base_replacement_definitions`) so it survives every layer reset — the
/// layer pass rebuilds the live store from base (see the
/// `replacement_definitions = base_replacement_definitions` reset below in
/// this file); the live store is always updated so the def functions this
/// pass. Single authority for additive replacement installs (shared by
/// `effects::add_target_replacement` and the granted-ETB-replacement drain in
/// `zone_pipeline::apply_zone_delivery_tail`).
pub(crate) fn install_replacement(
&mut self,
def: crate::types::ability::ReplacementDefinition,
to_base: bool,
) {
if to_base {
std::sync::Arc::make_mut(&mut self.base_replacement_definitions).push(def.clone());
}
self.replacement_definitions.push(def);
}

/// Apply an Alchemy "perpetually" modification to this card: record it on the
/// object (so it persists across zones/serialization and can be re-applied
/// after a copy rebuilds base characteristics) and edit the corresponding
Expand Down
4 changes: 4 additions & 0 deletions crates/engine/src/game/turns.rs
Original file line number Diff line number Diff line change
Expand Up @@ -984,6 +984,10 @@ pub fn start_next_turn(state: &mut GameState, events: &mut Vec<GameEvent>) {
state.pending_next_spell_modifiers.clear();
// CR 614.1c: Pending ETB counters are turn-scoped (e.g., "this turn" effects).
state.pending_etb_counters.clear();
// CR 614.1a: Backstop-clear pending granted ETB replacements for spells that
// never resolved this turn (e.g. countered). The play→enter path is same-turn,
// so this never drops a live install.
state.pending_etb_replacements.clear();
state.modal_modes_chosen_this_turn.clear();
for player in &mut state.players {
player.has_drawn_this_turn = false;
Expand Down
31 changes: 31 additions & 0 deletions crates/engine/src/game/zone_pipeline.rs
Original file line number Diff line number Diff line change
Expand Up @@ -1237,6 +1237,37 @@ pub(crate) fn apply_zone_delivery_tail(
library_placement: Option<&LibraryPosition>,
events: &mut Vec<GameEvent>,
) -> ZoneDeliveryResult {
// CR 614.1a + CR 613.1: install queued granted replacements onto the entering
// permanent (The Eighth Doctor's "if this permanent would leave the
// battlefield, exile it instead"). This is the single post-counter
// convergence point reached by BOTH the immediate `deliver_replaced_zone_change`
// fall-through and the counter-pause resume (`ContinueZoneDeliveryTail`), so a
// single drain here fires exactly once per delivery and cannot double-install.
// Persistent granted riders MUST go to base (`to_base = true`): the layer pass
// rebuilds live `replacement_definitions` from base every pass
// (game_object.rs). Cleanup is by object-mint on battlefield exit (a new object
// rebuilds base from the printed card), so base never accumulates a stale
// rider — this rider is itself self-exiling, so there is no lapse prune and
// none is needed (CR 611.2c: the granted ability persists on the object
// independent of the granting source).
if to == Zone::Battlefield {
let installs: Vec<_> = state
.pending_etb_replacements
.iter()
.filter(|(oid, _)| *oid == object_id)
.map(|(_, r)| r.clone())
.collect();
if !installs.is_empty() {
state
.pending_etb_replacements
.retain(|(oid, _)| *oid != object_id);
if let Some(obj) = state.objects.get_mut(&object_id) {
for replacement in installs {
obj.install_replacement(*replacement, true);
}
}
}
}
// CR 701.24a: To shuffle a library, randomize the cards within it so that
// no player knows their order. A request that places the object at a specific
// position is NOT a shuffle (a placement instruction is not a shuffle
Expand Down
70 changes: 45 additions & 25 deletions crates/engine/src/parser/oracle_effect/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -1669,6 +1669,11 @@ fn parse_leave_battlefield_rider_ref(input: &str) -> OracleResult<'_, ()> {
value(
(),
alt((
// `~` is the normalized self-reference token ("this permanent" →
// `~`); accepting it alongside the un-normalized phrase forms lets the
// shared def-builder parse a granted rider whether or not the caller
// normalized the host reference.
tag("~"),
tag("it"),
tag("the card"),
tag("the creature"),
Expand All @@ -1686,11 +1691,17 @@ fn parse_leave_battlefield_rider_ref(input: &str) -> OracleResult<'_, ()> {
.parse(input)
}

/// CR 614.1a + CR 614.6: Detect "If it would leave the battlefield, exile it
/// instead of putting it anywhere else." riders attached to a previously
/// selected object. The carried `ReplacementDefinition` is installed on the
/// parent target, where `valid_card: SelfRef` binds to that host object.
fn try_parse_leave_battlefield_exile_replacement(lower: &str) -> Option<Effect> {
/// CR 614.1a + CR 614.6 + CR 603.6c: Build the "If ‹this permanent› would leave
/// the battlefield, exile it instead of putting it anywhere else."
/// `ReplacementDefinition` from its (lowercased) inner text. The produced def is
/// a `Moved`/`SelfRef` battlefield-exit → Exile redirect (Rest-in-Peace class),
/// where `valid_card: SelfRef` binds to the host object the def is installed on.
/// Shared builder: used by the effect-rider wrapper
/// (`try_parse_leave_battlefield_exile_replacement`) AND by the granted-permission
/// carrier (`oracle_static::restriction::try_parse_disjunctive_graveyard_cast_permission`).
pub(crate) fn parse_leave_battlefield_exile_replacement_def(
lower: &str,
) -> Option<ReplacementDefinition> {
let (rest, _) = nom::combinator::opt(tag::<_, _, OracleError<'_>>("if "))
.parse(lower)
.ok()?;
Expand All @@ -1709,27 +1720,36 @@ fn try_parse_leave_battlefield_exile_replacement(lower: &str) -> Option<Effect>
.ok()?;
parse_optional_period_and_end(rest)?;

let replacement = ReplacementDefinition::new(ReplacementEvent::Moved)
.valid_card(TargetFilter::SelfRef)
.execute(AbilityDefinition::new(
AbilityKind::Spell,
Effect::ChangeZone {
origin: Some(Zone::Battlefield),
destination: Zone::Exile,
target: TargetFilter::SelfRef,
owner_library: false,
enter_transformed: false,
enters_under: None,
enter_tapped: crate::types::zones::EtbTapState::Unspecified,
enters_attacking: false,
up_to: false,
enter_with_counters: vec![],
conditional_enter_with_counters: vec![],
face_down_profile: None,
enters_modified_if: None,
},
));
Some(
ReplacementDefinition::new(ReplacementEvent::Moved)
.valid_card(TargetFilter::SelfRef)
.execute(AbilityDefinition::new(
AbilityKind::Spell,
Effect::ChangeZone {
origin: Some(Zone::Battlefield),
destination: Zone::Exile,
target: TargetFilter::SelfRef,
owner_library: false,
enter_transformed: false,
enters_under: None,
enter_tapped: crate::types::zones::EtbTapState::Unspecified,
enters_attacking: false,
up_to: false,
enter_with_counters: vec![],
conditional_enter_with_counters: vec![],
face_down_profile: None,
enters_modified_if: None,
},
)),
)
}

/// CR 614.1a + CR 614.6: Detect "If it would leave the battlefield, exile it
/// instead of putting it anywhere else." riders attached to a previously
/// selected object. The carried `ReplacementDefinition` is installed on the
/// parent target, where `valid_card: SelfRef` binds to that host object.
fn try_parse_leave_battlefield_exile_replacement(lower: &str) -> Option<Effect> {
let replacement = parse_leave_battlefield_exile_replacement_def(lower)?;
Some(Effect::AddTargetReplacement {
replacement: Box::new(replacement),
target: TargetFilter::Any,
Expand Down
Loading
Loading