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
22 changes: 22 additions & 0 deletions crates/engine/src/game/triggers_ordering_parity_tests.rs
Original file line number Diff line number Diff line change
Expand Up @@ -210,6 +210,28 @@ const DOCUMENTED_OVER_PROMPT: &[&str] = &[
// MISaligned copies go to a different graveyard and are correctly prompted by
// the same conflict — see the impl-report soundness note.)
"skyfisher spider",
// ---- unprofiled-effect commute, batch: the effect node has no RW profile, so
// the fail-closed `RwProfile::conservative()` catch-all prompts ----
// dies-batch optional PutOnTopOrBottom{SelfRef, chooser: Controller} (AST
// measured): each co-departing copy moves ONLY its own graveyard card (CR 603.6c
// first-zone check) to its OWN owner's library, so the members' writes are
// disjoint — owner-misaligned copies route to different libraries entirely, and
// same-owner copies contend only for the top/bottom slot, where identical
// same-name cards commute up to relabeling. The "may" and the top-or-bottom pick
// are resolution-time choices (CR 603.5) made by the batch's one controller in
// either order: production ordering groups are partitioned per-controller
// (`trigger_order_controller` / `begin_trigger_ordering`, triggers.rs; CR 603.3b
// — each player orders only the triggers THEY control, cross-controller
// placement being APNAP-fixed, not chosen), the premise the §5 batch rows model
// as `ControllerUniformity::Uniform` — single-controller BY CONSTRUCTION, not an
// artifact of the Phase+OnlyDuringYourTurn privacy predicate (same-event S2
// only). The prompt is conservative: `PutOnTopOrBottom` is unprofiled — it
// lands in `RwProfile::conservative()` (maximal reads/writes + fail-closed
// `reads_member_bound`), refusing batch-T1 and tripping the feed rows, so no
// in-scope recognizer proves the self-scoped move context-free (Valakut
// Exploration misparse-fix fixture regen surfaced this card in the sweep
// corpus; same adjudication as the #7031 branch).
"arashin sovereign",
];

/// Batch-depth GENUINE order-dependence (kept SEPARATE from the same-event
Expand Down
45 changes: 43 additions & 2 deletions crates/engine/src/parser/oracle_effect/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -184,6 +184,14 @@ pub(crate) fn is_bare_object_pronoun(text: &str) -> bool {
)
}

/// CR 608.2c (rules of English): the plural subset of the bare object-pronoun
/// family. A plural antecedent (a set-valued noun phrase) binds these and
/// only these — a singular "it" must never be captured by a plural pool
/// (see `ParseContext::plural_object_pronoun_ref`).
pub(crate) fn is_bare_plural_object_pronoun(text: &str) -> bool {
matches!(text, "them" | "themselves")
}

