Skip to content
Merged
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
2 changes: 2 additions & 0 deletions crates/engine/src/ai_support/filter.rs
Original file line number Diff line number Diff line change
Expand Up @@ -728,6 +728,8 @@ fn filterprop_reads_only_candidate_fp(p: &FilterProp) -> bool {
| FilterProp::PowerExceedsBase
| FilterProp::Suspected
| FilterProp::Renowned
// CR 701.15b/c: reads only the candidate's own `goaded_by` fingerprint field.
| FilterProp::Goaded
| FilterProp::Modified
| FilterProp::Historic
| FilterProp::NotHistoric
Expand Down
6 changes: 6 additions & 0 deletions crates/engine/src/game/ability_rw.rs
Original file line number Diff line number Diff line change
Expand Up @@ -2363,6 +2363,9 @@ fn legacy_filter_prop(p: &FilterProp) -> bool {
| FilterProp::NotSupertype { .. }
| FilterProp::Suspected
| FilterProp::Renowned
// CR 701.15b/c: goad is a candidate-local designation, not a legacy
// event-context or per-source member-bound referent.
| FilterProp::Goaded
| FilterProp::ToughnessGTPower
| FilterProp::PowerExceedsBase
| FilterProp::InAnyZone { .. }
Expand Down Expand Up @@ -2624,6 +2627,9 @@ fn member_bound_filter_prop(p: &FilterProp) -> bool {
| FilterProp::NotSupertype { .. }
| FilterProp::Suspected
| FilterProp::Renowned
// CR 701.15b/c: goad is a candidate-local designation, not a legacy
// event-context or per-source member-bound referent.
| FilterProp::Goaded
| FilterProp::ToughnessGTPower
| FilterProp::PowerExceedsBase
| FilterProp::InAnyZone { .. }
Expand Down
3 changes: 3 additions & 0 deletions crates/engine/src/game/ability_scan.rs
Original file line number Diff line number Diff line change
Expand Up @@ -3164,6 +3164,9 @@ fn scan_filter_prop(x: &FilterProp) -> Axes {
| FilterProp::NotSupertype { .. }
| FilterProp::Suspected
| FilterProp::Renowned
// CR 701.15b/c: goad is a candidate-local designation read; it scans no
// board/object axis.
| FilterProp::Goaded
| FilterProp::ToughnessGTPower
| FilterProp::PowerExceedsBase
| FilterProp::InTrackedSet { .. }
Expand Down
109 changes: 109 additions & 0 deletions crates/engine/src/game/coverage.rs
Original file line number Diff line number Diff line change
Expand Up @@ -899,6 +899,8 @@ fn fmt_typed_filter(tf: &TypedFilter) -> String {
}
FilterProp::Suspected => parts.push("suspected".into()),
FilterProp::Renowned => parts.push("renowned".into()),
// CR 701.15b/c
FilterProp::Goaded => parts.push("goaded".into()),
// CR 700.9
FilterProp::Modified => parts.push("modified".into()),
// CR 700.6
Expand Down Expand Up @@ -3603,6 +3605,34 @@ fn ability_details(def: &AbilityDefinition) -> Vec<(String, String)> {
if let Some(dur) = &def.duration {
d.push(("duration".into(), fmt_duration(dur)));
}
// CR 608.2c: a lifted "[once] for each ⟨set⟩" repeat multiplier is an
// `AbilityDefinition` field. Surface it in the per-card parse-diff signature ONLY
// for the shapes THIS PR's lift produces — a fieldless `Effect::Investigate` whose
// `repeat_for` is a member-count `QuantityRef` (`PlayerCount`/`ObjectCount`), i.e.
// exactly the eligibility set of `for_each_repeatable_repeat_for`
// (parser/oracle_effect/mod.rs). Projecting the *whole* repeat_for surface
// (CopySpell/Token/Proliferate/… and pre-existing `Fixed`/`Variable`/tracked-set
// Investigate forms) would migrate ~250 unrelated, parse-identical cards' coverage
// signatures in one shot — a deliberate global coverage-schema migration, deferred
// out of this focused feature. `None`, or any out-of-scope shape, pushes nothing,
// so those cards keep a byte-identical signature.
// COUPLING: if the lift's eligible quantity set ever widens (e.g. the Gap B
// leading-adjective fix), this scope MUST widen in lockstep, or the new lift class
// becomes false-green in the parse-diff.
if let Some(rf) = &def.repeat_for {
let is_lift_shape = matches!(&*def.effect, Effect::Investigate)
&& matches!(
rf,
QuantityExpr::Ref { qty }
if matches!(
qty,
QuantityRef::PlayerCount { .. } | QuantityRef::ObjectCount { .. }
)
);
if is_lift_shape {
d.push(("repeat_for".into(), fmt_quantity(rf)));
}
}
if def.optional_targeting {
d.push(("targeting".into(), "optional (up to)".into()));
}
Expand Down Expand Up @@ -10677,6 +10707,85 @@ mod tests {
);
}

#[test]
fn investigate_signature_exposes_repeat_for() {
// ASK 2 + #6110 3rd review: a lifted "[once] for each ⟨set⟩" multiplier
// (`def.repeat_for = Some(PlayerCount/ObjectCount)`) must be visible in the
// per-card parse-diff signature — but ONLY for the shapes this PR's lift
// produces (fieldless `Effect::Investigate` + a member-count `QuantityRef`).
// The projection must NOT fire for the whole pre-existing repeat_for surface
// (CopySpell/Token/Proliferate, or pre-existing `Fixed`/`Variable` Investigate
// forms), which would migrate ~250 parse-identical cards' signatures at once.
use crate::types::ability::{
AbilityDefinition, AbilityKind, PlayerFilter, QuantityExpr, QuantityRef, TargetFilter,
TypedFilter,
};
let projects = |effect: Effect, repeat: Option<QuantityExpr>| -> bool {
let mut def = AbilityDefinition::new(AbilityKind::Spell, effect);
def.repeat_for = repeat;
ability_details(&def)
.into_iter()
.any(|(k, _)| k == "repeat_for")
};
let object_count = || QuantityExpr::Ref {
qty: QuantityRef::ObjectCount {
filter: TargetFilter::Typed(TypedFilter::creature()),
},
};
let player_count = || QuantityExpr::Ref {
qty: QuantityRef::PlayerCount {
filter: PlayerFilter::OpponentLostLife,
},
};

// Positive — both member-count lift shapes surface (Serene = ObjectCount,
// Teysa/Wojek = PlayerCount). Revert-probe: reverting the `ability_details`
// projection drops the row and flips both.
assert!(
projects(Effect::Investigate, Some(object_count())),
"Investigate + ObjectCount lift must appear in the signature",
);
assert!(
projects(Effect::Investigate, Some(player_count())),
"Investigate + PlayerCount lift must appear in the signature",
);

// Negative — no repeat_for → byte-identical signature (unchanged cards).
assert!(
!projects(Effect::Investigate, None),
"an Investigate with no repeat_for must not add the row",
);
// Negative — a `Fixed` multiplier ("investigate twice", Confirm Suspicions et
// al.) is not a member-count lift. Revert-probe: dropping the
// `QuantityExpr::Ref` guard flips this.
assert!(
!projects(Effect::Investigate, Some(QuantityExpr::Fixed { value: 2 })),
"a Fixed repeat_for must not project (not a member-count lift)",
);
// Negative — a non-member-count `Ref` (pre-existing `Variable`/tracked-set
// Investigate forms: Disorder in the Court, Declaration in Stone) must not
// project. Revert-probe: dropping the inner `PlayerCount|ObjectCount` guard
// flips this.
assert!(
!projects(
Effect::Investigate,
Some(QuantityExpr::Ref {
qty: QuantityRef::Variable { name: "x".into() },
}),
),
"a non-member-count Ref repeat_for must not project",
);
// Negative (team-lead required) — the SAME member-count lift on a
// NON-Investigate effect (stand-in for the CopySpell/Token/Proliferate
// repeat_for surface) must not project. Revert-probe: dropping the
// `Effect::Investigate` guard widens the scope to the whole surface and flips
// this — this case is what locks a1.
assert!(
!projects(Effect::Populate, Some(object_count())),
"a non-Investigate repeat_for must not project (scope is the Investigate lift class)",
);
}

#[test]
fn prevent_damage_signature_exposes_damage_source_filter() {
// #5492: a change to `damage_source_filter` (e.g. unqualified
Expand Down
13 changes: 13 additions & 0 deletions crates/engine/src/game/filter.rs
Original file line number Diff line number Diff line change
Expand Up @@ -218,6 +218,9 @@ fn filter_prop_uses_object_population(prop: &FilterProp) -> bool {
| FilterProp::NotSupertype { .. }
| FilterProp::Suspected
| FilterProp::Renowned
// CR 701.15b/c: goad is a candidate-local designation (reads only the
// object's own `goaded_by` set), so the board population is irrelevant.
| FilterProp::Goaded
| FilterProp::ToughnessGTPower
| FilterProp::PowerExceedsBase
| FilterProp::Modified
Expand Down Expand Up @@ -460,6 +463,9 @@ fn entered_object_perturbs_filter_prop(
| FilterProp::NotSupertype { .. }
| FilterProp::Suspected
| FilterProp::Renowned
// CR 701.15b/c: an entering object cannot perturb a candidate-local goad
// designation (reads only the object's own `goaded_by` set).
| FilterProp::Goaded
| FilterProp::ToughnessGTPower
| FilterProp::PowerExceedsBase
| FilterProp::Modified
Expand Down Expand Up @@ -3363,6 +3369,8 @@ fn spell_record_matches_property(record: &SpellCastRecord, prop: &FilterProp) ->
| FilterProp::HasSingleTarget
| FilterProp::Suspected
| FilterProp::Renowned
// CR 701.15b/c: a spell on the stack carries no goad designation. Fail closed.
| FilterProp::Goaded
// CR 700.9: Modified requires on-battlefield attachments/counters,
// unavailable from a stack-snapshot record.
| FilterProp::Modified
Expand Down Expand Up @@ -4308,6 +4316,8 @@ fn matches_filter_prop(
FilterProp::Suspected => obj.is_suspected,
// CR 702.112b: Match permanents with the renowned designation.
FilterProp::Renowned => obj.is_renowned,
// CR 701.15b/c: a creature is goaded iff at least one player has goaded it.
FilterProp::Goaded => !obj.goaded_by.is_empty(),
// CR 700.9: A permanent is modified if it has one or more counters on
// it (CR 122), is equipped (CR 301.5), or is enchanted by an Aura
// controlled by its controller (CR 303.4).
Expand Down Expand Up @@ -5049,6 +5059,9 @@ fn zone_change_record_matches_property(
// evaluated on the live stack object, not the snapshot).
| FilterProp::Modal
| FilterProp::Renowned
// CR 701.15b/c: goad is not snapshotted onto the zone-change record
// (unlike Suspected's `record.is_suspected`). Fail closed.
| FilterProp::Goaded
// CR 700.9: Modified is a live-battlefield predicate (counters +
// attachments) — a zone-change snapshot cannot represent it.
| FilterProp::Modified
Expand Down
Loading
Loading