From cadcc13f84b75cfef01d89ed6bf5721acbe30142 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 3 Jul 2026 08:06:25 +0000 Subject: [PATCH] parser: compose leading action with as-enters persisted choice (Anointed Peacekeeper ETB) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Implement Anointed Peacekeeper's previously-Unimplemented ETB line ("As ~ enters the battlefield, look at an opponent's hand, then choose any card name.") by composing existing engine primitives into four general building blocks — no new engine enum variant: 1. try_parse_named_choice: accept "any card name" alongside the existing a/nonland/creature/land card-name forms (CardName choice). 2. parse_hand_possessive_target: accept non-targeted "an opponent's hand" (controller-scoped Opponent), routing "look at an opponent's hand" to a private RevealHand { reveal: false }. 3. reveal_hand::resolve: additive player-from-filter fallback via collect_player_targets when no explicit player target slot exists (replacement/resolution context). Explicit TargetRef::Player fast path and Any-target MissingParam behavior unchanged. 4. parse_as_enters_choose: compose an optional leading action before the persisted Choose, anchoring on the last enters-frame; honest-coverage gate (returns None, leaving the line Unimplemented) if the leading clause does not parse, so no semantics are silently dropped. Lines 3 & 4 (cost taxes keyed to the chosen name) already consumed a persisted ChosenAttribute::CardName; this ETB now establishes it plus the private hand-look. CR annotations reused from existing in-repo citations (614.1c/d, 701.20a/e, 800.4a, 201.3, 607.2d, 613.1). Tests: parser unit tests (any-card-name accept + negatives; an-opponent's-hand private look + siblings), resolver tests (opponent-hand fallback with multi-authority hostile fixture, explicit-target fast path, Any->MissingParam), as-enters composition + honest-gate tests, and an end-to-end cast pipeline test. Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_019mjar7ovGZua3ANz9r76fk --- crates/engine/src/game/effects/reveal_hand.rs | 171 +++++++++++++++++- .../src/parser/oracle_effect/imperative.rs | 8 + crates/engine/src/parser/oracle_effect/mod.rs | 3 +- .../engine/src/parser/oracle_effect/tests.rs | 87 +++++++++ .../engine/src/parser/oracle_replacement.rs | 134 +++++++++++++- .../engine/tests/anointed_peacekeeper_etb.rs | 135 ++++++++++++++ 6 files changed, 530 insertions(+), 8 deletions(-) create mode 100644 crates/engine/tests/anointed_peacekeeper_etb.rs diff --git a/crates/engine/src/game/effects/reveal_hand.rs b/crates/engine/src/game/effects/reveal_hand.rs index 4885fcee42..16a63e2e9f 100644 --- a/crates/engine/src/game/effects/reveal_hand.rs +++ b/crates/engine/src/game/effects/reveal_hand.rs @@ -21,13 +21,14 @@ pub fn resolve( ability: &ResolvedAbility, events: &mut Vec, ) -> 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(), @@ -35,11 +36,21 @@ pub fn resolve( 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() @@ -47,6 +58,19 @@ pub fn resolve( 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 @@ -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; diff --git a/crates/engine/src/parser/oracle_effect/imperative.rs b/crates/engine/src/parser/oracle_effect/imperative.rs index 0b8672cad0..e99f5ee889 100644 --- a/crates/engine/src/parser/oracle_effect/imperative.rs +++ b/crates/engine/src/parser/oracle_effect/imperative.rs @@ -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( diff --git a/crates/engine/src/parser/oracle_effect/mod.rs b/crates/engine/src/parser/oracle_effect/mod.rs index c59eab0a39..eb09e4b05f 100644 --- a/crates/engine/src/parser/oracle_effect/mod.rs +++ b/crates/engine/src/parser/oracle_effect/mod.rs @@ -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). @@ -17752,6 +17752,7 @@ pub(crate) fn try_parse_named_choice(lower: &str) -> Option { 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"), diff --git a/crates/engine/src/parser/oracle_effect/tests.rs b/crates/engine/src/parser/oracle_effect/tests.rs index 54d337fe2c..855d9088fa 100644 --- a/crates/engine/src/parser/oracle_effect/tests.rs +++ b/crates/engine/src/parser/oracle_effect/tests.rs @@ -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( @@ -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 " 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!( diff --git a/crates/engine/src/parser/oracle_replacement.rs b/crates/engine/src/parser/oracle_replacement.rs index 65f6af3838..2b5b6a5ac3 100644 --- a/crates/engine/src/parser/oracle_replacement.rs +++ b/crates/engine/src/parser/oracle_replacement.rs @@ -11,7 +11,8 @@ use nom::Parser; use super::oracle_effect::become_copy_except::parse_except_clause; use super::oracle_effect::{ - parse_effect_chain, parse_effect_chain_with_context, try_parse_named_choice, + parse_effect_chain, parse_effect_chain_with_context, parse_effect_clause, + try_parse_named_choice, }; use super::oracle_ir::context::ParseContext; use super::oracle_ir::replacement::ReplacementIr; @@ -1654,7 +1655,7 @@ fn parse_as_enters_choose(norm_lower: &str, original_text: &str) -> Option>("choose ").parse(i) })?; let choice_type = try_parse_named_choice(choose_text)?; @@ -1668,6 +1669,42 @@ fn parse_as_enters_choose(norm_lower: &str, original_text: &str) -> Option enters[ the + // battlefield], " head AND any earlier enters-tapped sentence (Thriving + // Grove's first "enters"), leaving only the interposed instruction. + // Iterating `scan_preceded` retains the final frame match rather than + // `str::rfind` on raw text. All work is on `norm_lower` (lowercased) — its + // byte offsets do NOT map to `original_text` because `~` substitution + // changed the length, so the extracted slice is fed to `parse_effect_clause` + // (which accepts lowercased text) rather than recovered from the original. + fn frame(i: &str) -> nom::IResult<&str, &str, OracleError<'_>> { + alt(( + tag::<_, _, OracleError<'_>>("enters the battlefield, "), + tag("enters, "), + )) + .parse(i) + } + let mut middle_lower = ""; + let mut cursor = prefix_lower; + while let Some((_, _, rest)) = nom_primitives::scan_preceded(cursor, frame) { + middle_lower = rest; + cursor = rest; + } + let middle_lower = middle_lower.trim(); + // Structural trailing-connector cleanup (not parsing dispatch): "..., then" / + // "... then" / "...," → bare instruction. `strip_suffix` is sanctioned for + // structural, non-dispatch string normalization. + let middle_lower = middle_lower + .strip_suffix(", then") + .or_else(|| middle_lower.strip_suffix(" then")) + .or_else(|| middle_lower.strip_suffix(',')) + .unwrap_or(middle_lower) + .trim(); + // CR 614.1c + CR 614.1d: The Thriving land cycle ("This land enters tapped. // As it enters, choose a color other than .") layers TWO replacement // effects on the same entry event — the enters-tapped modifier AND the @@ -1684,6 +1721,35 @@ fn parse_as_enters_choose(norm_lower: &str, original_text: &str) -> Option Option= 2, + "both cost-increase statics (spell tax + activated tax) must be present, got {}", + pk.static_definitions.len() + ); +}