Skip to content
Merged
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
163 changes: 154 additions & 9 deletions crates/engine/src/game/effects/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -3313,6 +3313,34 @@ fn split_player_scope_chain(
(scoped, tail)
}

/// CR 608.2c: A multi-target player subject owns only its same-sentence
/// continuation chain. A `SequentialSibling` starts an independent instruction
/// that resolves once after every selected player has completed the subject's
/// local clauses. This is the target-subject counterpart to
/// `split_player_scope_chain` above.
fn split_multi_target_player_chain(
ability: &ResolvedAbility,
) -> (ResolvedAbility, Option<Box<ResolvedAbility>>) {
let mut per_target = ability.clone();
let tail = detach_after_multi_target_player_local_chain(&mut per_target);
(per_target, tail)
}

/// CR 608.2c: Retain `ContinuationStep` clauses under the current "each target
/// player" subject, but detach the first independently sequenced instruction.
fn detach_after_multi_target_player_local_chain(
node: &mut ResolvedAbility,
) -> Option<Box<ResolvedAbility>> {
let mut next = node.sub_ability.take()?;
if next.sub_link != SubAbilityLink::ContinuationStep {
return Some(next);
}

let tail = detach_after_multi_target_player_local_chain(&mut next);
node.sub_ability = Some(next);
tail
}

/// CR 608.2e: Collect cross-player equalization quantity references from a
/// `QuantityExpr`. These are the refs whose value would shift as an APNAP
/// fan-out mutates the board — `ControlledByEachPlayer` (battlefield extremum)
Expand Down Expand Up @@ -3552,6 +3580,23 @@ fn effect_target_filter(effect: &Effect) -> Option<&TargetFilter> {
effect.target_filter()
}

/// CR 601.2c: Oracle's `target opponents` lowering can use the dedicated
/// `TargetFilter::Opponent` player role or a type-less `TypedFilter` constrained
/// solely by `ControllerRef::Opponent`; `target players` uses
/// `TargetFilter::Player`. All three select players at announcement and need
/// the same per-target resolution fan-out.
fn is_multi_target_player_filter(filter: &TargetFilter) -> bool {
match filter {
TargetFilter::Player | TargetFilter::Opponent => true,
TargetFilter::Typed(typed) => {
typed.type_filters.is_empty()
&& typed.properties.is_empty()
&& matches!(typed.controller, Some(ControllerRef::Opponent))
}
_ => false,
}
}

// ── Batch resolution (Tier 3) ────────────────────────────────────────────
//
// Driver-level collapse of N contiguous, identical, observer-free
Expand Down Expand Up @@ -7562,11 +7607,12 @@ fn resolve_chain_body(
return Ok(());
}

