feat(engine): Radiance color fan-out damage (Cleansing Beam #5244)#5470
Conversation
…5244) "Radiance — Cleansing Beam deals 2 damage to target creature and each other creature that shares a color with it." lowered to a plain single-target DealDamage — the "and each other creature that shares a color with it" Radiance clause was silently dropped (IgnoredRemainder), so only the chosen target took damage. The fix spans types + runtime + parser: 1. types/ability.rs: add FilterProp::DistinctFrom { reference: Box<TargetFilter> } — the object-IDENTITY dual of FilterProp::SharesQuality (which carries a reference and tests a shared QUALITY). Matches objects that are NOT the same object as any the reference resolves to. Distinct from FilterProp::Another (excludes the ability SOURCE) and OtherThanTriggerObject (excludes the TRIGGERING object) — neither can exclude the chosen target. A parameterized, reference-carrying variant (not a bare OtherThanParentTarget sibling), so it generalizes and avoids a 454-call-site refactor of Another. CR 109.1. 2. game/filter.rs: runtime eval — DistinctFrom{ParentTarget} resolves the ability's chosen object target(s) (mirrors SharesQuality's ParentTarget path) and matches every object except those. Plus the population-dependency and snapshot (spell-cast / zone-change) classifier arms, fail-closed like DifferentNameFrom. ability_rw.rs / ai_support / coverage arms too. 3. parser/oracle_effect/mod.rs: try_parse_radiance_color_fanout_damage recognizes "deals N damage to target <X> and each other <X> that shares a color with it" and lowers to DealDamage{target} (target slot + target's own damage) + a DamageAll sub-ability over Typed(<X>).[SharesQuality{Color, ParentTarget}, DistinctFrom{ParentTarget}] — the color-sharers EXCLUDING the already-damaged target, so it is not dealt damage twice (CR 120.3). Radiance is an ability word (CR 207.2c). The fan-out noun's type is taken from the parsed target, so it generalizes. Covers the Radiance damage subclass (Cleansing Beam, Wojek Embermage); Brightflame (X + "damage dealt this way" lifegain rider) is deferred. Tests: - parser: Cleansing Beam lowers to DealDamage + DamageAll{SharesQuality Color ParentTarget + DistinctFrom ParentTarget}; negative reach-guard that plain "deals N to target creature" stays a bare DealDamage (recognizer doesn't over-fire). - runtime (/card-test recipe): red target + red creature + blue creature → target takes 2 ONCE (not doubled), red creature takes 2, blue creature 0. CR 120.3 + CR 207.2c + CR 109.1. Closes phase-rs#5244 Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
There was a problem hiding this comment.
Code Review
This pull request introduces the FilterProp::DistinctFrom variant to support identity-exclusion filters (e.g., 'each other creature'), resolving Issue #5244. It implements this property across the engine's filtering, serialization, and coverage systems, and adds an integration test for Radiance color fan-out damage. The review feedback suggests optimizing the matching logic in crates/engine/src/game/filter.rs to avoid unnecessary heap allocations in a hot path by using .any() instead of collecting intermediate results into a temporary vector.
Important
The consumer version of Gemini Code Assist on GitHub is being sunset. Starting June 18, 2026, new organization installations will be blocked, and all code review activity will officially cease on July 17, 2026.
For more details on the timeline and next steps, please review the Help Documentation.
| let reference_ids: Vec<ObjectId> = if matches!(**reference, TargetFilter::ParentTarget) | ||
| { | ||
| source | ||
| .ability | ||
| .map(|ability| { | ||
| ability | ||
| .targets | ||
| .iter() | ||
| .filter_map(|t| match t { | ||
| TargetRef::Object(id) => Some(*id), | ||
| TargetRef::Player(_) => None, | ||
| }) | ||
| .collect() | ||
| }) | ||
| .unwrap_or_default() | ||
| } else { | ||
| crate::game::targeting::resolve_event_context_targets(state, reference, source.id) | ||
| .into_iter() | ||
| .filter_map(|t| match t { | ||
| TargetRef::Object(id) => Some(id), | ||
| TargetRef::Player(_) => None, | ||
| }) | ||
| .collect() | ||
| }; | ||
| !reference_ids.contains(&object_id) |
There was a problem hiding this comment.
[MED] Avoid unnecessary heap allocations in hot filtering path. Evidence: crates/engine/src/game/filter.rs:4119. Why it matters: Filtering is evaluated frequently across many objects, and collecting intermediate results into a Vec causes redundant allocations. Suggested fix: Use .any() to short-circuit and avoid allocating a temporary vector.
let is_referenced = if matches!(**reference, TargetFilter::ParentTarget) {
source
.ability
.map(|ability| {
ability.targets.iter().any(|t| match t {
TargetRef::Object(id) => *id == object_id,
TargetRef::Player(_) => false,
})
})
.unwrap_or(false)
} else {
crate::game::targeting::resolve_event_context_targets(state, reference, source.id)
.into_iter()
.any(|t| match t {
TargetRef::Object(id) => id == object_id,
TargetRef::Player(_) => false,
})
};
!is_referenced
Parse changes introduced by this PR · 22 card(s), 11 signature(s) (baseline: main
|
matthewevans
left a comment
There was a problem hiding this comment.
Strong work on the core of this — right seam, and the engine design is the one I'd have asked for. DistinctFrom { reference } as the identity dual of SharesQuality is the correct parameterized variant rather than a bare OtherThanParentTarget sibling, the fan-out noun is taken from the parsed primary target instead of being hard-coded to Creature, and the runtime test in deal_damage.rs genuinely discriminates (red_target == 2 flips to 4 on revert; blue_other == 0 flips on reverting SharesQuality). All 13 checks are green.
One blocker, though, and it's the reason the parse-diff sticky is required evidence.
Blocking — Brightflame silently loses its lifegain
The PR body says:
Brightflame (X + "damage dealt this way" lifegain rider) is explicitly deferred.
The parse-diff sticky on this PR says otherwise:
#### 1 card(s) · ability/DamageAll · added: `DamageAll (amount=X, filter=shares color with reference other than target creature)`
Examples: Brightflame
#### 1 card(s) · ability/GainLife · removed: `GainLife (amount=amount from preceding effect)`
Examples: Brightflame
Brightflame is not deferred — the recognizer fires on it. Its Oracle text (Scryfall) is:
Radiance — Brightflame deals X damage to target creature and each other creature that shares a color with it. You gain life equal to the damage dealt this way.
try_parse_radiance_color_fanout_damage matches Brightflame's first sentence (single-target DealDamage + the exact "…that shares a color with it" tail), so the fan-out lowers — and the second sentence's GainLife disappears from the card's parse. Before this PR Brightflame at least gained life; after it, the printed ability is gone.
That's a behavioral regression on a real card, and CI cannot catch it: the engine tests all pass, because none of them cast Brightflame. Green checks are necessary but not sufficient here.
Either resolution is fine, but it has to be a decision rather than a side effect:
- Preserve the rider. Keep
GainLifebound to the damage the fan-out actually deals. This needs the "damage dealt this way" quantity to resolve againstDamageAll's total rather than a precedingDealDamage, which is real engine work — reasonable to split into its own PR. - Defer honestly. Make the recognizer decline Brightflame (or make the lifegain sentence lower to
Effect::unimplemented) so coverage marks the card unsupported. A dropped clause must survive asEffect::unimplemented— silently omitting a printed ability makes the card look supported while playing it wrong, which is worse than not supporting it.
Please also correct the "explicitly deferred" line in the PR body once you've picked one; as written it describes behavior the diff doesn't have.
Non-blocking
filter.rs:4165 — per-candidate heap allocation on a filter hot path. (Confirming Gemini's comment against the current head; it's still live.) The new arm builds let reference_ids: Vec<ObjectId> = … and then calls .contains(&object_id). matches_filter_prop runs once per candidate object during filter evaluation, so this allocates per object. This repo treats filter/eval hot paths as first-class, not a nit. Allocation-free:
// ParentTarget arm
!ability.targets.iter().any(|t| matches!(t, TargetRef::Object(id) if *id == object_id))and .any() over the resolver output in the else arm rather than .collect().
DistinctFrom { ParentTarget } fails open. It reads only ability.targets with .unwrap_or_default(), so when ability is None or its targets are empty it excludes nothing — the target would be damaged twice. Safe for the current Radiance class (the resolving DamageAll sub-ability always carries the chosen target), but it lacks the LKI / recipient_id / effect_context_object fallback ladder that SharesQuality's ParentTarget path carries at filter.rs:4941. Worth a comment noting the asymmetry, so the next reuse in a layer-eval or recipient context doesn't inherit a silent fail-open.
CR notes
CR 207.2c (ability words have no rules meaning) is correct and verified. CR 109.1 is a loose but topically-fine anchor for object identity. CR 120.3 doesn't literally say a target isn't dealt damage twice — that comes from "other" plus DistinctFrom — but it's the codebase's established annotation for damage-dealing sites, so I'm not asking you to change it.
Happy to re-review as soon as the Brightflame question is settled. The engine half of this is genuinely good; it's the parser blast radius that needs a decision.
… alloc Review fixes on phase-rs#5470 (matthewevans): BLOCKER — Brightflame silently mis-lowered. The recognizer fired on Brightflame ("deals X damage to target creature and each other creature that shares a color with it. You gain life equal to the damage dealt this way."), lowering the fan-out while its "damage dealt this way" lifegain rider would need to bind to the DamageAll total rather than a preceding DealDamage. Gate the recognizer to FIXED-amount Radiance damage (Cleansing Beam = 2, Wojek Embermage = 1); X-amount + rider variants (Brightflame) decline and stay honestly unsupported instead of lowering wrong. Added a decline test. NON-BLOCKING — filter.rs DistinctFrom eval no longer allocates a per-candidate Vec; short-circuits via `.any()` (matches_filter_prop runs once per object). Documented the ParentTarget fail-open asymmetry vs. SharesQuality's LKI / recipient_id fallback ladder. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
|
Thanks @matthewevans — thorough review, and you're right on the Brightflame blocker. Blocker (fixed in 84d8e5f): the recognizer was firing on Brightflame and its "damage dealt this way" lifegain rider would then bind to the wrong preceding effect. Rather than the half-fix, I gated the recognizer to fixed-amount Radiance damage (Cleansing Beam = 2, Wojek Embermage = 1). Brightflame (X + rider) now declines — it stays honestly unsupported instead of lowering wrong, so its lifegain sentence isn't silently dropped. Added Non-blocking, both addressed:
The PR description's "explicitly deferred" line is now accurate — Brightflame genuinely declines. |
matthewevans
left a comment
There was a problem hiding this comment.
Approving. The round-1 blocker is resolved, and both non-blocking items were addressed well.
Blocker resolved: Brightflame no longer silently loses its lifegain
try_parse_radiance_color_fanout_damage now declines any non-fixed amount before it does any lowering:
if !matches!(amount, QuantityExpr::Fixed { .. }) {
return None;
}The refreshed parse-diff sticky (updated 07:42:58Z, after the head commit at 07:32:11Z, so it reflects the current head) confirms the effect. Brightflame no longer appears in the diff at all: no DamageAll(amount=X) row, no GainLife removed row. The only two DamageAll rows remaining are exactly the two intended cards:
DamageAll (amount=1, ...)— Wojek EmbermageDamageAll (amount=2, ...)— Cleansing Beam
I also confirmed the new radiance_fanout_declines_variable_x_amount test genuinely discriminates. It feeds the same subject form as the positive sibling at oracle_effect/tests.rs:4181, which asserts a DamageAll sub-ability must attach for the identical sentence shape with a fixed amount. The two differ only in the amount (2 -> X). Revert the guard and the negative test fails. Good test. The runtime GameScenario case that drives real damage marking through the engine is the right call too.
About the ~19 extra rows in the parse-diff sticky
Please ignore them. The sticky also lists cards touching PreventDamage, SeparateIntoPiles, CountsAsNamed, and TargetOnly "continuously since". None of that is yours. Your full diff is 8 files and contains zero occurrences of any of those identifiers. Those rows are baseline drift: they map onto #5463, #5476, #5480, and #5484, which landed in main after the sticky's baseline SHA was computed. No action needed, and please don't spend time chasing them.
The two non-blocking items
Both landed. The FilterProp::DistinctFrom eval now short-circuits with source.ability.is_some_and(|a| a.targets.iter().any(...)) instead of collecting a Vec<ObjectId> per candidate object. matches_filter_prop runs once per candidate, so keeping the allocation off that path matters.
The fail-open comment is exactly what I asked for. It names parent_target_shared_quality_values as the contrast, states plainly that ability: None or empty targets excludes nothing, and flags that a future layer-eval or recipient-context reuse needs the same LKI / recipient_id fallback ladder to avoid a silent double-hit. That is the kind of note that saves the next person a debugging session.
One maintainer note, no action required
Recording this for the record rather than asking you to change anything.
The guard keys on the amount being non-Fixed, while the actual reason to decline Brightflame is its "damage dealt this way" lifegain rider, which would need to bind to the fan-out DamageAll total. For today's card pool those coincide: the whole Radiance damage class is three cards, and X-ness selects exactly Brightflame. So the discriminator is sound as shipped. A future fixed-amount Radiance card carrying a rider would still lower wrong, which is worth remembering when that card shows up.
Separately, the comment's phrase "keeps Brightflame honestly unsupported" slightly overstates what happens. Declining restores main's existing behavior: Brightflame lowers to a plain single-target DealDamage with the fan-out clause dropped as IgnoredRemainder, and its GainLife intact. That is a pre-existing gap rather than a regression this PR introduces, and it is also not an Effect::unimplemented. The important property, that the printed lifegain survives, does hold.
On "Fixes #5244"
Heads up that the PR body says Fixes #5244, so merging will auto-close that issue even though Brightflame, one of its three cards, stays unimplemented. Please do not push a body edit for this. It would be unnecessary churn on a green PR and would cost you another CI cycle. I will reopen and re-scope #5244 after merge to track the remaining Brightflame X-amount plus lifegain-rider work.
Nice work on the second round. Enqueueing.
Summary
Fixes #5244. Cleansing Beam — "Radiance — Cleansing Beam deals 2 damage to target creature and each other creature that shares a color with it." — lowered to a plain single-target
DealDamage; the Radiance fan-out clause was silently dropped (IgnoredRemainder), so only the chosen target took damage.The fix spans types + runtime + parser (the correct home for each piece):
1.
types/ability.rs— newFilterProp::DistinctFrom { reference: Box<TargetFilter> }The object-identity dual of
FilterProp::SharesQuality(which carries areferenceand tests a shared quality). Matches objects that are not the same object as any thereferenceresolves to.Why a new variant: none of the existing exclusions fit —
Anotherexcludes the ability source,OtherThanTriggerObjectexcludes the triggering object; neither can exclude the chosen target. Gated through/add-engine-variant: chosen as a parameterized reference-carrying variant (mirrorsSharesQuality/DifferentNameFrom), not a bareOtherThanParentTargetsibling — so it generalizes to any "other than [reference]" case and avoids a 454-call-site refactor ofAnother. CR 109.1.2.
game/filter.rs— runtime evalDistinctFrom { ParentTarget }resolves the ability's chosen object target(s) (mirrorsSharesQuality'sParentTargetpath at filter.rs) and matches every object except those. Plus population-dependency and spell-cast / zone-change snapshot classifier arms (fail-closed, likeDifferentNameFrom).ability_rw.rs/ai_support/coveragearms updated in lockstep.3.
parser/oracle_effect/mod.rs— Radiance recognizertry_parse_radiance_color_fanout_damagerecognizes "deals N damage to target<X>and each other<X>that shares a color with it" and lowers to:DealDamage { target: Typed(<X>) }— provides the required target slot and the target's own damage, thensub_ability: DamageAll { target: Typed(<X>).[SharesQuality{Color, ParentTarget}, DistinctFrom{ParentTarget}] }— the color-sharers excluding the already-damaged target, so it is not dealt damage twice (CR 120.3).Radiance is an ability word (CR 207.2c, verified — no independent rules meaning). The fan-out noun's type is taken from the parsed target, so it generalizes beyond creatures.
Design notes (from the plan-review gate)
Two naive representations were considered and rejected on code evidence:
DamageAllincluding the target:DamageAllis a mass effect that surfaces no target slot, soParentTargetwould resolve to nothing.DealDamage + DamageAll{Another}:Anotherexcludes the source, not the target → the target would take 4 damage.Hence the
DistinctFrom{ParentTarget}exclusion is load-bearing.Class
Radiance damage subclass: Cleansing Beam, Wojek Embermage. Brightflame (X + "damage dealt this way" lifegain rider) is explicitly deferred.
Tests
DealDamage+DamageAll{SharesQuality Color ParentTarget + DistinctFrom ParentTarget}; negative reach-guard that plain "deals 2 damage to target creature" stays a bareDealDamage(recognizer doesn't over-fire)./card-testrecipe): red target + red creature + blue creature → target takes 2 once (not doubled), red creature 2, blue creature 0.Verification
cargo check -p engine— clean (exhaustiveness across allFilterPropmatch sites)cargo clippy -p engine— cleangame::filter(124),game::effects::deal_damage(66),parser::oracle_effect(1370) — zero failures.FilterProp::DistinctFromis purely additive to the serialized surface (old data never references it).CR 120.3 (fan-out damage same event) + CR 207.2c (Radiance ability word) + CR 109.1 (object identity).
🤖 Generated with Claude Code