/// CR 608.2c anaphora: substitute `replacement` for the FIRST bare object
/// pronoun word ("it"/"them"/…) in `body`, leaving any later pronouns intact so
/// a downstream "and it gains …" still chains to the now-declared target via
Expand Down Expand Up @@ -31545,6 +31553,12 @@ pub(crate) fn parse_effect_chain_ir(
// self-reference so a `"that creature"` copy-token anaphor in any
// chunk of an Aura/bestow card remaps to the enchanted host.
host_self_reference: ctx.host_self_reference.clone(),
// CR 608.2c + CR 608.2k + CR 406.6: the plural-anaphor antecedent
// introduced by the trigger's intervening-if ("if there are cards
// exiled with ~") is a property of the whole trigger body, not of
// an individual chunk — the "put THEM …" sweep chunk needs it to
// bind the bare plural pronoun to the linked-exile pool.
plural_object_pronoun_ref: ctx.plural_object_pronoun_ref.clone(),
// CR 608.2k: propagate the enclosing ability's exile-cost source
// zone so a `"the exiled card"` anaphor in any effect chunk
// disambiguates to `CostPaidObject` (Jhoira of the Ghitu).
Expand Down Expand Up @@ -33352,7 +33366,28 @@ fn try_parse_put_zone_change_parts(
(false, target_text)
};
let up_to = parse_up_to_one_target_prefix(before.lower) || choice_count.is_some();
let (target, _) = parse_target(target_text);
// CR 608.2c + CR 608.2k + CR 406.6 + CR 607.2a: a bare plural
// anaphor whose antecedent is the trigger's linked-exile pool
// ("if there are cards exiled with ~, put THEM into their owner's
// graveyard") is a mandatory sweep of the whole pool — a mass move
// (Bomat Courier's `ChangeZoneAll` shape, emitted through the
// existing mass branch below), never the single-object
// resolution-choice path. When `plural_object_pronoun_ref` is
// `None` — every card outside the class — behavior is unchanged
// (the ctx-free `parse_target` fallback below).
let plural_pool = if is_bare_plural_object_pronoun(&target_text.to_ascii_lowercase()) {
ctx.plural_object_pronoun_ref
.clone()
.filter(|pool| matches!(pool, TargetFilter::ExiledBySource))
} else {
None
};
let pool_bound = plural_pool.is_some();
let is_mass = is_mass || pool_bound;
let target = match plural_pool {
Some(pool) => pool,
None => parse_target(target_text).0,
};
let multi_origin_zones = put_hand_graveyard_origin_zones(before.lower);
let target = match multi_origin_zones.as_ref() {
Some(zones) => add_put_multi_origin_constraint(target, zones),
Expand Down Expand Up @@ -33445,7 +33480,13 @@ fn try_parse_put_zone_change_parts(
// graveyard"); "into your graveyard" then yields `origin: None`,
// matching the hand branch and letting the injected reveal target
// drive the move uniformly across the whole destination class.
let origin = if is_tracked_anaphor || multi_origin_zones.is_some() {
let origin = if pool_bound {
// CR 607.2a: the linked pool's members are in exile by
// definition, so the sweep scans Exile (Bomat Courier's proven
// `origin: Some(Exile)` shape — `resolve_all` with
// `origin: None` would fall back to a Battlefield scan).
Some(Zone::Exile)
} else if is_tracked_anaphor || multi_origin_zones.is_some() {
None
} else {
let origin_text = format!("{}{}", before.lower, after.lower);
Expand Down
10 changes: 10 additions & 0 deletions crates/engine/src/parser/oracle_ir/context.rs
Original file line number Diff line number Diff line change
Expand Up @@ -73,6 +73,16 @@ pub(crate) struct ParseContext {
/// set this to `TriggeringSource` so "Whenever you cast a spell, put it ..."
/// moves the spell on the stack, not the trigger source or a parent target.
pub object_pronoun_ref: Option<TargetFilter>,
/// CR 608.2c (rules of English — number agreement) + CR 608.2k + CR 406.6:
/// Antecedent for bare PLURAL object pronouns ("them"/"themselves") in the
/// current trigger body, introduced by a plural noun phrase in the trigger's
/// intervening-if ("if there are cards exiled with ~, put THEM …" → the
/// linked-exile pool). Deliberately separate from the singular
/// `object_pronoun_ref`: a plural antecedent must never capture a singular
/// "it", whose antecedent is the nearer chained object (River Song's Diary:
/// "choose one of them at random. You may cast IT" — "it" is the chosen card,
/// not the pool).
pub plural_object_pronoun_ref: Option<TargetFilter>,
/// Accumulated diagnostics for the current card parse (Phase 52, D-07).
/// Replaces thread-local oracle_warnings accumulator.
pub diagnostics: Vec<OracleDiagnostic>,
Expand Down
19 changes: 19 additions & 0 deletions crates/engine/src/parser/oracle_ir/snapshot_tests.rs
Original file line number Diff line number Diff line change
Expand Up @@ -2334,6 +2334,25 @@ fn edgewall_innkeeper() {
insta::assert_json_snapshot!("edgewall_innkeeper_lowered", &lowered);
}

// ---------------------------------------------------------------------------
// Valakut Exploration (existential exiled-with intervening-if + plural-pool
// sweep + chained "that much" damage — CR 603.4 + CR 406.6 + CR 607.2a +
// CR 608.2c/608.2k)
// ---------------------------------------------------------------------------

#[test]
fn valakut_exploration() {
let (ir, lowered) = parse_two_layer_with_keywords(
"Landfall — Whenever a land you control enters, exile the top card of your library. You may play that card for as long as it remains exiled.\nAt the beginning of your end step, if there are cards exiled with this enchantment, put them into their owner's graveyard, then this enchantment deals that much damage to each opponent.",
"Valakut Exploration",
&["Landfall"],
&["Enchantment"],
&[],
);
insta::assert_json_snapshot!("valakut_exploration_ir", &ir);
insta::assert_json_snapshot!("valakut_exploration_lowered", &lowered);
}

// ---------------------------------------------------------------------------
// Bomat Courier (exile + activated with complex costs)
// ---------------------------------------------------------------------------
Expand Down
Loading
Loading