// CR 603.3d + CR 601.2c: Multi-target-over-`Player` resolution fan-out.
// An "any number of target players each <verb>" trigger/spell announces a
// variable number of player targets when it goes on the stack (CR 601.2c,
// reached for triggers via CR 603.3d). The chosen player set lands in
// `ability.targets` as `TargetRef::Player` entries with `multi_target` set.
// CR 603.3d + CR 601.2c: Multi-target player-subject resolution fan-out.
// An "any number of target players/opponents each <verb>" trigger/spell
// announces a variable number of player targets when it goes on the stack
// (CR 601.2c, reached for triggers via CR 603.3d). The chosen player set
// lands in `ability.targets` as `TargetRef::Player` entries with
// `multi_target` set.
// Single-player-recipient effect handlers (`Discard`, `Mill`, `LoseLife`)
// resolve for only the FIRST `TargetRef::Player`, so without this branch a
// selection of two players discards only one. This fan-out is the missing
Expand All @@ -7580,7 +7626,7 @@ fn resolve_chain_body(
// `multi_target` and changes the effect's target filter, which would
// otherwise skip this branch and resolve only the first chosen player.
if ability.multi_target.is_some()
&& effect_target_filter(&ability.effect) == Some(&TargetFilter::Player)
&& effect_target_filter(&ability.effect).is_some_and(is_multi_target_player_filter)
{
let chosen_players: Vec<PlayerId> = ability
.targets
Expand All @@ -7591,6 +7637,11 @@ fn resolve_chain_body(
})
.collect();
if chosen_players.len() != 1 {
// A sentence boundary begins an instruction that is not qualified
// by the preceding "each target player" subject. Keep that tail
// outside the per-target template so Wheel and Deal's final
// "Draw a card" is performed once by its controller.
let (per_target, after_fanout) = split_multi_target_player_chain(ability);
// CR 601.2c: "any number of target players" permits zero targets.
// CR 608.2c: An ability resolving with zero chosen player targets
// does nothing — emit `EffectResolved` and stop BEFORE
Expand All @@ -7605,6 +7656,9 @@ fn resolve_chain_body(
source_id: ability.source_id,
subject: None,
});
if let Some(after_fanout) = after_fanout {
resolve_ability_chain(state, &after_fanout, events, depth + 1)?;
}
return Ok(());
}
// CR 101.4 + CR 608.2c: Resolve the effect once per chosen player in
Expand All @@ -7617,7 +7671,7 @@ fn resolve_chain_body(
.collect();
let initial_waiting_for = state.waiting_for.clone();
for (i, pid) in fanout_players.iter().enumerate() {
let mut narrowed = ability.clone();
let mut narrowed = per_target.clone();
narrowed.targets = vec![TargetRef::Player(*pid)];
narrowed.multi_target = None;
resolve_ability_chain(state, &narrowed, events, depth + 1)?;
Expand All @@ -7629,9 +7683,9 @@ fn resolve_chain_body(
// `drain_pending_continuation` after the choice resolves.
if state.waiting_for != initial_waiting_for {
let remaining = &fanout_players[i + 1..];
let mut tail: Option<Box<ResolvedAbility>> = None;
let mut tail = after_fanout.clone();
for &remaining_pid in remaining.iter().rev() {
let mut remaining_narrowed = ability.clone();
let mut remaining_narrowed = per_target.clone();
remaining_narrowed.targets = vec![TargetRef::Player(remaining_pid)];
remaining_narrowed.multi_target = None;
if let Some(prev) = tail {
Expand All @@ -7646,6 +7700,11 @@ fn resolve_chain_body(
break;
}
}
if state.waiting_for == initial_waiting_for {
if let Some(after_fanout) = after_fanout {
resolve_ability_chain(state, &after_fanout, events, depth + 1)?;
}
}
return Ok(());
}
}
Expand Down Expand Up @@ -23543,6 +23602,92 @@ mod tests {
);
}

/// CR 601.2c + CR 608.2c: If a multi-target opponent instruction pauses,
/// every remaining opponent must resume before its detached sequential tail
/// runs once for the controller.
#[test]
fn multi_target_opponents_resume_before_controller_sequential_tail() {
let mut state = GameState::new(FormatConfig::commander(), 3, 42);
for card in 0..3 {
create_object(
&mut state,
CardId(1_000 + card),
PlayerId(0),
format!("Controller library {card}"),
Zone::Library,
);
}
for player in 1..3u8 {
for card in 0..2 {
create_object(
&mut state,
CardId(u64::from(player) * 100 + card),
PlayerId(player),
format!("P{player} hand {card}"),
Zone::Hand,
);
}
}

let opponent_filter = TargetFilter::Typed(TypedFilter {
type_filters: vec![],
controller: Some(ControllerRef::Opponent),
properties: vec![],
});
let mut ability = ResolvedAbility::new(
Effect::Discard {
count: QuantityExpr::Fixed { value: 1 },
target: opponent_filter,
selection: crate::types::ability::CardSelectionMode::Chosen,
unless_filter: None,
filter: None,
},
vec![
TargetRef::Player(PlayerId(1)),
TargetRef::Player(PlayerId(2)),
],
ObjectId(500),
PlayerId(0),
);
ability.multi_target = Some(crate::types::ability::MultiTargetSpec::unlimited(0));
let mut controller_tail = ResolvedAbility::new(
Effect::Draw {
count: QuantityExpr::Fixed { value: 1 },
target: TargetFilter::Controller,
},
vec![],
ObjectId(500),
PlayerId(0),
);
controller_tail.sub_link = crate::types::ability::SubAbilityLink::SequentialSibling;
ability.sub_ability = Some(Box::new(controller_tail));

let controller_hand_before = state.players[0].hand.len();
let mut events = Vec::new();
resolve_ability_chain(&mut state, &ability, &mut events, 0).unwrap();

let mut prompted_players = Vec::new();
while let WaitingFor::DiscardChoice { player, cards, .. } = &state.waiting_for {
let player = *player;
let pick = cards[0];
crate::game::engine::apply_as_current(
&mut state,
GameAction::SelectCards { cards: vec![pick] },
)
.unwrap();
prompted_players.push(player);
}

assert_eq!(prompted_players, vec![PlayerId(1), PlayerId(2)]);
assert_eq!(state.players[1].graveyard.len(), 1);
assert_eq!(state.players[2].graveyard.len(), 1);
assert_eq!(
state.players[0].hand.len() - controller_hand_before,
1,
"the detached controller draw runs exactly once after both discards"
);
}

/// GH #582 — CR 104.2b + CR 107.3i + CR 608.2c + CR 700.5: Thassa's Oracle
/// runtime discriminator. The synthesized AST gates `Effect::WinTheGame`
/// with `AbilityCondition::QuantityCheck { lhs: Devotion{[Blue]}, comparator: GE, rhs:
Expand Down
37 changes: 29 additions & 8 deletions crates/engine/src/game/scenario.rs
Original file line number Diff line number Diff line change
Expand Up @@ -2155,12 +2155,14 @@ impl<'a> SpellCast<'a> {
&mut events,
)?;

// Intent the driver matches as it walks slots: object targets are
// consumed one per slot (most slots are object slots), while player
// targets are reusable across slots (one player may be targeted by
// several modes — see `pick_slot_target`).
// Intent the driver matches as it walks slots. Object targets are
// always consumed one per slot. Player declarations are consumed only
// by a multi-target run so `.target_players(&[a, b])` can express two
// distinct targets while a single declaration remains reusable across
// independent modal slots.
let mut remaining_objects: Vec<ObjectId> = target_objects;
let declared_players: Vec<PlayerId> = target_players;
let mut remaining_multi_target_players = declared_players.clone();
let mut remaining_cost_objects: Vec<ObjectId> = cost_objects;

// CR 601.2a: the spell leaves hand only at stack commit. Captured when
Expand Down Expand Up @@ -2358,6 +2360,7 @@ impl<'a> SpellCast<'a> {
}
// CR 601.2c: declare one target per slot, in written order.
WaitingFor::TargetSelection {
pending_cast,
target_slots,
selection,
..
Expand All @@ -2366,6 +2369,11 @@ impl<'a> SpellCast<'a> {
let choice = pick_slot_target(
slot,
&mut remaining_objects,
pending_cast
.ability
.multi_target
.as_ref()
.map(|_| &mut remaining_multi_target_players),
&declared_players,
selection.current_slot,
);
Expand Down Expand Up @@ -2547,15 +2555,17 @@ impl<'a> CastCommit<'a> {
/// matching CR 601.2c (targets declared one per slot, in written order).
///
/// Object intent is *consumed* (each declared object satisfies at most one
/// slot, so distinct exile/destroy targets never alias). Player intent is
/// *reusable* — the same player is routinely targeted by several modes of one
/// modal spell (e.g. Kozilek's Command mode 1 scries *and* draws for the same
/// target player), so a declared player may satisfy multiple player slots.
/// slot, so distinct exile/destroy targets never alias). A multi-target run
/// consumes player declarations in order when available; otherwise, player
/// intent is reusable, letting the same declared player satisfy independent
/// modal slots (e.g. Kozilek's Command mode 1 scries *and* draws for the same
/// target player).
/// Falls back to `None` for optional slots; panics for an unsatisfiable
/// required slot.
fn pick_slot_target(
slot: &crate::types::game_state::TargetSelectionSlot,
remaining_objects: &mut Vec<ObjectId>,
remaining_multi_target_players: Option<&mut Vec<PlayerId>>,
declared_players: &[PlayerId],
slot_index: usize,
) -> Option<TargetRef> {
Expand All @@ -2565,6 +2575,14 @@ fn pick_slot_target(
{
return Some(TargetRef::Object(remaining_objects.remove(pos)));
}
if let Some(remaining_players) = remaining_multi_target_players {
if let Some(pos) = remaining_players
.iter()
.position(|&player| slot.legal_targets.contains(&TargetRef::Player(player)))
{
return Some(TargetRef::Player(remaining_players.remove(pos)));
}
}
if let Some(&player) = declared_players
.iter()
.find(|&&p| slot.legal_targets.contains(&TargetRef::Player(p)))
Expand Down Expand Up @@ -2882,6 +2900,7 @@ impl<'a> AbilityActivation<'a> {
let choice = pick_slot_target(
slot,
&mut remaining_objects,
None,
&declared_players,
selection.current_slot,
);
Expand Down Expand Up @@ -3106,6 +3125,7 @@ fn drive_resolution(
let choice = pick_slot_target(
slot,
&mut remaining_objects,
None,
declared_players,
selection.current_slot,
);
Expand All @@ -3127,6 +3147,7 @@ fn drive_resolution(
let choice = pick_slot_target(
slot,
&mut remaining_objects,
None,
declared_players,
selection.current_slot,
);
Expand Down
Loading
Loading