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
171 changes: 168 additions & 3 deletions crates/engine/src/game/effects/reveal_hand.rs
Original file line number Diff line number Diff line change
Expand Up @@ -21,32 +21,56 @@ pub fn resolve(
ability: &ResolvedAbility,
events: &mut Vec<GameEvent>,
) -> Result<(), EffectError> {
let (card_filter, count, random, choice_optional, is_reveal) = match &ability.effect {
let (card_filter, count, random, choice_optional, is_reveal, target) = match &ability.effect {
Effect::RevealHand {
card_filter,
count,
selection,
choice_optional,
reveal,
target,
..
} => (
card_filter.clone(),
count.clone(),
selection.is_random(),
*choice_optional,
*reveal,
target.clone(),
),
_ => (
TargetFilter::Any,
None,
false,
false,
true,
TargetFilter::Any,
),
_ => (TargetFilter::Any, None, false, false, true),
};

// Find the target player from resolved targets
// Find the target player from resolved targets. Targeted RevealHand
// (Thoughtseize, Duress) builds a real `TargetRef::Player` slot — keep that
// fast path unchanged (zero regression).
let target_player = ability
.targets
.iter()
.find_map(|t| match t {
TargetRef::Player(pid) => Some(*pid),
_ => None,
})
// CR 800.4a: "an opponent" is a controller choice; resolving the first
// opponent is exact in two-player and a known multiplayer simplification
// (no interactive opponent choice yet). Only reached for non-targeted
// LookAt shapes (e.g. Anointed Peacekeeper's "look at an opponent's
// hand") that carry a `Controller`/`Typed` player filter rather than an
// explicit player target slot. `TargetFilter::Any` (RevealAll/
// RevealPartial) hits `collect_player_targets`' empty arm → still
// `MissingParam`, so those reveals are unchanged.
.or_else(|| {
crate::game::ability_utils::collect_player_targets(state, ability, &target)
.first()
.copied()
})
.ok_or(EffectError::MissingParam("target player".to_string()))?;

let full_hand: Vec<_> = state
Expand Down Expand Up @@ -480,6 +504,147 @@ mod tests {
);
}

/// CR 701.20e + CR 800.4a: A non-targeted "look at an opponent's hand"
/// (Anointed Peacekeeper) carries a controller-scoped `Typed(Opponent)`
/// target and an Object source slot (no explicit player target). The
/// resolver must fall back to `collect_player_targets` and privately look at
/// the OPPONENT's hand — never the controller's, even when both are
/// non-empty (multi-authority hostile fixture). Reverting the `.or_else`
/// fallback makes this return `MissingParam` and the assertions below fail.
#[test]
fn look_at_an_opponents_hand_falls_back_to_opponent_controller_choice() {
use crate::types::ability::{ControllerRef, TypedFilter};

let mut state = GameState::new_two_player(42);
// Controller (PlayerId(0)) also holds a card — must NOT be looked at.
let own_card = create_object(
&mut state,
CardId(10),
PlayerId(0),
"My Secret".to_string(),
Zone::Hand,
);
let opp_card = create_object(
&mut state,
CardId(1),
PlayerId(1),
"Their Secret".to_string(),
Zone::Hand,
);

let ability = ResolvedAbility::new(
Effect::RevealHand {
target: TargetFilter::Typed(
TypedFilter::default().controller(ControllerRef::Opponent),
),
card_filter: TargetFilter::None,
count: None,
selection: crate::types::ability::CardSelectionMode::Chosen,
choice_optional: false,
reveal: false,
},
// Source object slot only — no explicit player target.
vec![TargetRef::Object(ObjectId(100))],
ObjectId(100),
PlayerId(0),
);
let mut events = Vec::new();
resolve(&mut state, &ability, &mut events).expect("opponent-hand look should resolve");

assert_eq!(
state.private_look_ids,
vec![opp_card],
"must privately look at the opponent's hand"
);
assert!(
!state.private_look_ids.contains(&own_card),
"must never look at the controller's own hand"
);
assert_eq!(
state.private_look_player,
Some(PlayerId(0)),
"the looker is the ability controller"
);
}

/// An explicit `TargetRef::Player` slot (Thoughtseize-style) still wins the
/// fast path even when the effect also carries a `Typed(Opponent)` filter
/// that would resolve to a different player — zero regression for targeted
/// RevealHand.
#[test]
fn explicit_player_target_wins_over_controller_filter() {
use crate::types::ability::{ControllerRef, TypedFilter};

let mut state = GameState::new_two_player(42);
let controller_card = create_object(
&mut state,
CardId(1),
PlayerId(0),
"Controller Card".to_string(),
Zone::Hand,
);
create_object(
&mut state,
CardId(2),
PlayerId(1),
"Opponent Card".to_string(),
Zone::Hand,
);

let ability = ResolvedAbility::new(
Effect::RevealHand {
// Filter would resolve to the opponent (PlayerId(1))…
target: TargetFilter::Typed(
TypedFilter::default().controller(ControllerRef::Opponent),
),
card_filter: TargetFilter::None,
count: None,
selection: crate::types::ability::CardSelectionMode::Chosen,
choice_optional: false,
reveal: false,
},
// …but the explicit player target is the controller itself.
vec![TargetRef::Player(PlayerId(0))],
ObjectId(100),
PlayerId(0),
);
let mut events = Vec::new();
resolve(&mut state, &ability, &mut events).unwrap();

assert_eq!(
state.private_look_ids,
vec![controller_card],
"explicit player target must win over the filter fallback"
);
}

/// R2: `TargetFilter::Any` (RevealAll/RevealPartial shapes) with no explicit
/// player target hits `collect_player_targets`' empty arm, so the resolver
/// still errors `MissingParam` — the fallback is strictly additive.
#[test]
fn any_target_without_player_slot_still_missing_param() {
let mut state = GameState::new_two_player(42);
let ability = ResolvedAbility::new(
Effect::RevealHand {
target: TargetFilter::Any,
card_filter: TargetFilter::Any,
count: None,
selection: crate::types::ability::CardSelectionMode::Chosen,
choice_optional: false,
reveal: true,
},
vec![TargetRef::Object(ObjectId(100))],
ObjectId(100),
PlayerId(0),
);
let mut events = Vec::new();
let result = resolve(&mut state, &ability, &mut events);
assert!(
matches!(result, Err(EffectError::MissingParam(_))),
"Any-target reveal without a player slot must still be MissingParam, got {result:?}"
);
}

#[test]
fn optional_reveal_hand_choice_decline_skips_continuation() {
use crate::game::engine_resolution_choices::handle_resolution_choice;
Expand Down
8 changes: 8 additions & 0 deletions crates/engine/src/parser/oracle_effect/imperative.rs
Original file line number Diff line number Diff line change
Expand Up @@ -3138,6 +3138,14 @@ fn parse_hand_possessive_target(input: &str) -> nom::IResult<&str, TargetFilter,
TargetFilter::Typed(TypedFilter::default().controller(ControllerRef::Opponent)),
tag("target opponent's hand"),
),
// "look at an opponent's hand" (Anointed Peacekeeper ETB) — a
// non-targeted controller choice. Ordered AFTER the longer "target …"
// tags so those still win. Routes to `parse_hand_reveal_ast` LookAt →
// `Effect::RevealHand { reveal: false, target: Typed(Opponent) }`.
value(
TargetFilter::Typed(TypedFilter::default().controller(ControllerRef::Opponent)),
tag("an opponent's hand"),
),
value(TargetFilter::TriggeringPlayer, tag("that player's hand")),
value(TargetFilter::TriggeringPlayer, tag("their hand")),
value(
Expand Down
3 changes: 2 additions & 1 deletion crates/engine/src/parser/oracle_effect/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -4877,7 +4877,7 @@ fn attach_unless_slots(
}

#[tracing::instrument(level = "debug")]
fn parse_effect_clause(text: &str, ctx: &mut ParseContext) -> ParsedEffectClause {
pub(crate) fn parse_effect_clause(text: &str, ctx: &mut ParseContext) -> ParsedEffectClause {
// CR 608.2c: "do X unless [game state]" — strip trailing unless suffix and
// attach the negated gate before body parsing (payment-unless uses
// `unless_pay` / `extract_resolution_unless_pay_modifier` instead).
Expand Down Expand Up @@ -17752,6 +17752,7 @@ pub(crate) fn try_parse_named_choice(lower: &str) -> Option<ChoiceType> {
Some(ChoiceType::card_type())
} else if alt((
tag::<_, _, E>("a card name"),
tag("any card name"),
tag("a nonland card name"),
tag("a creature card name"),
tag("a land card name"),
Expand Down
87 changes: 87 additions & 0 deletions crates/engine/src/parser/oracle_effect/tests.rs
Original file line number Diff line number Diff line change
Expand Up @@ -18635,6 +18635,55 @@ fn parse_reveal_a_card_from_target_opponents_hand_preserves_hand_owner() {
assert_eq!(*card_filter, TargetFilter::Any);
}

// CR 701.20e + CR 800.4a: "Look at an opponent's hand" (Anointed Peacekeeper
// ETB) is a private look at a non-targeted opponent — reveal:false, no card
// filter, controller-scoped `Typed(Opponent)` target. The new possessive arm
// must not perturb the existing "target opponent's hand" / "target player's
// hand" / "your hand" look siblings.
#[test]
fn parse_look_at_an_opponents_hand_is_private_opponent_look() {
use crate::types::ability::ControllerRef;

let def = parse_effect_chain("Look at an opponent's hand.", AbilityKind::Spell);
let Effect::RevealHand {
target,
reveal,
card_filter,
..
} = &*def.effect
else {
panic!("Expected RevealHand, got {:?}", def.effect);
};
assert!(!*reveal, "look at is a private look, not a public reveal");
assert!(
matches!(target, TargetFilter::Typed(tf) if tf.controller == Some(ControllerRef::Opponent)),
"expected controller-scoped Opponent target, got {target:?}"
);
assert_eq!(*card_filter, TargetFilter::None);

let look_target = |t: &str| {
let d = parse_effect_chain(t, AbilityKind::Spell);
let Effect::RevealHand { target, .. } = &*d.effect else {
panic!("Expected RevealHand for {t:?}, got {:?}", d.effect);
};
target.clone()
};
assert!(
matches!(look_target("Look at target opponent's hand."), TargetFilter::Typed(tf) if tf.controller == Some(ControllerRef::Opponent)),
"targeted opponent look must stay Typed(Opponent)"
);
assert_eq!(
look_target("Look at target player's hand."),
TargetFilter::Player,
"targeted player look unchanged"
);
assert_eq!(
look_target("Look at your hand."),
TargetFilter::Controller,
"self look unchanged"
);
}

#[test]
fn parse_eladamri_hand_mode_reveal_sets_any_card_filter() {
let def = parse_effect_chain(
Expand Down Expand Up @@ -30339,6 +30388,44 @@ fn named_choice_accepts_land_card_name() {
);
}

// CR 201.3: "choose any card name" (Anointed Peacekeeper) is the same CardName
// choice as the "a card name" forms — the alt-list must accept the "any"
// determiner. Regression: the existing "a card name" / "a nonland card name"
// forms still resolve. Negative: a "card <X>" phrase that is NOT a name (a
// bare card, a card type) must NOT collapse to CardName, and the new "any"
// determiner must not bleed into other "any …" phrases — "any number" is not
// a card-name choice (the number arms require "a number").
#[test]
fn named_choice_accepts_any_card_name() {
assert_eq!(
super::try_parse_named_choice("choose any card name."),
Some(ChoiceType::CardName)
);
assert_eq!(
super::try_parse_named_choice("choose a card name"),
Some(ChoiceType::CardName)
);
assert_eq!(
super::try_parse_named_choice("choose a nonland card name"),
Some(ChoiceType::CardName)
);
// Negatives: no "name" head → not a CardName choice.
assert_ne!(
super::try_parse_named_choice("choose any card"),
Some(ChoiceType::CardName)
);
assert_ne!(
super::try_parse_named_choice("choose any card type"),
Some(ChoiceType::CardName)
);
// The new "any" determiner arm must not swallow other "any …" choices — a
// number choice must not collapse into CardName.
assert_ne!(
super::try_parse_named_choice("choose any number"),
Some(ChoiceType::CardName)
);
}

#[test]
fn named_choice_accepts_secretly_choose_land_or_nonland() {
assert_eq!(
Expand Down
Loading