Skip to content
Merged
Show file tree
Hide file tree
Changes from 2 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
18 changes: 18 additions & 0 deletions crates/engine/src/parser/oracle_effect/imperative.rs
Original file line number Diff line number Diff line change
Expand Up @@ -206,6 +206,24 @@ fn parse_dig_library_owner(rest_lower: &str) -> TargetFilter {
return TargetFilter::ParentTargetOwner;
}

// CR 401.1 + CR 608.2c: "the top card of each player's library" — each player
// owns their own library, so this names one library per player. Mark the
// per-player scope with `ScopedPlayer` so the look-then-exile idiom's
// materialized `ExileTop` lifts to a `player_scope: All` fan-out (the same
// shape the direct "exile the top card of each player's library" path gets via
// `parse_library_player_suffix`, the single authority for this phrase→scope
// mapping). Anchored on the possessive-of form so a stray later mention of a
// library in a multi-clause remainder can't false-match.
if preceded(
take_until::<_, _, OracleError<'_>>("of each player's library"),
tag::<_, _, OracleError<'_>>("of each player's library"),
)
.parse(rest_lower)
.is_ok()
{
return TargetFilter::ScopedPlayer;
Comment thread
coderabbitai[bot] marked this conversation as resolved.
Outdated
}

TargetFilter::Controller
}

Expand Down
16 changes: 16 additions & 0 deletions crates/engine/src/parser/oracle_effect/sequence.rs
Original file line number Diff line number Diff line change
Expand Up @@ -4963,6 +4963,22 @@ pub(super) fn apply_clause_continuation(
position: crate::types::ability::LibraryPosition::Top,
face_down,
};
// CR 608.2c + CR 401.1: "look at the top card of each player's library,
// then exile those cards" — the `ScopedPlayer` owner marker set by
// `parse_dig_library_owner` lifts this materialized `ExileTop` into a
// per-player `player_scope: All` fan-out, the same shape the direct
// "exile the top card of each player's library" path gets via the lift
// in `parse_effect_chain_ir`. This is the symmetric materialization
// seam: the direct path lifts where `parse_exile_ast` produces its
// `ExileTop`, and this look-then-exile path lifts where the `Dig` is
// back-patched into one. The lift survives assembly because this
// back-patched def already passed the per-clause `player_scope` write,
// and the current clause's write only touches its own def. The
// after-scope play/cast tail (Extract Power's `CastFromZone`) is
// detached by `split_player_scope_chain` and runs once for the
// controller over the union of exiled cards.
let d = &mut defs[bound_index];
super::lift_each_player_exile_top_scope(&mut d.effect, &mut d.player_scope);
}
// CR 702.75a + CR 406.3: "exile one of them face down" patches the
// preceding private `Dig` into the Hideaway shape — the controller
Expand Down
301 changes: 301 additions & 0 deletions crates/engine/tests/integration/extract_power_each_player_exile.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,301 @@
//! Regression: "Extract Power" (Marvel Super Heroes Commander) must exile the
//! top card of EACH player's library, not just the controller's.
//!
//! Oracle (verbatim, verified against MTGJSON `MSC`):
//! "Look at the top card of each player's library, then exile those cards face
//! down. You may play them without paying their mana costs for as long as they
//! remain exiled."
//!
//! Bug: the look-then-exile idiom ("look at the top card of each player's
//! library, then exile those cards") dropped the per-player scope. The `Dig`
//! owner recognizer (`parse_dig_library_owner`) had no "each player's library"
//! arm, so the `Dig` inherited `TargetFilter::Controller`; when the "then exile
//! those cards" continuation back-patched the `Dig` into an `Effect::ExileTop`,
//! that `ExileTop { player: Controller }` carried no `player_scope`, so at
//! runtime only the controller's top card was exiled.
//!
//! Fix (parser only): `parse_dig_library_owner` now maps "of each player's
//! library" to `TargetFilter::ScopedPlayer` (mirroring the direct-exile path's
//! `parse_library_player_suffix`), and the `ExileLookedAtCard` materialization
//! seam calls the shared `lift_each_player_exile_top_scope`, rewriting the
//! materialized `ExileTop { ScopedPlayer }` to `ExileTop { Controller }` +
//! `player_scope: All` — the exact production-proven shape of Etali / Nashi /
//! Lidless Gaze, whose `player_scope: All` fan-out (`resolve_chain_body`
//! rebinds the acting controller per player) exiles every library's top card.
//!
//! CR 401.1: each player's deck becomes their own library (one library per
//! player).
//! CR 406.3: the cards are exiled face down.

use engine::game::scenario::{GameRunner, GameScenario};
use engine::parser::oracle::parse_oracle_text;
use engine::types::ability::{Effect, PlayerFilter, TargetFilter};
use engine::types::actions::GameAction;
use engine::types::game_state::CastPaymentMode;
use engine::types::identifiers::ObjectId;
use engine::types::phase::Phase;
use engine::types::player::PlayerId;
use engine::types::zones::Zone;

