Skip to content

feat(engine): Radiance color fan-out damage (Cleansing Beam #5244)#5470

Merged
matthewevans merged 2 commits into
phase-rs:mainfrom
shin-core:pr-cleansing-beam-radiance-5244
Jul 10, 2026
Merged

feat(engine): Radiance color fan-out damage (Cleansing Beam #5244)#5470
matthewevans merged 2 commits into
phase-rs:mainfrom
shin-core:pr-cleansing-beam-radiance-5244

Conversation

@shin-core

Copy link
Copy Markdown
Contributor

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 — new 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.

Why a new variant: none of the existing exclusions fit — Another excludes the ability source, OtherThanTriggerObject excludes the triggering object; neither can exclude the chosen target. Gated through /add-engine-variant: chosen as a parameterized reference-carrying variant (mirrors SharesQuality/DifferentNameFrom), not a bare OtherThanParentTarget sibling — so it generalizes to any "other than [reference]" case 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 at filter.rs) and matches every object except those. Plus population-dependency and spell-cast / zone-change snapshot classifier arms (fail-closed, like DifferentNameFrom). ability_rw.rs / ai_support / coverage arms updated in lockstep.

3. parser/oracle_effect/mod.rs — Radiance recognizer

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: Typed(<X>) } — provides the required target slot and the target's own damage, then
  • sub_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:

  • A single DamageAll including the target: DamageAll is a mass effect that surfaces no target slot, so ParentTarget would resolve to nothing.
  • DealDamage + DamageAll{Another}: Another excludes 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

  • Parser: Cleansing Beam lowers to DealDamage + DamageAll{SharesQuality Color ParentTarget + DistinctFrom ParentTarget}; negative reach-guard that plain "deals 2 damage 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 2, blue creature 0.

Verification

  • cargo check -p engine — clean (exhaustiveness across all FilterProp match sites)
  • cargo clippy -p engine — clean
  • Suites pass: game::filter (124), game::effects::deal_damage (66), parser::oracle_effect (1370) — zero failures.

FilterProp::DistinctFrom is 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

…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>
@shin-core
shin-core requested a review from matthewevans as a code owner July 10, 2026 01:07

@gemini-code-assist gemini-code-assist Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Comment thread crates/engine/src/game/filter.rs Outdated
Comment on lines +4119 to +4143
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)

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

[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

@github-actions

github-actions Bot commented Jul 10, 2026

Copy link
Copy Markdown

Parse changes introduced by this PR · 22 card(s), 11 signature(s) (baseline: main 42bb1135379b)

14 card(s) · ability/PreventDamage · field target: any targetparent target

Examples: Delirium, Djeru's Resolve, Ebony Horse (+11 more)

7 card(s) · ability/PreventDamage · added: PreventDamage (amount=All, scope=CombatDamage, target=any target)

Examples: Delirium, Ebony Horse, Elvish Scout (+4 more)

3 card(s) · ability/TargetOnly · field target: creature non-Wallcontrolled continuously since turn began the active player controls creature non-Wall

Examples: Arcum's Whistle, Nettling Imp, Norritt

2 card(s) · ability/effect_structure · removed: effect_structure

Examples: Diligent Farmhand, Pardic Firecat

1 card(s) · static/CountsAsNamed(Flame Burst) · added: CountsAsNamed(Flame Burst)

Examples: Pardic Firecat

1 card(s) · static/CountsAsNamed(Muscle Burst) · added: CountsAsNamed(Muscle Burst)

Examples: Diligent Farmhand

1 card(s) · ability/DamageAll · added: DamageAll (amount=1, filter=shares color with reference other than target creature)

Examples: Wojek Embermage

1 card(s) · ability/DamageAll · added: DamageAll (amount=2, filter=shares color with reference other than target creature)

Examples: Cleansing Beam

1 card(s) · ability/PreventDamage · added: PreventDamage (amount=All, scope=AllDamage, target=any target)

Examples: Kiora, the Tide's Fury

1 card(s) · ability/RevealTop · removed: RevealTop (count=5, player=controller)

Examples: Fact or Fiction

1 card(s) · ability/SeparateIntoPiles · added: SeparateIntoPiles

Examples: Fact or Fiction

1 card(s) had Oracle-text changes (errata/reprint) — excluded as non-parser.

@matthewevans matthewevans left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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:

  1. Preserve the rider. Keep GainLife bound to the damage the fan-out actually deals. This needs the "damage dealt this way" quantity to resolve against DamageAll's total rather than a preceding DealDamage, which is real engine work — reasonable to split into its own PR.
  2. 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 as Effect::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.

@matthewevans matthewevans added the enhancement New feature or request label Jul 10, 2026
… 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>
@shin-core

Copy link
Copy Markdown
Contributor Author

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 radiance_fanout_declines_variable_x_amount asserting no DamageAll sub-ability is produced for the X form. Binding the rider to the fan-out's DamageAll total is the real engine work you described — I'll split that into its own PR.

Non-blocking, both addressed:

  • filter.rs: the DistinctFrom eval no longer allocates a per-candidate Vec — it short-circuits via .any() (your exact suggestion), since matches_filter_prop runs once per object.
  • Documented the ParentTarget fail-open asymmetry: it reads only ability.targets and lacks the LKI / recipient_id / effect-context fallback ladder that SharesQuality's ParentTarget path carries — safe for the current Radiance class (the resolving DamageAll always carries the chosen target) but noted so a future layer-eval/recipient reuse doesn't inherit a silent fail-open.

The PR description's "explicitly deferred" line is now accurate — Brightflame genuinely declines. check/clippy clean; parser + filter + deal_damage suites green.

@matthewevans matthewevans left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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 Embermage
  • DamageAll (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.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

enhancement New feature or request

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Mini Beam — [[Cleansing Beam]] only deals damage to the original target of the spell, not other creatures that share a…

2 participants