Skip to content

fix(engine): honor dynamically granted Sunburst at battlefield entry (Solar Array #5337)#5802

Merged
matthewevans merged 8 commits into
phase-rs:mainfrom
shin-core:fix-granted-sunburst-5337
Jul 24, 2026
Merged

fix(engine): honor dynamically granted Sunburst at battlefield entry (Solar Array #5337)#5802
matthewevans merged 8 commits into
phase-rs:mainfrom
shin-core:fix-granted-sunburst-5337

Conversation

@shin-core

@shin-core shin-core commented Jul 14, 2026

Copy link
Copy Markdown
Contributor

Fixes #5337 (claimed there with the full diagnosis). Verified Solar Array's Oracle text and CR 702.44a/b/d against docs/MagicCompRules.txt, and reproduced end-to-end before building: a granted-sunburst artifact cast for three colors entered with 0 counters (printed-sunburst control: 3 — correct).

Three stacked defects, one per layer

1. Parser — the grant never landed (Solar Array). "When you next cast an artifact spell this turn, that spell gains sunburst" lowered the subject-position anaphor to affected: ParentTarget. A WhenNextEvent delayed trigger has no parent target, so register_transient_effect bound the grant against the empty chain-tracked set and silently dropped it — the stack object never had Sunburst at all. Fixed with a scoped lift (mirroring the lift_parent_target_to_triggering_source family): GenericEffect grants in a WhenNextEvent body rebind to TriggeringSource — the just-cast spell IS the event source (CR 608.2k).

2. Engine — a landed grant still did nothing (the class bug; Lux Artillery is equally broken today). Printed sunburst works via an object-carried ETB ReplacementDefinition pre-synthesized from the card face's printed keywords; a granted keyword adds no replacement, and nothing consulted live keywords at entry. Fix: a granted-instance virtual replacement candidate in find_applicable_replacements (alongside the existing shield/finality-counter virtual candidates), built from the same shared sunburst_replacement_definition authority the printed synthesis uses — so it participates in normal CR 616 replacement ordering (a Doubling Season-class doubler doubles it, tested).

The subtle part: granted instances are counted as effective minus base keywords via effective_off_zone_keywords — the entering spell is still on the stack when its entry pipeline runs, and a granted keyword exists only as a continuous effect at that moment (the materialized keyword list is empty), so the off-zone authority is the only correct read. The base subtraction keeps printed instances on their carried definitions: printed + granted apply separately, exactly CR 702.44d ("each one works separately").

3. Engine — cast-trigger resolution wiped the color provenance. Resolving an intervening cast trigger (Lux Artillery's own grant trigger) cleared colors_spent_to_cast on objects still on the stack — erasing the color count before the granted spell entered. The clear now preserves live cast provenance for stack objects (CR 601.2h), mirroring how cast_from_zone is preserved.

Supporting: the Keyword instance-coexistence predicate now also covers CR 702.44d instance-count multiplicity (previously only Toxic's parameter summation kept duplicate instances), so a granted Sunburst survives beside an identical printed one.

Tests (integration, real activate/cast/trigger/replacement pipeline, verbatim Oracle texts)

test asserts
Solar Array creature, 3 colors 3 +1/+1 (primary canary — reverting either gap → 0)
Solar Array noncreature, 2 colors 2 charge, 0 +1/+1 (CR 702.44a branch)
Solar Array, zero colored mana 0 counters (CR 702.44b negative, with resolve reach-guard)
Lux Artillery, 2 colors 2 +1/+1 (gap 2+3 canary — non-delayed grant path)
Printed control, 3 colors 3 charge (carried-definition path untouched)
Printed + granted, 2 colors 4 charge (CR 702.44d: each instance separately, 2+2)
Granted + counter doubler 4 (CR 616 ordering through the virtual candidate)
Parser shape delayed grant lowers affected: TriggeringSource + Sunburst AddKeyword

The Solar Array tests drive the real "{T}: Add one mana of any color" activation including the ChooseManaColor prompt.

Class coverage

The granted-vs-printed gap is structural across the enters-with-counters keyword family that synthesis pre-bakes (Modular, Graft, Bloodthirst, Devour, Amplify, Fading, Vanishing, Read Ahead). Both actual granters today are fixed and tested in this PR:

  • Sunburst — Solar Array, Lux Artillery.
  • Bloodthirst — Bloodlord of Vaasgoth. Commit af975c2c generalizes the virtual-candidate authority over a GrantedEtbKeyword{Sunburst, Bloodthirst} parameterization rather than adding a parallel Sunburst-shaped path, and carries its own integration coverage (granted_bloodthirst_5802.rs), including the CR 702.54a condition gate (an unmet damage condition contributes zero counters, routed through the same evaluate_replacement_condition seam the printed path uses).

(An earlier revision of this description said Bloodthirst was deferred; that is no longer accurate — it ships here.)

Verification

Full engine lib: 16549 passed, 0 failed. Full integration: 3038 passed, 0 failed. cargo fmt clean; parser combinator gate pass. All CR annotations (702.44a/b/d, 608.2k, 601.2h, 616.1, 604.1, 613.1f, 614.1c) grep-verified. (These counts predate the maintainer fixup commit below; that commit's evidence is the PR's own required CI run.)

Maintainer fixups

Applied directly on this branch to avoid another contributor round-trip; the contributor is retained as co-author.

  • CR 702.44a — branch on PRINTED, not live, core types. granted_etb_replacement_definitions selected the Sunburst counter type from obj.card_types, the live layer result. CR 702.44a says sunburst applies "if this object is entering as a creature, ignoring any type-changing effects that would affect it", and Layer-6 type effects do reach stack objects (remote_type_layer_recipients) — the very zone this check runs in. Now reads base_card_types, seeded from the same card_face.card_type the printed synthesizer branches on, so the granted and printed paths agree. All 12 pre-existing fixtures set base_card_types == card_types, leaving the divergent arm unexercised, so a fixture whose printed and live types diverge was added — it fails if the branch is reverted.
  • Hot-path cost in find_applicable_replacements. The ZoneChange→Battlefield gate ran a full off-zone keyword sweep per keyword family — each a whole-game continuous-effect collect plus ordering and per-effect filter evaluation — and ordered the guard expensive-term-first. The cheap already_applied set lookup now short-circuits first, and the live keyword list is resolved at most once (lazily) and threaded through the family helpers, so one sweep serves every family.
  • Keyword::sums_across_instancesinstances_must_coexist. All three call sites implement "don't dedup identical instances"; none sums a parameter. The predicate's own doc already headlines "must COEXIST" — only the name disagreed.
  • Dangling doc reference to granted_sunburst_replacements (no such symbol) repointed at the real granted_sunburst_instancesgranted_etb_replacement_definitions chain.

🤖 Generated with Claude Code

Summary by CodeRabbit

  • Bug Fixes

    • Granted Sunburst and Bloodthirst now correctly apply their enter-the-battlefield counters.
    • Bloodthirst only adds counters when its damage condition is satisfied.
    • Sunburst supports the correct counter type, multiple instances, counter doubling, and replacement ordering.
    • Delayed effects now correctly apply to the spell that triggered them.
  • Tests

    • Added comprehensive integration coverage for granted Sunburst and Bloodthirst interactions.

@shin-core
shin-core requested a review from matthewevans as a code owner July 14, 2026 11:59
@superagent-security superagent-security Bot added the contributor:flagged Contributor flagged for review by trust analysis. label Jul 14, 2026
@superagent-security

Copy link
Copy Markdown

🚨 Contributor flagged. Click here for more info: Superagent Dashboard

@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 implements support for granted sunburst instances (e.g., from cards like Solar Array or Lux Artillery) in accordance with MTG Comprehensive Rules (CR 702.44a/b/d). It extracts the sunburst replacement definition into a shared helper, introduces a virtual replacement candidate for granted sunburst instances, ensures multiple sunburst instances coexist on the keyword list without deduplication, and adds comprehensive integration tests. The review feedback suggests refactoring the defensive check in apply_granted_sunburst_replacement to avoid manually rebuilding the ProposedEvent::ZoneChange struct, which reduces boilerplate and prevents future compilation breakages when new fields are added.

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/replacement.rs Outdated
Comment on lines +379 to +411
let ProposedEvent::ZoneChange {
object_id,
from,
to,
cause,
attach_to,
enter_tapped,
mut enter_with_counters,
controller_override,
enter_transformed,
face_down_profile,
applied,
} = event
else {
return event;
};
if object_id != rid.source {
// Rebuild the event unchanged if the ids diverged (defensive; the
// candidate is keyed on the entering object so this never fires).
return ProposedEvent::ZoneChange {
object_id,
from,
to,
cause,
attach_to,
enter_tapped,
enter_with_counters,
controller_override,
enter_transformed,
face_down_profile,
applied,
};
}

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

[MEDIUM] Avoid manual rebuild of ProposedEvent::ZoneChange in defensive check.

Why it matters: Manually rebuilding the ProposedEvent::ZoneChange struct with all its fields creates a maintenance burden, as any future addition of new fields to ZoneChange will break this compilation. Checking the object_id via a reference borrow before moving/destructuring the event allows returning the original event directly on mismatch, eliminating the boilerplate and future-proofing the code.

    let ProposedEvent::ZoneChange { object_id, .. } = &event else {
        return event;
    };
    if *object_id != rid.source {
        return event;
    }

    let ProposedEvent::ZoneChange {
        object_id,
        from,
        to,
        cause,
        attach_to,
        enter_tapped,
        mut enter_with_counters,
        controller_override,
        enter_transformed,
        face_down_profile,
        applied,
    } = event
    else {
        unreachable!();
    };

@github-actions

Copy link
Copy Markdown

Parse changes introduced by this PR

✓ No card-parse changes detected.

@matthewevans matthewevans self-assigned this Jul 14, 2026

@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.

Request changes — granted Sunburst is incorrectly declared order-independent from counter-doubling replacements.

🔴 Blocker

  • candidate_materiality returns Disjoint for the virtual Sunburst candidate (crates/engine/src/game/replacement.rs:7050-7056), while its applier appends enter_with_counters to the same ZoneChange event (crates/engine/src/game/replacement.rs:334-440). A counter doubler can therefore be applied first to an empty payload, after which Sunburst appends counters that are no longer doubled; the reverse ordering doubles them. These outcomes differ, so CR 616 ordering must remain available. The new doubled-Sunburst test does not exercise both ordering choices.

✅ Clean

  • The virtual candidate uses the existing replacement pipeline and shares the printed-Sunburst definition authority, which is the right seam for the granted-instance gap.

Recommendation: classify the candidate as a counter-payload write (or conservatively order-sensitive) and add an integration regression proving both legal orderings against a counter doubler before re-review.

@matthewevans matthewevans added the bug Bug fix label Jul 14, 2026
@matthewevans matthewevans removed their assignment Jul 14, 2026
shin-core added a commit to shin-core/phase that referenced this pull request Jul 14, 2026
… write (CR 616.1e)

Review on phase-rs#5802: the virtual granted-sunburst candidate was classified
`Disjoint` in `candidate_materiality`, but its applier appends to the same
event counter payload a co-firing Count writer modifies — an append does not
commute with a doubler ((0+N)*2 vs 0*2+N), so `Disjoint` silently suppressed
the CR 616.1e affected-controller ordering choice. Reclassify as
`Writes { field: Count, commute: Additive }`: two appenders still commute
(no degenerate prompt), while an appender + any non-additive Count writer on
one event now surfaces the ordering prompt.

Integration regression (both fail on revert to `Disjoint` with "the CR
616.1e ordering prompt must surface"): granted sunburst + a same-event
Moved-keyed `quantity_modification(DOUBLE)` writer co-fire on the entering
spell's ZoneChange; the ordering prompt lists both candidates, and BOTH
legal orderings are driven end-to-end to a clean entry with the granted
payload intact.

Empirical note recorded in the test doc: the two orderings currently
converge (2 counters each) because a bare `quantity_modification` on a
Moved-keyed definition has no ZoneChange counter-payload applier yet — the
functioning Doubling Season path scales the downstream AddCounter placement
instead (asserted at 4 by `granted_sunburst_participates_in_counter_
doubling`). The reclassification pins the ordering machinery so that if a
ZoneChange payload applier lands later, only the expected totals change
(4 sunburst-first vs 2 writer-first), not the choice surface.

Full lib: 16549 passed. Integration: 3040 passed. Clippy clean.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
@shin-core

Copy link
Copy Markdown
Contributor Author

Pushed 23135a6c — took your recommendation: the candidate is now Writes { field: Count, commute: Additive }. Two appenders still commute (no degenerate prompt between two additive writers), while an appender + any non-additive Count writer on the same event surfaces the CR 616.1e choice.

Regression added, both orderings driven end-to-end: granted_sunburst_ordering_choice_sunburst_first / _count_writer_first co-fire the granted-sunburst appender with a same-event Moved-keyed quantity_modification(DOUBLE) writer on the entering spell's ZoneChange. Both assert the ordering prompt lists both candidates and drive their chosen order to a clean entry with the payload intact. Revert-probed: restoring Disjoint fails both with "the CR 616.1e ordering prompt must surface" — the prompt is the revert-canary.

One empirical finding to flag while writing the regression (recorded in the test doc): the two orderings currently CONVERGE (2 counters each) rather than diverge (4 vs 2) — a bare quantity_modification on a Moved-keyed definition is offered by find_applicable_replacements (the Counter/Moved keys on ZoneChange events) but has no ZoneChange counter-payload applier, so the writer no-ops in either position. The functioning Doubling Season path scales the downstream AddCounter placement instead — granted_sunburst_participates_in_counter_doubling pins that at 4 (always doubled, which also matches the paper interaction: the doubler isn't applicable to an empty payload per CR 616.2, so sunburst-then-double is the only live order there). So the divergent-outcome scenario in the review isn't reachable in the current engine — but the reclassification is still the right call: it pins the ordering machinery so that if/when a ZoneChange payload applier lands (e.g. for forge-imported Counter-event definitions), the choice surface already exists and only the expected totals in the regression change (4 sunburst-first vs 2 writer-first, called out in the test comment).

Happy to add the ZoneChange counter-payload applier as a follow-up if you'd like the divergent totals realized now rather than pinned for later.

Full lib 16549 / integration 3040 green, clippy clean.

@matthewevans matthewevans removed the contributor:flagged Contributor flagged for review by trust analysis. label Jul 16, 2026
@matthewevans matthewevans self-assigned this Jul 16, 2026

@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.

Request changes — generalize virtual ETB keyword handling beyond Sunburst.

🟡 Finding

[MED] The virtual ETB path handles only dynamically granted Sunburst and ignores the existing granted Bloodthirst class. Why it matters: the fix creates a new special case while leaving an equivalent keyword path incorrect. Suggested fix: generalize the existing granted-keyword handling and add a Bloodlord regression.

Recommendation: request-changes.

@matthewevans matthewevans removed their assignment Jul 16, 2026
shin-core and others added 4 commits July 18, 2026 08:44
…(Solar Array phase-rs#5337)

"That spell gains sunburst" (Solar Array) and "it gains sunburst" (Lux
Artillery) placed ZERO as-enters counters. Three stacked defects, fixed per
layer:

1. Parser — the grant never landed (Solar Array). In a "when you next cast
   a <spell> this turn" delayed trigger, the subject-position "that spell"
   anaphor lowered to `affected: ParentTarget`; a `WhenNextEvent` delayed
   trigger has no parent target, so `register_transient_effect` bound the
   grant against the empty chain-tracked set and silently dropped it. A new
   lift (`lift_generic_effect_parent_target_to_triggering_source_in_ability`,
   mirroring the oracle_trigger.rs lift family) rebinds GenericEffect grants
   in a WhenNextEvent body to `TriggeringSource` — the just-cast spell is the
   event source (CR 608.2k).

2. Engine — a landed grant still placed no counters (the class bug, also
   breaking Lux Artillery). Printed sunburst is realized as an object-carried
   ETB `ReplacementDefinition` pre-synthesized from the card face's PRINTED
   keywords (`synthesize_sunburst`); a granted keyword adds no replacement
   and nothing consulted live keywords at entry. A granted-instance VIRTUAL
   replacement candidate now surfaces in `find_applicable_replacements`
   (alongside the shield/finality-counter virtual candidates), built from the
   same shared `sunburst_replacement_definition` authority the printed
   synthesis uses, so it participates in normal CR 616 replacement ordering
   (Doubling Season doubles it). Granted instances are counted as EFFECTIVE
   minus BASE keywords via `effective_off_zone_keywords` — the entering spell
   is still on the STACK when its entry pipeline runs, and a granted keyword
   exists only as a continuous effect at that moment (the materialized
   keyword list is empty), so the off-zone authority is the only correct
   read. The base subtraction keeps printed instances on their carried
   definitions — printed + granted apply separately, exactly CR 702.44d.

3. Engine — cast-trigger resolution wiped the color provenance. Resolving an
   intervening cast trigger (Lux Artillery's own grant trigger) cleared
   `colors_spent_to_cast` on objects still on the STACK, erasing the color
   count before the granted spell entered. The clear now preserves live cast
   provenance for stack objects (CR 601.2h), mirroring how cast_from_zone is
   preserved.

Supporting changes: `Keyword` instance-coexistence predicate extended so a
granted Sunburst survives next to an identical printed instance (CR 702.44d
"each one works separately"; previously only Toxic's summation kept
duplicates); `synthesize_sunburst`'s per-instance definition extracted into
the shared `sunburst_replacement_definition` builder used by both the printed
synthesis and the virtual candidate.

Tests (integration, real activate/cast/trigger/replacement pipeline,
verbatim Oracle texts):
- Solar Array: creature cast for 3 colors -> 3 +1/+1; noncreature for 2 ->
  2 charge; zero colored mana -> 0 counters (CR 702.44b).
- Lux Artillery: 2 colors -> 2 +1/+1 (gap 2+3 canary).
- Printed control: 3 colors -> 3 charge (carried-definition path untouched).
- Printed + granted: 2 colors -> 4 charge (CR 702.44d separateness).
- Counter doubling: granted sunburst under an AddCounter doubler -> 4
  (CR 616 ordering through the virtual candidate).
- Parser shape: the delayed grant lowers `affected: TriggeringSource` with
  the Sunburst AddKeyword (gap 1 canary).

Full lib suite: 16549 passed, 0 failed. Integration: 3038 passed, 0 failed.

Fixes phase-rs#5337.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…py empty-line-after-doc)

The phase-rs#5337 parser-shape test was inserted between an existing doc comment
and its function; reattach the comment.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
… write (CR 616.1e)

Review on phase-rs#5802: the virtual granted-sunburst candidate was classified
`Disjoint` in `candidate_materiality`, but its applier appends to the same
event counter payload a co-firing Count writer modifies — an append does not
commute with a doubler ((0+N)*2 vs 0*2+N), so `Disjoint` silently suppressed
the CR 616.1e affected-controller ordering choice. Reclassify as
`Writes { field: Count, commute: Additive }`: two appenders still commute
(no degenerate prompt), while an appender + any non-additive Count writer on
one event now surfaces the ordering prompt.

Integration regression (both fail on revert to `Disjoint` with "the CR
616.1e ordering prompt must surface"): granted sunburst + a same-event
Moved-keyed `quantity_modification(DOUBLE)` writer co-fire on the entering
spell's ZoneChange; the ordering prompt lists both candidates, and BOTH
legal orderings are driven end-to-end to a clean entry with the granted
payload intact.

Empirical note recorded in the test doc: the two orderings currently
converge (2 counters each) because a bare `quantity_modification` on a
Moved-keyed definition has no ZoneChange counter-payload applier yet — the
functioning Doubling Season path scales the downstream AddCounter placement
instead (asserted at 4 by `granted_sunburst_participates_in_counter_
doubling`). The reclassification pins the ordering machinery so that if a
ZoneChange payload applier lands later, only the expected totals change
(4 sunburst-first vs 2 writer-first), not the choice surface.

Full lib: 16549 passed. Integration: 3040 passed. Clippy clean.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…(Bloodlord of Vaasgoth, phase-rs#5802 review)

matthewevans's [MED] review: the virtual ETB replacement handled only granted
Sunburst, leaving the equivalent granted Bloodthirst path (Bloodlord of
Vaasgoth: "Whenever you cast a Vampire creature spell, it gains bloodthirst 3")
incorrect — a new special case beside an equally-broken keyword.

Generalize the machinery into a keyword-agnostic granted-ETB-keyword path
covering both Sunburst and Bloodthirst (and any future as-enters keyword):

- `GrantedEtbKeyword { Sunburst, Bloodthirst }` with per-keyword reserved
  virtual-candidate ids (`GRANTED_SUNBURST_INDEX = MAX-7`,
  `GRANTED_BLOODTHIRST_INDEX = MAX-8`) feeding a shared count/apply core;
  `rid.index` recovers the keyword.
- `granted_keyword_etb_instances` factors out the EFFECTIVE-minus-BASE keyword
  count (via `effective_off_zone_keywords`, since the entering spell is still
  on the stack); `granted_sunburst_instances` delegates to it,
  `granted_bloodthirst_instances` counts per distinct `BloodthirstValue`
  (mirroring `synthesize_bloodthirst`, CR 702.54c).
- `bloodthirst_replacement_definition` extracted in synthesis.rs (mirroring
  `sunburst_replacement_definition`); both the printed synthesizer and the
  virtual applier build per-instance definitions from the same authority.
- `apply_granted_keyword_etb_replacement` is keyword-agnostic and honors each
  definition's carried `condition` via `evaluate_replacement_condition` —
  Bloodthirst fixed-N only places counters when an opponent was dealt damage
  this turn (CR 702.54a); Sunburst has `condition: None` and always applies.
  `granted_etb_keyword_candidate_applies` re-checks the condition at
  registration so a condition-unmet grant raises no spurious CR 616.1e prompt.
- `find_applicable_replacements` registers both keywords on
  `ZoneChange`→Battlefield; materiality stays `Writes { Count, Additive }`.
- `keywords.rs`: Bloodthirst added to the instance-coexistence predicate so a
  granted instance survives beside an identical printed one (CR 702.54c).

Also addresses Gemini's nit: the applier no longer rebuilds
`ProposedEvent::ZoneChange` field-by-field — it mutates `enter_with_counters`
in place via `if let ProposedEvent::ZoneChange { enter_with_counters, .. } =
&mut event`, immune to new event fields.

Rebased onto current main (resolved the `usize::MAX - 6` index collision with
the new commander-return virtual candidate; `GRANTED_SUNBURST_INDEX` moved to
MAX-7).

Tests (integration, real cast/trigger/replacement pipeline): granted
Bloodthirst 3 with an opponent damaged this turn → 3 +1/+1 counters; NO
opponent damaged → 0 counters (condition revert-canary); end-to-end via
Bloodlord's real "it gains bloodthirst 3" trigger → 3 counters. All 9 existing
sunburst tests stay green.

Full lib: 16959 passed. Integration: 3349 passed. Clippy clean.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
@shin-core
shin-core force-pushed the fix-granted-sunburst-5337 branch from 23135a6 to af975c2 Compare July 18, 2026 01:14
@superagent-security superagent-security Bot added the contributor:flagged Contributor flagged for review by trust analysis. label Jul 18, 2026
@shin-core

Copy link
Copy Markdown
Contributor Author

Pushed af975c2c (rebased onto current main + review changes).

[MED, matthewevans] Generalized beyond Sunburst to granted Bloodthirst

The virtual ETB machinery is now keyword-agnostic, covering both granted Sunburst and granted Bloodthirst (Bloodlord of Vaasgoth: "Whenever you cast a Vampire creature spell, it gains bloodthirst 3"):

  • GrantedEtbKeyword { Sunburst, Bloodthirst } with per-keyword reserved ids feeding a shared count/apply core (granted_keyword_etb_instances, apply_granted_keyword_etb_replacement); a future third as-enters keyword is one more reserved index.
  • bloodthirst_replacement_definition extracted in synthesis.rs (mirroring sunburst_replacement_definition) — the printed synthesizer and the virtual applier build per-instance definitions from the same authority.
  • Bloodthirst's condition is honored: the fixed-N form only places counters when an opponent was dealt damage this turn (CR 702.54a). The shared applier routes each granted instance's carried condition through the same evaluate_replacement_condition seam the printed path uses; granted_etb_keyword_candidate_applies also gates at registration so a condition-unmet grant raises no spurious CR 616.1e ordering prompt.
  • Bloodthirst added to the instance-coexistence predicate (CR 702.54c) so a granted instance survives beside an identical printed one.

New tests (revert-probed): granted Bloodthirst 3 + opponent damaged → 3 counters; no opponent damaged → 0 counters (condition canary — disabling the condition check makes this place 3, and the test fails); end-to-end through Bloodlord's real trigger → 3 counters. All 9 sunburst tests stay green.

[Gemini] ProposedEvent::ZoneChange rebuild

The applier no longer reconstructs the struct field-by-field — it mutates enter_with_counters in place: if let ProposedEvent::ZoneChange { enter_with_counters, .. } = &mut event. No field is manually re-listed, so a new event field can't silently break it.

[HIGH, prior round] materiality

Already resolved on the current head — the candidate is Writes { field: Count, commute: Additive }, with both-orderings integration tests against a same-event counter writer (unchanged, still green).

Rebased onto current main (resolved a usize::MAX - 6 index collision with the new commander-return virtual candidate; GRANTED_SUNBURST_INDEX → MAX-7, Bloodthirst → MAX-8). Full lib 16959 / integration 3349 green, clippy clean.

@matthewevans

Copy link
Copy Markdown
Member

Current follow-up reviewed — prior request-changes findings are resolved on .\n\n## ✅ Clean\n\n- The virtual granted-ETB candidate is now an additive Count writer, so it remains order-material with non-additive counter writers; both ordering paths are exercised.\n- The virtual path is parameterized over granted Sunburst and Bloodthirst, with Bloodthirst's opponent-damaged condition covered through the real Bloodlord trigger.\n- Gemini's ZoneChange reconstruction concern is resolved by the current in-place payload mutation.\n\nRecommendation: hold pending the current required CI run, then approve/enqueue if it remains green.

@matthewevans

Copy link
Copy Markdown
Member

Correction: the reviewed current head is af975c2. The prior request-changes findings are resolved there; this remains held only for the current required CI run before approval/enqueue.

@matthewevans

Copy link
Copy Markdown
Member

Current source review is clean, but approval/enqueue is blocked by the required Contributor trust check, which is ACTION_REQUIRED and links to the existing superagent-security flagged-contributor notice. This sweep does not override required trust checks.

@matthewevans matthewevans removed their assignment Jul 18, 2026
matthewevans and others added 2 commits July 23, 2026 14:50
Maintainer-side bring-current. This branch was reviewed clean on 2026-07-18
at head af975c2 and the author has not touched it since; main has advanced
241 commits underneath it, which is what turned it DIRTY. The staleness is
maintainer-caused, so the port is ours.

This commit keeps the contributor's `clear_post_collection_transients` hunk
verbatim so the merge itself carries no judgement. The semantic resolution
follows in the next commit.
…in's cast-payment-stamp authority

Conflict resolution for the bring-current merge, isolated here so the
judgement is reviewable on its own.

This branch guarded `clear_post_collection_transients` so a spell still on
the Stack kept `mana_spent_to_cast` / `colors_spent_to_cast`, because a
GRANTED sunburst (Solar Array / Lux Artillery) enters AFTER the intervening
cast-triggered grant resolves and that clear runs — wiping the color tally
made it place zero counters (phase-rs#5337).

While this branch sat, main fixed the same underlying seam independently for
issue phase-rs#5943: `clear_post_collection_transients` now routes all five cast-
payment stamps through `GameObject::clear_cast_payment_stamps()` and calls it
ONLY for objects outside the Battlefield/Stack provenance zones. So
`colors_spent_to_cast` — the field granted Sunburst actually reads, via
`colors_spent_to_cast.distinct_colors()` — already survives on Stack objects
under main's authority. The remaining unconditional line clears only the
`mana_spent_to_cast` boolean, which main documents as a deliberate
per-collection transient and which this feature does not read.

Taking main's side therefore preserves this branch's behavior while keeping a
single authority for payment-stamp lifetime, instead of layering a second,
partly-redundant guard beside it.

Verified, not assumed: with main's side taken, all 12 of this branch's own
regressions pass — granted_sunburst_5337 (9) and granted_bloodthirst_5802 (3),
including lux_artillery_grants_sunburst_two_colors_enters_with_two_p1p1 and
solar_array_grants_sunburst_creature_three_colors_enters_with_three_p1p1,
which are exactly the cases that fail if stack color provenance is wiped.
Full `cargo test -p engine`: 21,496 passed, 0 failed.

Co-authored-by: shin-core <153108882+shin-core@users.noreply.github.com>
@coderabbitai

coderabbitai Bot commented Jul 23, 2026

Copy link
Copy Markdown
Contributor

Review Change Stack

📝 Walkthrough

Walkthrough

Granted Sunburst and Bloodthirst keywords now create virtual battlefield-entry counter replacements when granted to spells. Delayed grants rebind “that spell” correctly, while shared synthesis helpers and integration tests cover conditions, stacking, counter doubling, and replacement ordering.

Changes

Granted as-enters keyword handling

Layer / File(s) Summary
Keyword instance semantics and replacement definitions
crates/engine/src/types/keywords.rs, crates/engine/src/database/synthesis.rs, crates/engine/src/game/layers.rs, crates/engine/src/game/off_zone_characteristics.rs
Sunburst and Bloodthirst support independent keyword instances, with shared helpers producing their battlefield-entry replacement definitions and updated keyword contribution handling.
Delayed grant target rebinding
crates/engine/src/parser/oracle_effect/mod.rs, crates/engine/src/parser/oracle_effect/tests.rs
Delayed “that spell” grants rebind generic-effect subjects to TriggeringSource, with parser coverage for granted Sunburst.
Virtual granted replacement runtime
crates/engine/src/game/replacement.rs
Reserved IDs identify granted instances, generate and apply their counter replacements, label candidates, and classify replacement ordering.
Integration validation
crates/engine/tests/integration/granted_bloodthirst_5802.rs, crates/engine/tests/integration/granted_sunburst_5337.rs, crates/engine/tests/integration/main.rs
Tests cover Bloodthirst conditions, Sunburst counter behavior, coexistence, doubling, ordering, and harness registration.

Estimated code review effort: 4 (Complex) | ~60 minutes

Sequence Diagram(s)

sequenceDiagram
  participant SpellOnStack
  participant ReplacementCandidates
  participant GrantedEtbReplacement
  participant Battlefield
  SpellOnStack->>ReplacementCandidates: expose granted keyword ETB candidates
  ReplacementCandidates->>GrantedEtbReplacement: apply matching virtual candidate
  GrantedEtbReplacement->>SpellOnStack: add per-instance counter groups
  SpellOnStack->>Battlefield: enter with counters
Loading

Suggested labels: quality

Suggested reviewers: matthewevans, dripsmvcp

🚥 Pre-merge checks | ✅ 5
✅ Passed checks (5 passed)
Check name Status Explanation
Linked Issues check ✅ Passed The changes address #5337 by attaching granted Sunburst to the next spell, applying it on entry, and computing counters from mana colors spent.
Out of Scope Changes check ✅ Passed The extra Bloodthirst and keyword-coexistence work appears to support the same granted ETB replacement machinery rather than unrelated functionality.
Docstring Coverage ✅ Passed Docstring coverage is 100.00% which is sufficient. The required threshold is 80.00%.
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title clearly and concisely describes the main change: fixing dynamically granted Sunburst to apply at battlefield entry.
✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests

Comment @coderabbitai help to get the list of available commands.

@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.

Approved — current head 44e2119c11 (maintainer port of af975c2c4).

Clearing the stale CHANGES_REQUESTED state. Both request-changes reviews (the CR 616.1e ordering-materiality blocker on 07-14, and the generalize-beyond-Sunburst finding on 07-16) were confirmed resolved on af975c2c4 in the 2026-07-18 follow-up; every review after that point was a hold on pending CI, not on a code finding. The formal state simply never got cleared.

Staleness was ours, so the port was ours

This branch was reviewed clean on 2026-07-18 and @shin-core has not touched it since. main then advanced 241 commits underneath it, which is what turned it DIRTY. That is maintainer-caused staleness, so the maintainer-fixup size cap does not apply and the rebase is our work, not the author's. Pushed as two commits, with every one of @shin-core's commits keeping its original author identity:

  • d1e2a8f530Merge branch 'main', taking the contributor's hunk verbatim so the merge itself carries no judgement.
  • 44e2119c11 — the one conflict resolution, isolated for review, trailing Co-authored-by: @shin-core.

The conflict, and why it resolved this way. The single conflict was in clear_post_collection_transients (game/triggers.rs). This branch guarded that clear so a spell still on the Stack kept mana_spent_to_cast / colors_spent_to_cast, because a granted Sunburst enters after the intervening cast-triggered grant resolves and that clear runs. While the branch sat, main fixed the same seam independently for issue #5943: the clear now routes all five payment stamps through GameObject::clear_cast_payment_stamps() and calls it only for objects outside the Battlefield/Stack provenance zones. So colors_spent_to_cast — the field granted Sunburst actually reads, via distinct_colors() — already survives on Stack objects under main's authority. The remaining unconditional line clears only the mana_spent_to_cast boolean, which main documents as a deliberate per-collection transient and which this feature never reads. Taking main's side keeps one authority for payment-stamp lifetime instead of layering a second, partly-redundant guard beside it.

Verified, not assumed. With main's side taken, all 12 of this branch's own regressions pass — granted_sunburst_5337 (9) and granted_bloodthirst_5802 (3), including lux_artillery_grants_sunburst_two_colors_enters_with_two_p1p1 and solar_array_grants_sunburst_creature_three_colors_enters_with_three_p1p1, which are precisely the cases that fail if stack color provenance is wiped. Full cargo test -p engine in an isolated worktree: 21,496 passed, 0 failed. cargo fmt --all and git diff --check clean. mergeable has flipped CONFLICTINGMERGEABLE.

Security re-read (contributor is trust-flagged)

Contributor trust is ACTION_REQUIRED from the superagent-security app, so I re-read the complete current diff rather than relying on the earlier pass. It is not in main's branch-protection contexts (which require only Rust (fmt, clippy, test, coverage-gate) and Frontend (lint, type-check, test)) and not in the aggregator's needs: list, so it does not gate the queue. What I checked, explicitly:

  • Paths — 8 files, all under crates/engine/. No .github/workflows/, no .github/actions/, no .claude/, no CLAUDE.md/AGENTS.md/AI-CONTRIBUTOR.md, no package.json, no build.rs, no .gitignore, no Cargo.toml. No binaries, no gitlinks/submodules.
  • Capability surface — grepped every added line for std::env/env!, std::process/Command::new, any HTTP/socket client, std::fs/File::open/write_all/remove_file, unsafe, include_bytes!/include_str!, base64/decode, and token/secret/credential strings. Zero hits. The only panic!s are expected-shape assertions inside tests.
  • Prompt injection — no instructions addressed to a reviewing LLM, no fake <system> tags, no attempts to redefine project rules in comments, docs, or fixtures.
  • Scope — the diff matches the stated scope. The one parser change (oracle_effect/mod.rs) is narrow and on-topic: it lifts a ParentTarget anaphor to TriggeringSource for GenericEffect grants inside a WhenNextEvent body, which is Solar Array's "when you next cast" form — the issue under fix. The sticky parse-diff reports no card-parse changes.

Nothing malicious or out-of-scope. The flag is not corroborated by the diff.

Architecture

Right seam and idiomatic. GrantedEtbKeyword { Sunburst, Bloodthirst } is a parameterized enum feeding one shared count/apply core (granted_keyword_etb_instances / apply_granted_keyword_etb_replacement) rather than two duplicated keyword paths — a future third as-enters keyword is one more reserved index. It reuses the existing virtual-ID protocol already shared by intrinsic shield/finality/compleated, routes conditions through the same evaluate_replacement_condition seam the printed path uses, and reads keywords through effective_off_zone_keywords (the single authority for stack objects, whose keywords are not yet materialized). CR annotations are present and specific throughout.

One non-blocking observation, recorded rather than actioned: Keyword::sums_across_instances() now covers two distinct reasons — parameter summation (Toxic, CR 702.164b) and instance-count multiplicity (Sunburst/Bloodthirst, CR 702.44d / 702.54c). The doc comment states this explicitly and both are served by the same "don't dedup identical instances" mechanic, so the behavior is right, but the name now under-describes the predicate. A rename to something like instances_must_coexist would fit better. I deliberately did not do it in this port: it touches call sites beyond the conflict and would widen the blast radius of a rebase that is already carrying 241 commits of drift.

@coderabbitai coderabbitai 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.

Actionable comments posted: 1

🧹 Nitpick comments (1)
crates/engine/src/types/keywords.rs (1)

1749-1754: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick win

sums_across_instances name no longer matches half its own semantics.

The doc block itself distinguishes two disjoint reasons a keyword belongs here: Parameter-value summation (CR 702.164b): the aggregate reader sums each instance's parameter (a creature's total toxic value). Only Toxic sums today... Instance-count multiplicity (CR 702.44d + CR 702.54c): an as-enters static ability where "each instance works separately"... A GRANTED Sunburst... or GRANTED Bloodthirst... on top of an identical printed one must coexist so both the printed object-carried replacement AND the granted virtual replacement... fire. Sunburst/Bloodthirst don't "sum" anything — they coexist as separate, independently-firing replacement instances. The name only accurately describes the Toxic case; callers reading sums_across_instances() at the single call site in layers.rs will reasonably assume it always implies numeric summation.

Consider renaming to something like coexists_when_granted (or similar) to match the umbrella concept the doc already describes, since the function now serves two distinct rules purposes under one label.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@crates/engine/src/types/keywords.rs` around lines 1749 - 1754, Rename
Keyword::sums_across_instances and its single caller in layers.rs to an umbrella
name such as coexists_when_granted that covers both Toxic parameter aggregation
and Sunburst/Bloodthirst instance multiplicity. Update the associated
documentation and all references consistently, preserving the existing keyword
matching behavior.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@crates/engine/src/database/synthesis.rs`:
- Around line 7226-7243: The granted Sunburst replacement path must derive its
object types from the printed/base snapshot rather than live `card_types`.
Update the logic around `sunburst_replacement_definition` and its
granted-keyword caller (`granted_sunburst_replacements`) to use
`base_card_types` or the appropriate face-specific base types, matching the
printed `synthesize_sunburst` behavior while preserving separate replacement
instances.

---

Nitpick comments:
In `@crates/engine/src/types/keywords.rs`:
- Around line 1749-1754: Rename Keyword::sums_across_instances and its single
caller in layers.rs to an umbrella name such as coexists_when_granted that
covers both Toxic parameter aggregation and Sunburst/Bloodthirst instance
multiplicity. Update the associated documentation and all references
consistently, preserving the existing keyword matching behavior.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: CHILL

Plan: Pro Plus

Run ID: 27c773b6-7c83-4368-b53b-3fdeb852417f

📥 Commits

Reviewing files that changed from the base of the PR and between 21a53d5 and 44e2119.

📒 Files selected for processing (8)
  • crates/engine/src/database/synthesis.rs
  • crates/engine/src/game/replacement.rs
  • crates/engine/src/parser/oracle_effect/mod.rs
  • crates/engine/src/parser/oracle_effect/tests.rs
  • crates/engine/src/types/keywords.rs
  • crates/engine/tests/integration/granted_bloodthirst_5802.rs
  • crates/engine/tests/integration/granted_sunburst_5337.rs
  • crates/engine/tests/integration/main.rs

Comment thread crates/engine/src/database/synthesis.rs
@matthewevans

Copy link
Copy Markdown
Member

Not enqueued — blocked by a maintainer-side breakage on main, not by anything in this PR.

main at 21a53d50cb ("release: v0.35.2") bumped client/src-tauri/Cargo.toml to 0.35.2 without regenerating client/src-tauri/Cargo.lock, which still records phase-tauri 0.35.1. The Tauri compile check job runs cargo check --locked and fails:

error: cannot update the lock file .../client/src-tauri/Cargo.lock because --locked was passed to prevent this

That job is a needs: dependency of the required aggregator Rust (fmt, clippy, test, coverage-gate) (.github/workflows/ci.yml), so it fails the aggregator and hard-blocks the queue — even though GitHub reports isRequired: false for the job itself. The merge queue is currently failing every batch on exactly this: merge-queue run 30047324483 shows Tauri compile check :: failureRust (fmt, clippy, test, coverage-gate) :: failure.

Bringing this branch current necessarily inherited that breakage — before the port, this PR's base predated the release commit, so its Tauri check passed. Enqueuing now would just get the PR bounced and would misattribute a maintainer failure to your work, so I'm holding it here instead.

State: approved, MERGEABLE, conflicts resolved. It should go straight into the queue once main's lock is regenerated. Nothing further is needed from you.

Local verification on this head, for the record: full cargo test -p engine = 21,496 passed, 0 failed; cargo fmt --all and git diff --check clean; the Card data (generate, validate, coverage) check is green on this head with no card-parse changes.

@matthewevans matthewevans self-assigned this Jul 23, 2026
…gate keyword sweep

Maintainer review fixups on top of phase-rs#5802.

CR 702.44a rules defect: `granted_etb_replacement_definitions` branched the
Sunburst counter type on `obj.card_types`, the LIVE layer result. The rule says
sunburst applies "if this object is entering as a creature, IGNORING ANY
TYPE-CHANGING EFFECTS that would affect it", and Layer-6 type effects do reach
stack objects (`remote_type_layer_recipients`) — which is the zone this check
runs in. Branch on `base_card_types` instead, the same printed
`card_face.card_type` the printed synthesizer (`synthesize_sunburst`) uses, so
the granted and printed paths agree. Adds the first fixture whose printed and
live core types DIVERGE; all 12 existing fixtures set
`base_card_types == card_types`, so this arm was previously unexercised and the
defect was invisible.

Perf on the `find_applicable_replacements` hot path (states are cloned and
replayed constantly under AI search): the `ZoneChange`→Battlefield gate ran a
full off-zone keyword sweep per keyword family, each one a whole-game
continuous-effect collect plus ordering and per-effect filter evaluation, and
the guard was ordered expensive-term-first. Test the cheap `already_applied`
set lookup first, and resolve the live keyword list at most once (lazily) and
thread it through the family helpers so one sweep serves every family.

Also renames `Keyword::sums_across_instances` to `instances_must_coexist` —
all three call sites implement "don't dedup identical instances" and none sums
a parameter, matching the predicate's own doc — and repoints a dangling doc
reference to `granted_sunburst_replacements`, a symbol that does not exist.

Co-authored-by: shin-core <153108882+shin-core@users.noreply.github.com>
@matthewevans matthewevans added enhancement New feature or request and removed bug Bug fix labels Jul 23, 2026
@matthewevans
matthewevans enabled auto-merge July 23, 2026 22:53
@matthewevans

Copy link
Copy Markdown
Member

Maintainer fixups pushed44e2119c11f0b3f2270b, in a separate commit with you retained as Co-authored-by:. Everything the review verified stands (20/20 CR numbers grep-verified, all three Oracle texts verbatim against live Scryfall, correct seam, correct GrantedEtbKeyword{Sunburst, Bloodthirst} parameterization); these were fixup-sized, so they were applied here rather than sent back for another round.

1. CR 702.44a — branch on PRINTED, not live, core types (rules defect)

granted_etb_replacement_definitions selected the Sunburst counter type from obj.card_types:

// CR 702.44a: branch on the entering object's current core types.
.filter(|obj| obj.card_types.core_types.contains(&CoreType::Creature))

docs/MagicCompRules.txt:4336 reads: "Sunburst … means 'If this object is entering as a creature, ignoring any type-changing effects that would affect it, it enters with a +1/+1 counter…'"

card_types is definitionally the live layer result — layers.rs:1672 reseeds it from base_card_types, layers.rs:6356 applies type-changing effects, and those effects do reach off-battlefield objects via remote_type_layer_recipients, which is exactly the zone this check runs in. So the old branch honored precisely the effects the rule orders it to ignore. Your own printed-path authority already had this right — synthesis.rs:7206 uses face.card_type with the annotation "branch on the printed (characteristic-defining) core types, ignoring type-changing effects".

Now reads base_card_types, which printed_cards.rs seeds from the same card_face.card_type the printed synthesizer branches on, so the two paths are identical by construction.

This also confirms CodeRabbit's inline finding at synthesis.rs:7226-7243 — it independently reached the same conclusion. Resolved at head.

The fix needed a test to be real. All 12 existing fixtures set obj.base_card_types = obj.card_types.clone(), so printed and live never diverged and the defect was invisible. Added granted_sunburst_ignores_type_changing_effect_and_branches_on_printed_types: a printed noncreature artifact whose live types include Creature, asserting 2 charge / 0 +1/+1. Reverting the branch makes it 0 charge / 2 +1/+1. It also carries an explicit non-vacuity guard asserting the divergence is actually present at cast time, so if a future change ever re-seeds stack objects from printed types the guard fires instead of the test quietly going green.

2. Hot-path cost in find_applicable_replacements

The ZoneChange→Battlefield gate looped [Sunburst, Bloodthirst] and ran a full off-zone keyword sweep per familygranted_etb_keyword_candidate_appliesgranted_*_instanceseffective_off_zone_keywordscollect_shared_active_continuous_effects, i.e. a whole-game continuous-effect collect plus Vec alloc, ordering, and per-effect filter evaluation, each time. The guard was also ordered expensive-term-first (candidate_applies(...) && !already_applied(...)).

Two changes: the cheap already_applied set lookup now short-circuits first, and the live keyword list is resolved at most once (lazily, so an all-applied event pays nothing) and threaded through the family helpers as live_keywords: &[Keyword] — one sweep serves every family instead of one per family. find_applicable_replacements is on the AI-search path where states are cloned and replayed constantly.

3–4. Naming and docs

  • Keyword::sums_across_instancesinstances_must_coexist (3 call sites updated). All three implement "don't dedup identical instances"; none sums a parameter. The predicate's own doc block already headlines "instances must COEXIST" — only the name disagreed. This is CodeRabbit's keywords.rs:1749-1754 nitpick; the chosen name tracks the existing doc wording rather than introducing a third term. Resolved at head.
  • Dangling doc reference to granted_sunburst_replacements (no such symbol) repointed at the real granted_sunburst_instancesgranted_etb_replacement_definitions chain.

5. PR description

The body claimed Bloodthirst was "kept out of scope" while af975c2c ships it with its own integration file. Since the body becomes the squash commit message, it has been corrected.


Nothing here touches client/. Any Tauri/Rust fan-in redness is maintainer-caused, not yours: release commit 21a53d50cb left the nested client/src-tauri/Cargo.lock at 0.35.1 against Cargo.toml 0.35.2; #6568 fixes it.

Thanks for this one — the GrantedEtbKeyword parameterization (rather than a second Sunburst-shaped path for Bloodthirst) is the right call, and the condition-gate routing through the shared evaluate_replacement_condition seam is exactly the house pattern.

@coderabbitai coderabbitai 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.

🧹 Nitpick comments (1)
crates/engine/src/game/off_zone_characteristics.rs (1)

377-377: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick win

Update the off-zone dedup comment crates/engine/src/game/off_zone_characteristics.rs:371-377instances_must_coexist() now keeps Toxic, Sunburst, and Bloodthirst instances, but this note still only describes Toxic’s summing behavior under CR 702.164b. Rewrite it to match the full contract and add a regression test for a granted non-Toxic coexisting keyword.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@crates/engine/src/game/off_zone_characteristics.rs` at line 377, Update the
comment immediately above the instances_must_coexist() check to document that
Toxic, Sunburst, and Bloodthirst instances are retained, rather than describing
only Toxic’s CR 702.164b summing behavior. Add a regression test covering a
granted non-Toxic coexisting keyword, using the existing off-zone
characteristics test helpers and asserting that its instances are preserved.

Source: Path instructions

🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Nitpick comments:
In `@crates/engine/src/game/off_zone_characteristics.rs`:
- Line 377: Update the comment immediately above the instances_must_coexist()
check to document that Toxic, Sunburst, and Bloodthirst instances are retained,
rather than describing only Toxic’s CR 702.164b summing behavior. Add a
regression test covering a granted non-Toxic coexisting keyword, using the
existing off-zone characteristics test helpers and asserting that its instances
are preserved.

ℹ️ Review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: CHILL

Plan: Pro Plus

Run ID: c8416d27-94a7-403e-8635-61e41d0694d8

📥 Commits

Reviewing files that changed from the base of the PR and between 44e2119 and f0b3f22.

📒 Files selected for processing (6)
  • crates/engine/src/database/synthesis.rs
  • crates/engine/src/game/layers.rs
  • crates/engine/src/game/off_zone_characteristics.rs
  • crates/engine/src/game/replacement.rs
  • crates/engine/src/types/keywords.rs
  • crates/engine/tests/integration/granted_sunburst_5337.rs
🚧 Files skipped from review as they are similar to previous changes (3)
  • crates/engine/src/database/synthesis.rs
  • crates/engine/src/game/replacement.rs
  • crates/engine/tests/integration/granted_sunburst_5337.rs

@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.

Re-approving on f0b3f227 to refresh the approval onto the current head.

The earlier approval was recorded against 44e2119c (22:14). The maintainer fixup f0b3f227 (22:50) landed afterwards, and since auto-merge is armed, that left an approval covering a head that no longer exists. Approval freshness attaches to a head, not to a PR number, so this re-approval closes that gap rather than letting the stale one carry.

Verified independently before re-approving, not taken on trust from the fixup's own commit message:

  • CR 702.44a is verbatim at docs/MagicCompRules.txt:4336"If this object is entering as a creature, ignoring any type-changing effects that would affect it…" The fixup's diagnosis holds: branching the counter type on obj.card_types reads the live layer result, and Layer-6 type effects do reach stack objects via remote_type_layer_recipients, which is the zone this check runs in. base_card_types is the correct axis, and it makes the granted path agree with synthesize_sunburst's printed-type read instead of diverging from it.
  • The new fixture is the one that matters. All 12 pre-existing fixtures set base_card_types == card_types, so this arm was structurally unexercised — the defect could not have been observed by the existing suite regardless of coverage numbers. Adding the first fixture whose printed and live core types diverge is what turns this from an assertion into evidence.
  • The sums_across_instancesinstances_must_coexist rename is complete. Five occurrences across types/keywords.rs (definition + doc cross-reference), game/layers.rs (two call sites), and game/off_zone_characteristics.rs (one); the commit's per-file line counts account for exactly those five. It is a method rename, so any missed call site is a compile error rather than a silent behavior change — the compiler is the census here. The new name is also the more accurate one: all three call sites implement "don't dedup identical instances" and none sums a parameter.

The perf change on find_applicable_replacements — testing the cheap already_applied set membership before the keyword sweep, and resolving the live keyword list lazily once instead of per keyword family — is on a genuinely hot path (states are cloned and replayed constantly under AI search) and is a reordering plus memoization, not a semantic change.

This stays behind the queue's own required checks; the re-approval is about the review record being accurate, not about bypassing CI.

@matthewevans matthewevans removed their assignment Jul 24, 2026
@matthewevans
matthewevans added this pull request to the merge queue Jul 24, 2026
Merged via the queue into phase-rs:main with commit e3d6b71 Jul 24, 2026
13 of 14 checks passed
jsdevninja pushed a commit to jsdevninja/phase that referenced this pull request Jul 24, 2026
…(Solar Array phase-rs#5337) (phase-rs#5802)

* fix(engine): honor dynamically granted Sunburst at battlefield entry (Solar Array phase-rs#5337)

"That spell gains sunburst" (Solar Array) and "it gains sunburst" (Lux
Artillery) placed ZERO as-enters counters. Three stacked defects, fixed per
layer:

1. Parser — the grant never landed (Solar Array). In a "when you next cast
   a <spell> this turn" delayed trigger, the subject-position "that spell"
   anaphor lowered to `affected: ParentTarget`; a `WhenNextEvent` delayed
   trigger has no parent target, so `register_transient_effect` bound the
   grant against the empty chain-tracked set and silently dropped it. A new
   lift (`lift_generic_effect_parent_target_to_triggering_source_in_ability`,
   mirroring the oracle_trigger.rs lift family) rebinds GenericEffect grants
   in a WhenNextEvent body to `TriggeringSource` — the just-cast spell is the
   event source (CR 608.2k).

2. Engine — a landed grant still placed no counters (the class bug, also
   breaking Lux Artillery). Printed sunburst is realized as an object-carried
   ETB `ReplacementDefinition` pre-synthesized from the card face's PRINTED
   keywords (`synthesize_sunburst`); a granted keyword adds no replacement
   and nothing consulted live keywords at entry. A granted-instance VIRTUAL
   replacement candidate now surfaces in `find_applicable_replacements`
   (alongside the shield/finality-counter virtual candidates), built from the
   same shared `sunburst_replacement_definition` authority the printed
   synthesis uses, so it participates in normal CR 616 replacement ordering
   (Doubling Season doubles it). Granted instances are counted as EFFECTIVE
   minus BASE keywords via `effective_off_zone_keywords` — the entering spell
   is still on the STACK when its entry pipeline runs, and a granted keyword
   exists only as a continuous effect at that moment (the materialized
   keyword list is empty), so the off-zone authority is the only correct
   read. The base subtraction keeps printed instances on their carried
   definitions — printed + granted apply separately, exactly CR 702.44d.

3. Engine — cast-trigger resolution wiped the color provenance. Resolving an
   intervening cast trigger (Lux Artillery's own grant trigger) cleared
   `colors_spent_to_cast` on objects still on the STACK, erasing the color
   count before the granted spell entered. The clear now preserves live cast
   provenance for stack objects (CR 601.2h), mirroring how cast_from_zone is
   preserved.

Supporting changes: `Keyword` instance-coexistence predicate extended so a
granted Sunburst survives next to an identical printed instance (CR 702.44d
"each one works separately"; previously only Toxic's summation kept
duplicates); `synthesize_sunburst`'s per-instance definition extracted into
the shared `sunburst_replacement_definition` builder used by both the printed
synthesis and the virtual candidate.

Tests (integration, real activate/cast/trigger/replacement pipeline,
verbatim Oracle texts):
- Solar Array: creature cast for 3 colors -> 3 +1/+1; noncreature for 2 ->
  2 charge; zero colored mana -> 0 counters (CR 702.44b).
- Lux Artillery: 2 colors -> 2 +1/+1 (gap 2+3 canary).
- Printed control: 3 colors -> 3 charge (carried-definition path untouched).
- Printed + granted: 2 colors -> 4 charge (CR 702.44d separateness).
- Counter doubling: granted sunburst under an AddCounter doubler -> 4
  (CR 616 ordering through the virtual candidate).
- Parser shape: the delayed grant lowers `affected: TriggeringSource` with
  the Sunburst AddKeyword (gap 1 canary).

Full lib suite: 16549 passed, 0 failed. Integration: 3038 passed, 0 failed.

Fixes phase-rs#5337.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* style: restore the Magus Lucea Kane doc comment to its function (clippy empty-line-after-doc)

The phase-rs#5337 parser-shape test was inserted between an existing doc comment
and its function; reattach the comment.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix(engine): classify granted sunburst as an additive counter-payload write (CR 616.1e)

Review on phase-rs#5802: the virtual granted-sunburst candidate was classified
`Disjoint` in `candidate_materiality`, but its applier appends to the same
event counter payload a co-firing Count writer modifies — an append does not
commute with a doubler ((0+N)*2 vs 0*2+N), so `Disjoint` silently suppressed
the CR 616.1e affected-controller ordering choice. Reclassify as
`Writes { field: Count, commute: Additive }`: two appenders still commute
(no degenerate prompt), while an appender + any non-additive Count writer on
one event now surfaces the ordering prompt.

Integration regression (both fail on revert to `Disjoint` with "the CR
616.1e ordering prompt must surface"): granted sunburst + a same-event
Moved-keyed `quantity_modification(DOUBLE)` writer co-fire on the entering
spell's ZoneChange; the ordering prompt lists both candidates, and BOTH
legal orderings are driven end-to-end to a clean entry with the granted
payload intact.

Empirical note recorded in the test doc: the two orderings currently
converge (2 counters each) because a bare `quantity_modification` on a
Moved-keyed definition has no ZoneChange counter-payload applier yet — the
functioning Doubling Season path scales the downstream AddCounter placement
instead (asserted at 4 by `granted_sunburst_participates_in_counter_
doubling`). The reclassification pins the ordering machinery so that if a
ZoneChange payload applier lands later, only the expected totals change
(4 sunburst-first vs 2 writer-first), not the choice surface.

Full lib: 16549 passed. Integration: 3040 passed. Clippy clean.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* feat(engine): generalize the granted-ETB-keyword path to Bloodthirst (Bloodlord of Vaasgoth, phase-rs#5802 review)

matthewevans's [MED] review: the virtual ETB replacement handled only granted
Sunburst, leaving the equivalent granted Bloodthirst path (Bloodlord of
Vaasgoth: "Whenever you cast a Vampire creature spell, it gains bloodthirst 3")
incorrect — a new special case beside an equally-broken keyword.

Generalize the machinery into a keyword-agnostic granted-ETB-keyword path
covering both Sunburst and Bloodthirst (and any future as-enters keyword):

- `GrantedEtbKeyword { Sunburst, Bloodthirst }` with per-keyword reserved
  virtual-candidate ids (`GRANTED_SUNBURST_INDEX = MAX-7`,
  `GRANTED_BLOODTHIRST_INDEX = MAX-8`) feeding a shared count/apply core;
  `rid.index` recovers the keyword.
- `granted_keyword_etb_instances` factors out the EFFECTIVE-minus-BASE keyword
  count (via `effective_off_zone_keywords`, since the entering spell is still
  on the stack); `granted_sunburst_instances` delegates to it,
  `granted_bloodthirst_instances` counts per distinct `BloodthirstValue`
  (mirroring `synthesize_bloodthirst`, CR 702.54c).
- `bloodthirst_replacement_definition` extracted in synthesis.rs (mirroring
  `sunburst_replacement_definition`); both the printed synthesizer and the
  virtual applier build per-instance definitions from the same authority.
- `apply_granted_keyword_etb_replacement` is keyword-agnostic and honors each
  definition's carried `condition` via `evaluate_replacement_condition` —
  Bloodthirst fixed-N only places counters when an opponent was dealt damage
  this turn (CR 702.54a); Sunburst has `condition: None` and always applies.
  `granted_etb_keyword_candidate_applies` re-checks the condition at
  registration so a condition-unmet grant raises no spurious CR 616.1e prompt.
- `find_applicable_replacements` registers both keywords on
  `ZoneChange`→Battlefield; materiality stays `Writes { Count, Additive }`.
- `keywords.rs`: Bloodthirst added to the instance-coexistence predicate so a
  granted instance survives beside an identical printed one (CR 702.54c).

Also addresses Gemini's nit: the applier no longer rebuilds
`ProposedEvent::ZoneChange` field-by-field — it mutates `enter_with_counters`
in place via `if let ProposedEvent::ZoneChange { enter_with_counters, .. } =
&mut event`, immune to new event fields.

Rebased onto current main (resolved the `usize::MAX - 6` index collision with
the new commander-return virtual candidate; `GRANTED_SUNBURST_INDEX` moved to
MAX-7).

Tests (integration, real cast/trigger/replacement pipeline): granted
Bloodthirst 3 with an opponent damaged this turn → 3 +1/+1 counters; NO
opponent damaged → 0 counters (condition revert-canary); end-to-end via
Bloodlord's real "it gains bloodthirst 3" trigger → 3 counters. All 9 existing
sunburst tests stay green.

Full lib: 16959 passed. Integration: 3349 passed. Clippy clean.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* fix(engine): fold the granted-Sunburst stack-provenance guard into main's cast-payment-stamp authority

Conflict resolution for the bring-current merge, isolated here so the
judgement is reviewable on its own.

This branch guarded `clear_post_collection_transients` so a spell still on
the Stack kept `mana_spent_to_cast` / `colors_spent_to_cast`, because a
GRANTED sunburst (Solar Array / Lux Artillery) enters AFTER the intervening
cast-triggered grant resolves and that clear runs — wiping the color tally
made it place zero counters (phase-rs#5337).

While this branch sat, main fixed the same underlying seam independently for
issue phase-rs#5943: `clear_post_collection_transients` now routes all five cast-
payment stamps through `GameObject::clear_cast_payment_stamps()` and calls it
ONLY for objects outside the Battlefield/Stack provenance zones. So
`colors_spent_to_cast` — the field granted Sunburst actually reads, via
`colors_spent_to_cast.distinct_colors()` — already survives on Stack objects
under main's authority. The remaining unconditional line clears only the
`mana_spent_to_cast` boolean, which main documents as a deliberate
per-collection transient and which this feature does not read.

Taking main's side therefore preserves this branch's behavior while keeping a
single authority for payment-stamp lifetime, instead of layering a second,
partly-redundant guard beside it.

Verified, not assumed: with main's side taken, all 12 of this branch's own
regressions pass — granted_sunburst_5337 (9) and granted_bloodthirst_5802 (3),
including lux_artillery_grants_sunburst_two_colors_enters_with_two_p1p1 and
solar_array_grants_sunburst_creature_three_colors_enters_with_three_p1p1,
which are exactly the cases that fail if stack color provenance is wiped.
Full `cargo test -p engine`: 21,496 passed, 0 failed.

Co-authored-by: shin-core <153108882+shin-core@users.noreply.github.com>

* fix(engine): branch granted Sunburst on printed types; cut the entry-gate keyword sweep

Maintainer review fixups on top of phase-rs#5802.

CR 702.44a rules defect: `granted_etb_replacement_definitions` branched the
Sunburst counter type on `obj.card_types`, the LIVE layer result. The rule says
sunburst applies "if this object is entering as a creature, IGNORING ANY
TYPE-CHANGING EFFECTS that would affect it", and Layer-6 type effects do reach
stack objects (`remote_type_layer_recipients`) — which is the zone this check
runs in. Branch on `base_card_types` instead, the same printed
`card_face.card_type` the printed synthesizer (`synthesize_sunburst`) uses, so
the granted and printed paths agree. Adds the first fixture whose printed and
live core types DIVERGE; all 12 existing fixtures set
`base_card_types == card_types`, so this arm was previously unexercised and the
defect was invisible.

Perf on the `find_applicable_replacements` hot path (states are cloned and
replayed constantly under AI search): the `ZoneChange`→Battlefield gate ran a
full off-zone keyword sweep per keyword family, each one a whole-game
continuous-effect collect plus ordering and per-effect filter evaluation, and
the guard was ordered expensive-term-first. Test the cheap `already_applied`
set lookup first, and resolve the live keyword list at most once (lazily) and
thread it through the family helpers so one sweep serves every family.

Also renames `Keyword::sums_across_instances` to `instances_must_coexist` —
all three call sites implement "don't dedup identical instances" and none sums
a parameter, matching the predicate's own doc — and repoints a dangling doc
reference to `granted_sunburst_replacements`, a symbol that does not exist.

Co-authored-by: shin-core <153108882+shin-core@users.noreply.github.com>

---------

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
Co-authored-by: Matt Evans <matt.evans.dev@gmail.com>
Co-authored-by: matthewevans <matthewevans@users.noreply.github.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

contributor:flagged Contributor flagged for review by trust analysis. enhancement New feature or request

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Solar Array — The sunburst property isn't being applied to the artifacts cast after producing mana with it

2 participants