const P0: PlayerId = PlayerId(0);
const P1: PlayerId = PlayerId(1);
const P2: PlayerId = PlayerId(2);

/// Verbatim Oracle text (MTGJSON `MSC`).
const EXTRACT_POWER: &str = "Look at the top card of each player's library, then exile those cards \
face down. You may play them without paying their mana costs for as long as they remain exiled.";

fn zone_of(runner: &GameRunner, id: ObjectId) -> Zone {
runner
.state()
.objects
.get(&id)
.expect("object present")
.zone
}

/// CR 401.1: Extract Power exiles the top card of EVERY player's library.
/// Three players, each library seeded with a distinct known top card and a
/// distinct second card. After resolution every player's TOP card is in exile
/// and every player's SECOND card is untouched in the library.
///
/// DISCRIMINATING ASSERTION: `zone_of(p1_top) == Exile` and
/// `zone_of(p2_top) == Exile`. Reverting either parser edit drops
/// `player_scope`, so the `ExileTop { player: Controller }` exiles only P0's top
/// card — P1's and P2's top cards stay in the library and both assertions fail.
/// The paired positive reach-guard `zone_of(p0_top) == Exile` proves the effect
/// resolved (not a vacuous no-op).
#[test]
fn extract_power_exiles_top_card_of_each_player() {
let mut scenario = GameScenario::new_n_player(3, 7_431);
scenario.at_phase(Phase::PreCombatMain);

// Seed each library: add the "second" card first, then the "top" card, so
// the top card ends up at library[0] and the second at library[1].
let p0_second = scenario.add_card_to_library_top(P0, "P0 Second");
let p0_top = scenario.add_card_to_library_top(P0, "P0 Top");
let p1_second = scenario.add_card_to_library_top(P1, "P1 Second");
let p1_top = scenario.add_card_to_library_top(P1, "P1 Top");
let p2_second = scenario.add_card_to_library_top(P2, "P2 Second");
let p2_top = scenario.add_card_to_library_top(P2, "P2 Top");

let spell = scenario
.add_spell_to_hand_from_oracle(P0, "Extract Power", false, EXTRACT_POWER)
.id();
let mut runner = scenario.build();

let card_id = runner.state().objects[&spell].card_id;
runner
.act(GameAction::CastSpell {
object_id: spell,
card_id,
targets: vec![],
payment_mode: CastPaymentMode::Auto,
})
.expect("casting Extract Power (no mana cost) must be accepted");
runner.advance_until_stack_empty();

// Positive reach-guard: the controller's top card IS exiled — the effect
// resolved.
assert_eq!(
zone_of(&runner, p0_top),
Zone::Exile,
"the controller's top card must be exiled"
);
// Multi-authority discriminators: EVERY other player's top card is exiled
// too. These flip to Library on revert (player_scope dropped).
assert_eq!(
zone_of(&runner, p1_top),
Zone::Exile,
"P1's top card must be exiled — each player's library, not just the controller's"
);
assert_eq!(
zone_of(&runner, p2_top),
Zone::Exile,
"P2's top card must be exiled — each player's library, not just the controller's"
);

// Exactly the top card of each library leaves — the second card stays.
assert_eq!(
zone_of(&runner, p0_second),
Zone::Library,
"only the TOP card is exiled — P0's second card stays in the library"
);
assert_eq!(
zone_of(&runner, p1_second),
Zone::Library,
"only the TOP card is exiled — P1's second card stays in the library"
);
assert_eq!(
zone_of(&runner, p2_second),
Zone::Library,
"only the TOP card is exiled — P2's second card stays in the library"
);

// CR 406.3: the exiled cards are exiled face down.
for top in [p0_top, p1_top, p2_top] {
assert!(
runner.state().objects[&top].face_down,
"exiled cards must be face down (CR 406.3)"
);
}
}

/// Hostile fixture — CR 609.3 (an effect does only as much as possible; moving
/// cards out of a library moves as many as possible): a player with an EMPTY
/// library contributes nothing and does not error. P2's library is left empty;
/// P0 and P1 each have a top card. Resolution completes cleanly and the two
/// non-empty libraries' top cards are exiled.
#[test]
fn extract_power_empty_library_resolves_without_error() {
let mut scenario = GameScenario::new_n_player(3, 7_432);
scenario.at_phase(Phase::PreCombatMain);

let p0_top = scenario.add_card_to_library_top(P0, "P0 Top");
let p1_top = scenario.add_card_to_library_top(P1, "P1 Top");
// P2: library intentionally left empty.

let spell = scenario
.add_spell_to_hand_from_oracle(P0, "Extract Power", false, EXTRACT_POWER)
.id();
let mut runner = scenario.build();

let card_id = runner.state().objects[&spell].card_id;
runner
.act(GameAction::CastSpell {
object_id: spell,
card_id,
targets: vec![],
payment_mode: CastPaymentMode::Auto,
})
.expect("casting must be accepted even with an empty opponent library");
runner.advance_until_stack_empty();

assert_eq!(
zone_of(&runner, p0_top),
Zone::Exile,
"the controller's top card is exiled"
);
assert_eq!(
zone_of(&runner, p1_top),
Zone::Exile,
"the non-empty opponent's top card is exiled"
);
// P2's empty library is a no-op: the game did not panic and resolution
// reached this assertion.
assert!(
runner
.state()
.players
.iter()
.find(|p| p.id == P2)
.expect("P2 present")
.library
.is_empty(),
"P2's library stays empty — nothing to exile, no error"
);
}

