Skip to content
Merged
Show file tree
Hide file tree
Changes from 5 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
40 changes: 29 additions & 11 deletions crates/engine/src/game/casting_costs.rs
Original file line number Diff line number Diff line change
Expand Up @@ -1917,6 +1917,11 @@ fn park_cost_payment_triggers_if_paused(
.filter(|ev| !matches!(ev, GameEvent::PhaseChanged { .. }))
.cloned()
.collect();
let cost_events =
crate::game::triggers::filter_pending_trigger_order_owned_events(state, &cost_events);
if cost_events.is_empty() {
return;
}
if let Some(mut collection) = state.take_pending_activation_trigger_collection() {
// CR 602.2b + CR 603.3b: A target-first activation owns cost-trigger
// collection until its stack entry exists, even when a later payment
Expand Down Expand Up @@ -2615,6 +2620,8 @@ fn park_deferred_cost_triggers_if_paused(
.filter(|ev| !matches!(ev, GameEvent::PhaseChanged { .. }))
.cloned()
.collect();
let cost_events =
crate::game::triggers::filter_pending_trigger_order_owned_events(state, &cost_events);
crate::game::triggers::collect_triggers_into_deferred(state, &cost_events);
}

Expand Down Expand Up @@ -2699,34 +2706,45 @@ fn settle_sacrifice_for_cost_events(
current_end: usize,
) {
if let Some(collection) = pending.activation_trigger_collection.as_mut() {
let unclaimed_cost_events =
crate::game::triggers::filter_pending_trigger_order_owned_events(
state,
&deferred_cost_events,
);
// CR 602.2b + CR 603.2: an announced target-bearing activation owns
// replacement-paused cost events until its stack commit. Earlier action
// fragments are not present in this action's event buffer, while the
// current fragment is collected once by the eventual stack boundary (or
// the next pending-action staging pass).
if !deferred_cost_events.is_empty() {
collection.collect(state, &deferred_cost_events);
if !unclaimed_cost_events.is_empty() {
collection.collect(state, &unclaimed_cost_events);
}
return;
}

deferred_cost_events.extend_from_slice(&events[current_start..current_end]);
let deferred_cost_events = crate::game::triggers::filter_pending_trigger_order_owned_events(
state,
&deferred_cost_events,
);
if !deferred_cost_events.is_empty() {
crate::game::triggers::collect_triggers_into_deferred(state, &deferred_cost_events);
}
// The journal claims the whole current fragment, not just what was collected
// above: an occurrence the filter dropped is one the in-flight ordering pass
// already collected, so the Priority pipeline must not reach it either.
let occurrences = events[current_start..current_end]
.iter()
.enumerate()
.map(|(offset, event)| {
let index = current_start + offset;
crate::game::triggers::ConsumedTriggerEventOccurrence {
.map(
|(offset, event)| crate::game::triggers::ConsumedTriggerEventOccurrence {
event: event.clone(),
occurrence: events[..index]
.iter()
.filter(|prior| *prior == event)
.count(),
}
})
occurrence: crate::game::triggers::trigger_event_occurrence(
events,
current_start + offset,
),
},
)
.collect();
crate::game::triggers::resolve_and_apply_trigger_collection(
state,
Expand Down
210 changes: 208 additions & 2 deletions crates/engine/src/game/triggers.rs
Original file line number Diff line number Diff line change
Expand Up @@ -9894,6 +9894,78 @@ pub(crate) fn filter_consumed_trigger_events(
filter_consumed_trigger_events_from(events, 0, consumed)
}

/// CR 603.2 + CR 603.2c: The occurrence identities an in-flight ordering pass
/// already holds through its pending trigger contexts.
///
/// A context's `trigger_events` lists the occurrences that one observer saw, so
/// occurrence identity within a single context is positional exactly as
/// [`trigger_event_occurrence`] defines it. CR 603.2 fires one ability per
/// observer per occurrence, so observers that saw the same batch REPEAT those
/// identities rather than adding new ones: the union across contexts — not their
/// concatenation — is the set the ordering pass owns.
///
/// THE ORDINALS ARE PER-CONTEXT, AND THE CONSUMER'S ARE PER-BATCH. Contexts do
/// not carry an action-buffer index, so this reduces to "for each event value,
/// the pass owns its first `max`-over-contexts copies", and
/// [`filter_pending_trigger_order_owned_events`] drops that many leading copies
/// from the batch. That is exact whenever byte identity implies one occurrence —
/// which `ZoneChanged` guarantees within a turn through
/// `ZoneChangeRecord::turn_zone_change_index` (proved on
/// [`filter_already_collected_trigger_events_from`]) and which every one-shot
/// departure event (`PermanentSacrificed`, `PermanentDestroyed`) guarantees by
/// construction. The residual is a repeatable non-zone event (`PermanentTapped`
/// for one object across a tap/untap/tap span) whose earlier copy the pass owns
/// but whose batch no longer carries it: the newer copy then takes ordinal 0 and
/// is dropped. Closing that needs the pass to record its occurrences against the
/// action buffer at collection time, not ownership re-derived here; until then
/// this stays strictly narrower than the whole-batch skip it replaces.
///
/// This is a third "already collected" witness alongside the two on
/// [`filter_already_collected_trigger_events_from`], and deliberately not folded
/// into them: that authority consumes ZoneChanged witnesses only, while a cost
/// span parks non-zone occurrences (`PermanentSacrificed`) that an ordering pass
/// owns just as firmly.
fn pending_trigger_order_owned_occurrences(
state: &GameState,
) -> Vec<ConsumedTriggerEventOccurrence> {
let Some(order) = state.pending_trigger_order.as_ref() else {
return Vec::new();
};
order
.groups
.iter()
.flat_map(|group| group.triggers.iter())
.flat_map(|context| {
context
.trigger_events
.iter()
.enumerate()
.map(|(index, event)| ConsumedTriggerEventOccurrence {
event: event.clone(),
occurrence: trigger_event_occurrence(&context.trigger_events, index),
})
})
.fold(Vec::new(), |mut owned, candidate| {
if !owned.contains(&candidate) {
owned.push(candidate);
}
owned
})
}

/// CR 603.2c + CR 603.3b: Drop from `events` only the occurrences an in-flight
/// ordering pass already owns. A cost handler may return through an ordering
/// prompt after its own contexts were collected; it must not park those same
/// occurrences again. Every other occurrence in the batch — a newer event from
/// the resumed fragment, or a further occurrence of an equal-shaped event — is a
/// distinct trigger event under CR 603.2c and still parks normally.
pub(crate) fn filter_pending_trigger_order_owned_events(
state: &GameState,
events: &[GameEvent],
) -> Vec<GameEvent> {
filter_consumed_trigger_events(events, &pending_trigger_order_owned_occurrences(state))
}

/// CR 603.2c: Remove from `events[event_start..]` the occurrences a trigger
/// collector has already taken, so a second collector over the same raw slice
/// cannot fire the same observers twice.
Expand Down Expand Up @@ -14324,8 +14396,8 @@ pub mod tests {
use crate::types::game_state::{
DamageRecord, DeferredLifeCostResume, DelayedTrigger, DistributionUnit, GameState,
LayersDirty, LoopDetectionMode, NamedChoiceSourceBinding, PendingCast,
PendingCostMoveResume, SpellCastRecord, StackEntry, StackEntryKind,
TransientContinuousEffect, WaitingFor, ZoneChangeRecord,
PendingCostMoveResume, PendingTriggerOrder, SpellCastRecord, StackEntry, StackEntryKind,
TransientContinuousEffect, TriggerOrderGroup, WaitingFor, ZoneChangeRecord,
};
use crate::types::identifiers::{
CardId, DelayedTriggerInstanceId, DelayedTriggerOrigin, DelayedTriggerToken, ObjectId,
Expand All @@ -14342,6 +14414,140 @@ pub mod tests {
GameState::new_two_player(42)
}

fn ordering_context(trigger_events: Vec<GameEvent>) -> PendingTriggerContext {
let pending = PendingTrigger::ordinary(
ObjectId(1),
PlayerId(0),
None,
Box::new(ResolvedAbility::new(
Effect::GainLife {
amount: QuantityExpr::Fixed { value: 1 },
player: TargetFilter::Controller,
},
Vec::new(),
ObjectId(1),
PlayerId(0),
)),
0,
);
PendingTriggerContext::batched(pending, trigger_events)
}

fn ordering_with_groups(groups: Vec<Vec<PendingTriggerContext>>) -> PendingTriggerOrder {
PendingTriggerOrder {
groups: groups
.into_iter()
.map(|triggers| TriggerOrderGroup {
controller: PlayerId(0),
triggers,
ordered: false,
})
.collect(),
resume_after_ordering: None,
}
}

fn tapped(object_id: ObjectId) -> GameEvent {
GameEvent::PermanentTapped {
object_id,
caused_by: None,
}
}

#[test]
fn pending_trigger_order_filters_only_the_owned_occurrence_from_a_mixed_batch() {
let owned = tapped(ObjectId(7));
let unowned = tapped(ObjectId(8));
let mut state = setup();
state.pending_trigger_order =
Some(ordering_with_groups(vec![vec![ordering_context(vec![
owned.clone(),
])]]));

assert_eq!(
filter_pending_trigger_order_owned_events(&state, &[owned, unowned.clone()]),
vec![unowned],
);
}

/// CR 603.2c: one event may contain multiple occurrences, so an ordering
/// pass holding a single occurrence must not swallow an equal-shaped second
/// one from the resumed fragment.
#[test]
fn pending_trigger_order_keeps_the_unowned_duplicate_shaped_occurrence() {
let repeated = tapped(ObjectId(7));
let mut state = setup();
state.pending_trigger_order =
Some(ordering_with_groups(vec![vec![ordering_context(vec![
repeated.clone(),
])]]));

assert_eq!(
filter_pending_trigger_order_owned_events(
&state,
&[repeated.clone(), repeated.clone()]
),
vec![repeated],
);
}

/// A batch observer records every occurrence it saw, so both equal-shaped
/// occurrences are owned while a third one is not.
#[test]
fn pending_trigger_order_owns_every_occurrence_a_batch_context_recorded() {
let repeated = tapped(ObjectId(7));
let mut state = setup();
state.pending_trigger_order =
Some(ordering_with_groups(vec![vec![ordering_context(vec![
repeated.clone(),
repeated.clone(),
])]]));

assert_eq!(
filter_pending_trigger_order_owned_events(
&state,
&[repeated.clone(), repeated.clone(), repeated.clone()],
),
vec![repeated],
);
}

/// Two observers of the same occurrence repeat one identity rather than
/// claiming two, and ownership spans every group in the ordering pass.
#[test]
fn pending_trigger_order_unions_occurrences_across_groups_and_observers() {
let first = tapped(ObjectId(7));
let second = tapped(ObjectId(8));
let unowned = tapped(ObjectId(9));
let mut state = setup();
state.pending_trigger_order = Some(ordering_with_groups(vec![
vec![
ordering_context(vec![first.clone()]),
ordering_context(vec![first.clone()]),
],
vec![ordering_context(vec![second.clone()])],
]));

assert_eq!(
filter_pending_trigger_order_owned_events(
&state,
&[first.clone(), first.clone(), second, unowned.clone()],
),
vec![first, unowned],
);
}

#[test]
fn pending_trigger_order_absent_leaves_the_batch_intact() {
let state = setup();
let batch = vec![tapped(ObjectId(7)), tapped(ObjectId(8))];

assert_eq!(
filter_pending_trigger_order_owned_events(&state, &batch),
batch,
);
}

#[test]
fn abandon_ceased_pending_trigger_recovers_when_its_stack_firing_was_pruned() {
let mut state = setup();
Expand Down
Binary file modified crates/engine/tests/fixtures/integration_cards.json.gz
Binary file not shown.
Loading
Loading