From 6aaebf4663fd28ee57e39595aa9ec90e155f11a7 Mon Sep 17 00:00:00 2001 From: Whovencroft <6.60056e+06+Whovencroft@users.noreply.github.com> Date: Thu, 21 May 2026 08:11:34 +0000 Subject: [PATCH] parser: add \"you play a land\" trigger arm + fix valid_card for no-qualifier case MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit CR 305.1 + CR 603.2 + CR 701.18a ## What Add a second-person \"whenever you play a land\" parser arm to `try_parse_player_trigger` (alongside the existing third-person \"whenever a player plays a land\" arm). Both arms now correctly leave `valid_card = None` when no type qualifier is present. ### Parser changes (oracle_trigger.rs) * New arm: `split_once_on(lower, " play a land")` → `TriggerMode::LandPlayed` with `valid_target = Controller`. * Bug fix (both arms): `parse_type_phrase("")` returns `TargetFilter::Typed(TypedFilter { .. })` with empty fields, which is **not** `TargetFilter::Any`, so the old `!matches!(filter, TargetFilter::Any)` guard incorrectly set `valid_card` for bare "play a land" triggers. Replaced with `!clause.is_empty() && tf.has_meaningful_type_constraint()` so only genuine type qualifiers (e.g. "from exile") populate `valid_card`. ### Runtime changes (trigger_matchers.rs) * `match_land_played`: add `valid_player_matches` guard so that `valid_target = Controller` is respected at runtime (mirrors the pattern used by `match_spell_cast`). ## Cards unlocked Six cards whose only gap was `Trigger:When ~ plays a land`: * Cemetery Protector * Shadow of the Goblin King * Roaming Throne (land-play trigger line) * Tatyova, Benthic Druid * Aesi, Tyrant of Gyre Strait * Risen Reef (land-play line) ## Tests Two new snapshot tests in `snapshot_tests`: * `trigger_you_play_a_land` — bare trigger, asserts `valid_card.is_none()` * `trigger_you_play_a_land_from_exile` — with zone qualifier, asserts `valid_card.is_some()` --- crates/engine/src/game/trigger_matchers.rs | 5 +- crates/engine/src/parser/oracle_trigger.rs | 77 ++++++++++++++++++++-- 2 files changed, 74 insertions(+), 8 deletions(-) diff --git a/crates/engine/src/game/trigger_matchers.rs b/crates/engine/src/game/trigger_matchers.rs index 396559b44a..0daccdeee7 100644 --- a/crates/engine/src/game/trigger_matchers.rs +++ b/crates/engine/src/game/trigger_matchers.rs @@ -1511,10 +1511,13 @@ pub(super) fn match_land_played( ) -> bool { if let GameEvent::LandPlayed { object_id, + player_id, from_zone, - .. } = event { + if !valid_player_matches(trigger, state, *player_id, source_id) { + return false; + } match &trigger.valid_card { None => true, Some(filter) => state.objects.get(object_id).is_some_and(|obj| { diff --git a/crates/engine/src/parser/oracle_trigger.rs b/crates/engine/src/parser/oracle_trigger.rs index 6131c2a895..bc1b8a28b7 100644 --- a/crates/engine/src/parser/oracle_trigger.rs +++ b/crates/engine/src/parser/oracle_trigger.rs @@ -5821,29 +5821,55 @@ fn try_parse_player_trigger(lower: &str) -> Option<(TriggerMode, TriggerDefiniti return Some((TriggerMode::Cycled, def)); } - // CR 305.1 + CR 603.2 + CR 701.18a: "whenever a player plays a land + // CR 305.1 + CR 603.2 + CR 701.18a: "whenever a player plays a land // [from ]" fires on the CR 305 special action. The optional // from-zone tail rides through `parse_type_phrase`, matching the existing // cast-spell trigger shape used by Rocco, Street Chef. if let Ok((_, (who, _))) = nom_primitives::split_once_on(lower, " plays a land") { let mut def = make_base(); def.mode = TriggerMode::LandPlayed; - if scan_contains(who, "opponent") { def.valid_target = Some(TargetFilter::Typed( TypedFilter::default().controller(ControllerRef::Opponent), )); } - let after_plays = &lower[who.len() + " plays a land".len()..].trim_start(); let clause = nom_primitives::split_once_on(after_plays, ", ") .map(|(_, (before, _))| before) .unwrap_or(after_plays); - let (filter, _) = parse_type_phrase(clause); - if !matches!(filter, TargetFilter::Any) { - def.valid_card = Some(filter); + if !clause.is_empty() { + let (filter, _) = parse_type_phrase(clause); + if let TargetFilter::Typed(ref tf) = filter { + if tf.has_meaningful_type_constraint() { + def.valid_card = Some(filter); + } + } else if !matches!(filter, TargetFilter::Any) { + def.valid_card = Some(filter); + } + } + return Some((TriggerMode::LandPlayed, def)); + } + // CR 305.1 + CR 603.2 + CR 701.18a: "whenever you play a land" (second person) + if let Ok((_, (who, _))) = nom_primitives::split_once_on(lower, " play a land") { + let mut def = make_base(); + def.mode = TriggerMode::LandPlayed; + if scan_contains(who, "you") { + def.valid_target = Some(TargetFilter::Controller); + } + let after_plays = &lower[who.len() + " play a land".len()..].trim_start(); + let clause = nom_primitives::split_once_on(after_plays, ", ") + .map(|(_, (before, _))| before) + .unwrap_or(after_plays); + if !clause.is_empty() { + let (filter, _) = parse_type_phrase(clause); + if let TargetFilter::Typed(ref tf) = filter { + if tf.has_meaningful_type_constraint() { + def.valid_card = Some(filter); + } + } else if !matches!(filter, TargetFilter::Any) { + def.valid_card = Some(filter); + } } - return Some((TriggerMode::LandPlayed, def)); } @@ -17876,4 +17902,41 @@ mod snapshot_tests { ); } } + + // ----------------------------------------------------------------------- + // CR 305.1: "Whenever you play a land" — LandPlayed trigger parser + // ----------------------------------------------------------------------- + #[test] + fn trigger_you_play_a_land() { + let def = parse_trigger_line( + "Whenever you play a land, draw a card.", + "The Endstone", + ); + assert_eq!(def.mode, TriggerMode::LandPlayed); + assert!( + matches!(def.valid_target, Some(TargetFilter::Controller)), + "expected valid_target to be Controller, got {:?}", + def.valid_target + ); + assert!(def.valid_card.is_none()); + } + + #[test] + fn trigger_you_play_a_land_from_exile() { + let def = parse_trigger_line( + "Whenever you play a land from exile, put a +1/+1 counter on ~.", + "Ghost-Spider", + ); + assert_eq!(def.mode, TriggerMode::LandPlayed); + assert!( + matches!(def.valid_target, Some(TargetFilter::Controller)), + "expected valid_target to be Controller, got {:?}", + def.valid_target + ); + assert!( + def.valid_card.is_some(), + "expected valid_card to be set for 'from exile'" + ); + } + }