/// Find the `ExileTop` ability in a parsed card.
fn find_exile_top(
parsed: &engine::parser::oracle::ParsedAbilities,
) -> &engine::types::ability::AbilityDefinition {
parsed
.abilities
.iter()
.find(|a| matches!(&*a.effect, Effect::ExileTop { .. }))
.expect("an ExileTop ability must be present")
}

/// SHAPE (parser structure). Extract Power's verbatim Oracle text must parse to
/// the each-player fan-out shape: the ability carries `player_scope: All`, its
/// primary effect is `ExileTop { player: Controller, face_down: true }`, and its
/// sub-ability is the `CastFromZone` play permission.
///
/// Positive reach-guard: zero `Effect::Unimplemented` anywhere in the parse (so
/// the `player_scope` assertion can't pass vacuously on a parse failure) and the
/// `CastFromZone` sub is present.
///
/// DISCRIMINATING ASSERTION: `player_scope == Some(PlayerFilter::All)`. Reverting
/// either parser edit leaves it `None`.
///
/// Sibling non-regression: Lidless Gaze reaches Extract Power via the DIRECT
/// "exile the top card of each player's library" path (`parse_library_player_suffix`),
/// which these edits do not touch — it must still parse to the identical
/// `ExileTop { Controller }` + `player_scope: All` shape.
#[test]
fn extract_power_ability_carries_all_player_scope() {
let parsed = parse_oracle_text(
EXTRACT_POWER,
"Extract Power",
&[],
&["Sorcery".to_string()],
&[],
);
let dbg = format!("{parsed:#?}");
assert!(
!dbg.contains("Unimplemented"),
"Extract Power must parse with zero Unimplemented nodes; got:\n{dbg}"
);

let ability = find_exile_top(&parsed);
assert_eq!(
ability.player_scope,
Some(PlayerFilter::All),
"the each-player look-then-exile ability must carry player_scope: All;\n{dbg}"
);
match &*ability.effect {
Effect::ExileTop {
player, face_down, ..
} => {
assert_eq!(
*player,
TargetFilter::Controller,
"the lifted ExileTop rebinds the acting player to Controller (re-scoped per fan-out iteration)"
);
assert!(
*face_down,
"Extract Power exiles the cards face down (CR 406.3)"
);
}
other => panic!("expected ExileTop, got {other:?}"),
}
// The "you may play them ..." permission must survive as the sub-ability.
let sub = ability
.sub_ability
.as_deref()
.expect("the play permission must attach as a sub-ability");
assert!(
matches!(&*sub.effect, Effect::CastFromZone { .. }),
"the sub-ability must be the CastFromZone play permission; got {:?}",
sub.effect
);
Comment thread
coderabbitai[bot] marked this conversation as resolved.

// Sibling non-regression: the untouched direct-exile path is unchanged.
let lidless = parse_oracle_text(
"Exile the top card of each player's library. Until the end of your next turn, you may \
play those cards, and mana of any type can be spent to cast those spells.\nFlashback \
{2}{B}{R} (You may cast this card from your graveyard for its flashback cost. Then exile it.)",
"Lidless Gaze",
&[],
&["Sorcery".to_string()],
&[],
);
let lidless_dbg = format!("{lidless:#?}");
let lidless_exile = find_exile_top(&lidless);
assert_eq!(
lidless_exile.player_scope,
Some(PlayerFilter::All),
"the direct-exile sibling (Lidless Gaze) must still carry player_scope: All;\n{lidless_dbg}"
);
assert!(
matches!(
&*lidless_exile.effect,
Effect::ExileTop {
player: TargetFilter::Controller,
..
}
),
"the direct-exile sibling's ExileTop must remain player: Controller;\n{lidless_dbg}"
);
}
1 change: 1 addition & 0 deletions crates/engine/tests/integration/main.rs
Original file line number Diff line number Diff line change
Expand Up @@ -188,6 +188,7 @@ mod exhibition_tidecaller_target_player_mill;
mod exile_dynamic_from_top;
mod export_runtime_canaries;
mod exquisite_blood_routing;
mod extract_power_each_player_exile;
mod eyetwitch_learn_decline_lesson;
mod fact_or_fiction_pile_separation;
mod fantastic_four_bounded_loop;
Expand Down
Loading