Skip to content
Merged
Show file tree
Hide file tree
Changes from 5 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
6 changes: 4 additions & 2 deletions crates/engine/src/game/ability_rw.rs
Original file line number Diff line number Diff line change
Expand Up @@ -2825,7 +2825,8 @@ fn legacy_effect(x: &Effect) -> bool {
| Effect::ExileHaunting { target }
| Effect::HideawayConceal { target }
| Effect::ChooseCard { target, .. }
| Effect::Transform { target }
// CR 701.27a: both scopes write ObjectPt on the target/population filter.
| Effect::Transform { target, .. }
// CR 710.4: same single-target-slot shape as `Transform`.
| Effect::FlipPermanent { target }
| Effect::Shuffle { target }
Expand Down Expand Up @@ -4949,7 +4950,7 @@ fn rw_effect(
factor: _,
} => obj(StateKind::ObjectPt, target),
Effect::SwitchPT { target } => obj(StateKind::ObjectPt, target),
Effect::Transform { target } => obj(StateKind::ObjectPt, target),
Effect::Transform { target, .. } => obj(StateKind::ObjectPt, target),
// CR 710.1b: flipping replaces the permanent's power and toughness
// (along with its name, type line, and text box) — the same
// `ObjectPt` write axis `Transform` records.
Expand Down Expand Up @@ -7106,6 +7107,7 @@ mod tests {
let docent = cond(
ra(token(&["Creature", "Wizard"], qfix(1))).sub_ability(ra(Effect::Transform {
target: TargetFilter::SelfRef,
scope: crate::types::ability::EffectScope::Single,
})),
qcheck(obj_count(sub("Wizard")), 1),
);
Expand Down
81 changes: 69 additions & 12 deletions crates/engine/src/game/ability_scan.rs
Original file line number Diff line number Diff line change
Expand Up @@ -1044,7 +1044,7 @@ fn scan_effect(x: &Effect, mode: ScanMode) -> Axes {
acc = acc.or(scan_target_filter(target, target_ctx, mode));
acc
}
Effect::Transform { target } => {
Effect::Transform { target, .. } => {
let mut acc = Axes::NONE;
acc = acc.or(scan_target_filter(target, target_ctx, mode));
acc
Expand Down Expand Up @@ -5198,6 +5198,16 @@ fn effect_target_ctx(e: &Effect, mode: ScanMode) -> FilterReadContext {
// (missed offer, never a false certificate).
| Effect::Suspect { scope: EffectScope::All, .. }
| Effect::Unsuspect { scope: EffectScope::All, .. }
// CR 701.27a + CR 115.10a: mass Transform ("Transform all Humans", scope:All)
// is a non-targeting battlefield-population read (`target_filter()`==None;
// `transform_effect::resolve_all` enumerates `state.battlefield`, like
// DestroyAll) ⇒ census — its read SCALES with the growing class. Unlike the
// state-convergent SetTapState exception below, Transform WRITES ObjectPt and
// swaps the object's abilities, so a grown token is NOT inert and the read can
// escalate: `LiveBoardCensus`, never the Snapshot exception. scope:Single is a
// single announced/anaphoric target (a2), relaxed in the single-object group
// below. Exhaustive over EffectScope = {Single, All}.
| Effect::Transform { scope: EffectScope::All, .. }
// ── F1-CLASS DUAL-MODE MASS-BATTLEFIELD RESOLVERS (P3-B round-2): each has a
// resolver mode that, when the ability carries NO explicit object target,
// enumerates the battlefield (or all phased-in/-out permanents) and applies the
Expand Down Expand Up @@ -5337,8 +5347,11 @@ fn effect_target_ctx(e: &Effect, mode: ScanMode) -> FilterReadContext {
| Effect::Mana { .. }
| Effect::Discard { .. }
| Effect::Shuffle { .. }
| Effect::Transform { .. }
// CR 710.4: same single-target read context as `Transform`.
// CR 701.27a: only the scope:Single Transform relaxes — a single announced or
// anaphoric target (a2). scope:All is the mass battlefield read, census-tagged
// above with the DestroyAll/Suspect{All} group.
| Effect::Transform { scope: EffectScope::Single, .. }
// CR 710.4: same single-target read context as `Transform` (always self-ref).
| Effect::FlipPermanent { .. }
| Effect::SearchLibrary { .. }
| Effect::SearchOutsideGame { .. }
Expand Down Expand Up @@ -5540,7 +5553,7 @@ enum CensusRole {
#[cfg(test)]
fn effect_census_role(e: &Effect) -> CensusRole {
match e {
// -- CENSUS (29): verbatim mirror of `effect_target_ctx`'s LiveBoardCensus
// -- CENSUS (30): verbatim mirror of `effect_target_ctx`'s LiveBoardCensus
// arm - mass battlefield population reads that scale with growth.
Effect::EachSourceDealsDamage { .. }
| Effect::EachDealsDamageEqualToPower { .. }
Expand Down Expand Up @@ -5592,7 +5605,16 @@ fn effect_census_role(e: &Effect) -> CensusRole {
// token copy per matching attacker over an UNCONDITIONAL battlefield scan (grows
// the board); unsound across CR 508.1 multi-combat loops. Mirror of the new
// effect_target_ctx census member.
| Effect::CopyTokenBlockingAttacker { .. } => CensusRole::Census,
| Effect::CopyTokenBlockingAttacker { .. }
// CR 701.27a + CR 115.10a: mass Transform (scope:All) enumerates
// `state.battlefield` (`transform_effect::resolve_all`) — a census read that
// GROWS with the class. It WRITES ObjectPt + swaps abilities (NOT state-
// convergent like SetTapState), so it is a true `Census`, never the SetTapState
// relax exception. Parity with the effect_target_ctx LiveBoardCensus member.
| Effect::Transform {
scope: EffectScope::All,
..
} => CensusRole::Census,

// -- SetTapState (scope-DESTRUCTURED, exhaustive over EffectScope): scope:All is
// the census-ROLE proven exception (TapAll/UntapAll - state-convergent/idempotent,
Expand Down Expand Up @@ -5725,9 +5747,14 @@ fn effect_census_role(e: &Effect) -> CensusRole {
| Effect::Mana { .. }
| Effect::Discard { .. }
| Effect::Shuffle { .. }
| Effect::Transform { .. }
// CR 701.27a: scope:Single Transform reads only its single announced/anaphoric
// target — not a board census. scope:All is census-tagged above.
| Effect::Transform {
scope: EffectScope::Single,
..
}
// CR 710.4: a flip reads only its own self-referential target — not a
// board census, mirroring `Transform`.
// board census, mirroring `Transform`'s single scope.
| Effect::FlipPermanent { .. }
| Effect::TargetOnly { .. }
| Effect::Choose { .. }
Expand Down Expand Up @@ -6951,7 +6978,7 @@ mod tests {
}

/// guard#3 (mitigation #3): the `LiveBoardCensus` tag set of `effect_target_ctx`
/// == EXACTLY the enumeration-derived MASS-POPULATION set (29). Source-scanned, not
/// == EXACTLY the enumeration-derived MASS-POPULATION set (30). Source-scanned, not
/// hand-counted (the hand-count is what produced the earlier "relax=4" miss). Under
/// B's SnapshotOrEvent default this is the primary false-certificate gate: only a
/// census tag vetoes a mass read that ESCALATES over inert token growth (which
Expand Down Expand Up @@ -7007,6 +7034,11 @@ mod tests {
// `EffectScope::All`; the scope:Single arms live in the relax group below and
// are NOT scanned here (they sit past the census terminator).
"Suspect",
// CR 701.27a + CR 115.10a: mass Transform (scope:All) enumerates
// `state.battlefield` (`transform_effect::resolve_all`). Scope-gated on
// `EffectScope::All` in the census `|`-chain; the scope:Single arm sits past
// the census terminator in the relax group and is NOT scanned here.
"Transform",
"UnattachAll",
"Unsuspect",
// P3-B round-2: F1-class dual-mode mass-battlefield resolvers (a resolver
Expand All @@ -7027,7 +7059,7 @@ mod tests {
got, want,
"census tag set drifted from the enumeration-derived mass-population set"
);
assert_eq!(got.len(), 29, "exactly 29 mass-population census tags");
assert_eq!(got.len(), 30, "exactly 30 mass-population census tags");
}

/// A7' (mitigation #4, replaces the void census-default A7): with SnapshotOrEvent the
Expand Down Expand Up @@ -7138,7 +7170,7 @@ mod tests {
/// with `effect_target_ctx` on the Census/Relax boundary, closing the F1 gap where a
/// census-ROLE slot silently in the generic relax `|`-chain (exactly R1's Suspect{All})
/// is invisible to the census-arm-only guards. Structural: both functions' `Census`
/// name-sets are source-scanned and asserted IDENTICAL (== the 29). Behavioral: the
/// name-sets are source-scanned and asserted IDENTICAL (== the 30). Behavioral: the
/// two oracles agree on every discriminator, incl. BOTH Suspect/Unsuspect scopes.
///
/// REVERT-PROBE (discrimination proof): moving `Suspect{All}` out of the census arm of
Expand All @@ -7153,7 +7185,7 @@ mod tests {
use crate::types::ability::{EffectScope, TapStateChange};
use ScanMode::LoopFirewall;

// -- Structural: the two census name-sets are byte-identical (and == 29).
// -- Structural: the two census name-sets are byte-identical (and == 30).
fn census_names(fnsrc: &str, terminator: &str) -> Vec<String> {
let end = fnsrc.find(terminator).expect("census terminator");
let block = &fnsrc[..end];
Expand Down Expand Up @@ -7182,7 +7214,7 @@ mod tests {
etc_census, ecr_census,
"effect_census_role Census set diverged from effect_target_ctx"
);
assert_eq!(ecr_census.len(), 29, "exactly 29 census members");
assert_eq!(ecr_census.len(), 30, "exactly 30 census members");

// -- Behavioral: the two oracles agree on the Census/Relax boundary for every
// discriminator. `census(e, true)` requires BOTH `effect_census_role == Census`
Expand Down Expand Up @@ -7237,6 +7269,31 @@ mod tests {
census(&settap, false);
census(&Effect::HeistExile, false);
census(&Effect::NoOp, false);
// CR 701.27a + CR 115.10a: mass Transform is a battlefield census in BOTH oracles
// (scope:All), and a bounded single-target read (scope:Single) that relaxes. It is
// a true Census, NOT the SetTapState relax exception (ObjectPt/ability write).
census(
&Effect::Transform {
target: f(),
scope: EffectScope::All,
},
true,
);
census(
&Effect::Transform {
target: f(),
scope: EffectScope::Single,
},
false,
);
assert_eq!(
effect_census_role(&Effect::Transform {
target: f(),
scope: EffectScope::All,
}),
CensusRole::Census,
"mass Transform must be a true Census, not the SetTapState relax exception"
);

// -- Reason sub-tags reachable and correct (documentation-grade, unenforced by the
// Census/Relax boundary but proving each `RelaxReason` arm is live).
Expand Down
32 changes: 30 additions & 2 deletions crates/engine/src/game/ability_utils.rs
Original file line number Diff line number Diff line change
Expand Up @@ -2995,6 +2995,13 @@ fn mass_all_target_filter(effect: &Effect) -> Option<&TargetFilter> {
| Effect::BounceAll { target, .. }
| Effect::CounterAll { target, .. }
| Effect::ChangeZoneAll { target, .. }
// CR 701.27a + CR 115.10a: mass Transform's `target` is a resolution-time
// population scan (`target_filter()`==None), exactly like `TapAll`/`DestroyAll`.
| Effect::Transform {
scope: EffectScope::All,
target,
..
}
| Effect::DoublePTAll { target, .. } => Some(target),
_ => None,
}
Expand Down Expand Up @@ -3703,6 +3710,13 @@ fn effect_references_parent_target_combat_relation(effect: &Effect) -> bool {
| Effect::ChangeZoneAll { target, .. }
| Effect::DoublePTAll { target, .. }
| Effect::DamageAll { target, .. }
// CR 701.27a + CR 115.10a: parity with the other mass-population `target`
// filters — mass Transform's population filter is walked here too.
| Effect::Transform {
scope: EffectScope::All,
target,
..
}
| Effect::PutCounterAll { target, .. } => {
filter_references_parent_target_combat_relation(target)
}
Expand Down Expand Up @@ -3779,6 +3793,13 @@ fn effect_target_slot_filter(effect: &Effect) -> Option<TargetFilter> {
| Effect::BounceAll { target, .. }
| Effect::CounterAll { target, .. }
| Effect::ChangeZoneAll { target, .. }
// CR 701.27a + CR 115.10a: mass Transform's population filter is a
// resolution-time scan, walked here like the other mass-`All` effects.
| Effect::Transform {
scope: EffectScope::All,
target,
..
}
| Effect::DoublePTAll { target, .. } => filter_target_slot_filter(target),
_ => None,
}
Expand Down Expand Up @@ -4857,8 +4878,15 @@ fn concretize_granting_object_in_effect(effect: &mut Effect, granter: ObjectId)
| Effect::DealDamage { target, .. }
| Effect::Pump { target, .. }
| Effect::Counter { target, .. }
| Effect::Transform { target, .. }
// CR 710.4: same single-target-slot shape as `Transform`.
// CR 701.27a: only single-scope Transform carries a targetable slot that
// can bind a GrantingObject anaphor; the mass (`All`) scope's `target` is a
// population filter (mirrors the SetTapState Single-gate above).
| Effect::Transform {
scope: EffectScope::Single,
target,
..
}
// CR 710.4: same single-target-slot shape as `Transform`'s single scope.
| Effect::FlipPermanent { target, .. }
| Effect::Connive { target, .. }
| Effect::PhaseOut { target }
Expand Down
15 changes: 14 additions & 1 deletion crates/engine/src/game/coverage.rs
Original file line number Diff line number Diff line change
Expand Up @@ -2367,7 +2367,13 @@ fn effect_details(effect: &Effect) -> Vec<(String, String)> {
| Effect::PhaseOut { target }
| Effect::PhaseIn { target }
| Effect::ForceAttack { target, .. }
| Effect::Transform { target }
// CR 701.27a: single-scope Transform reports its `target` like other
// single-target effects; mass Transform (scope:All) reports a `filter` below.
| Effect::Transform {
scope: EffectScope::Single,
target,
..
}
// CR 710.4: the flipping permanent is the effect's single reported target.
| Effect::FlipPermanent { target }
| Effect::Shuffle { target }
Expand Down Expand Up @@ -2406,6 +2412,13 @@ fn effect_details(effect: &Effect) -> Vec<(String, String)> {
target,
..
}
// CR 701.27a + CR 115.10a: mass Transform ("Transform all Humans") reports its
// non-targeting population `filter`, like the other mass effects.
| Effect::Transform {
scope: EffectScope::All,
target,
..
}
| Effect::BounceAll { target, .. }
| Effect::CounterAll { target, .. }
| Effect::DamageAll {
Expand Down
12 changes: 10 additions & 2 deletions crates/engine/src/game/effects/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -6432,8 +6432,7 @@ fn extract_event_context_filter(effect: &Effect) -> Option<&TargetFilter> {
| Effect::Fight { target, .. }
| Effect::Attach { target, .. }
| Effect::UnattachAll { target, .. }
| Effect::Transform { target, .. }
// CR 710.4: same single-target-slot shape as `Transform`.
// CR 710.4: same single-target-slot shape as `Transform`'s single scope.
| Effect::FlipPermanent { target, .. }
| Effect::CopySpell { target, .. }
| Effect::CastCopyOfCard { target, .. }
Expand Down Expand Up @@ -6492,6 +6491,15 @@ fn extract_event_context_filter(effect: &Effect) -> Option<&TargetFilter> {
target,
..
} => target,
// CR 701.27a + CR 603.7c: only the single-scope Transform exposes an
// event-context target (e.g. an anaphoric trigger subject). The mass
// (`All`) scope's `target` is a population filter, not a per-event ref —
// it falls through to `None`, mirroring the SetTapState split above.
Effect::Transform {
scope: EffectScope::Single,
target,
..
} => target,
// CR 603.7c + CR 608.2c: `GenericEffect` carries an optional `target` that may
// be an event-context ref (e.g., `TriggeringSource` for "that land doesn't untap
// during its controller's next untap step" on a TapsForMana trigger). Routing it
Expand Down
4 changes: 3 additions & 1 deletion crates/engine/src/game/effects/token.rs
Original file line number Diff line number Diff line change
Expand Up @@ -2714,6 +2714,7 @@ fn incubator_ability() -> AbilityDefinition {
AbilityKind::Activated,
Effect::Transform {
target: TargetFilter::SelfRef,
scope: crate::types::ability::EffectScope::Single,
},
)
.cost(AbilityCost::Mana {
Expand Down Expand Up @@ -3996,7 +3997,8 @@ mod tests {
assert!(matches!(
*abilities[0].effect,
Effect::Transform {
target: TargetFilter::SelfRef
target: TargetFilter::SelfRef,
..
}
));
assert!(matches!(
Expand Down
Loading
Loading