Skip to content

fix(engine): honor CR 702.16n protection Aura exemption (Flickering Ward #6499)#6542

Merged
matthewevans merged 12 commits into
phase-rs:mainfrom
andriypolanski:fix/6499-flickering-ward-protection-exemption
Jul 24, 2026
Merged

fix(engine): honor CR 702.16n protection Aura exemption (Flickering Ward #6499)#6542
matthewevans merged 12 commits into
phase-rs:mainfrom
andriypolanski:fix/6499-flickering-ward-protection-exemption

Conversation

@andriypolanski

@andriypolanski andriypolanski commented Jul 23, 2026

Copy link
Copy Markdown
Contributor

Closes #6499

Summary

  • Discord: Flickering Ward could not stay attached after choosing a color — choosing white granted protection from white and SBA CR 704.5m / CR 702.16c removed the Aura.
  • Model CR 702.16n/p via ProtectionDoesNotRemove on continuous protection grants; parser recovers the rider (including post-normalization ~); runtime honors per-grant exemptions in attachment_illegality.
  • Ordinary printed protection without a rider still removes matching Auras.

Test plan

  • cargo test -p engine --lib -- protection_chosen_color_drops_trailing_this_aura
  • cargo test -p engine --test integration -- flickering_ward
  • CI green

Summary by CodeRabbit

  • New Features

    • Added support for “doesn’t remove” protection riders to preserve qualifying Auras and attachments when the rider applies.
    • Protection exemptions now correctly respect attachments already matched at the start of an effect, while not exempting those that only become matched later.
    • Enhanced protection rider parsing for accurate continuous/static grants.
  • Bug Fixes

    • Fixed Flickering Ward (and similar effects) so eligible Auras/attachments are not removed incorrectly during evaluation and state-based actions.
    • Ensured later protection grants behave correctly even when earlier exemptions existed.
  • Tests

    • Added/updated integration and coverage tests for the above rule behavior.

…ard phase-rs#6499)

Stamp ProtectionDoesNotRemove on continuous protection grants (including
post-normalization `~`) and skip SBA removal for matching exempted attachments.

Co-authored-by: Cursor <cursoragent@cursor.com>
@superagent-security superagent-security Bot added the contributor:flagged Contributor flagged for review by trust analysis. label Jul 23, 2026
@superagent-security

Copy link
Copy Markdown

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

@coderabbitai

coderabbitai Bot commented Jul 23, 2026

Copy link
Copy Markdown
Contributor

Review Change Stack

Note

Reviews paused

It looks like this branch is under active development. To avoid overwhelming you with review comments due to an influx of new commits, CodeRabbit has automatically paused this review. You can configure this behavior by changing the reviews.auto_review.auto_pause_after_reviewed_commits setting.

Use the following commands to manage reviews:

  • @coderabbitai resume to resume automatic reviews.
  • @coderabbitai review to trigger a single review.

Use the checkboxes below for quick actions:

  • ▶️ Resume reviews
  • 🔍 Trigger review
📝 Walkthrough

Walkthrough

The change adds protection-exemption data to static definitions, parses “doesn’t remove” riders, tracks grant-start attachment snapshots, and applies exemptions during attachment legality checks. Tests cover Flickering Ward and CR 702.16p attachment behavior.

Changes

Flickering Ward protection exemption

Layer / File(s) Summary
Protection exemption model and struct migration
crates/engine/src/types/ability.rs, crates/engine/src/**, crates/phase-ai/src/**
Static definitions carry an optional protection exemption, while existing production and test literals initialize it to None.
Protection rider parsing
crates/engine/src/parser/oracle_static/*, crates/engine/src/parser/oracle_modal.rs
Oracle parsing recognizes “doesn’t remove” riders and attaches exemptions to continuous protection definitions.
Attachment protection evaluation
crates/engine/src/game/effects/attach.rs, crates/engine/src/game/game_object.rs, crates/engine/src/game/layers.rs
Attachment legality resolves protection grants, applies rider exemptions, and maintains grant-start snapshots for controlled attachments.
Flickering Ward regression coverage
crates/engine/tests/integration/issue_6499_flickering_ward_protection_exemption.rs, crates/engine/tests/integration/main.rs
Integration tests cover rider parsing, Aura retention, printed protection removal, attachment snapshots, late matching attachments, and subsequent protection grants.

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

Sequence Diagram(s)

sequenceDiagram
  participant OracleText
  participant StaticDefinition
  participant GameState
  participant attachment_illegality
  participant StateBasedActions
  OracleText->>StaticDefinition: parse protection rider
  StaticDefinition->>GameState: apply protection grant and exemption
  GameState->>attachment_illegality: evaluate attachment legality
  attachment_illegality->>GameState: read grant snapshots
  attachment_illegality-->>StateBasedActions: retain or remove attachment
Loading

Suggested reviewers: matthewevans

🚥 Pre-merge checks | ✅ 5
✅ Passed checks (5 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title is concise and accurately describes the main Flickering Ward protection exemption fix.
Linked Issues check ✅ Passed The PR implements the Flickering Ward Aura exemption so it stays attached after color choice, matching #6499.
Out of Scope Changes check ✅ Passed The changes all support the Flickering Ward protection-exemption fix and related CR 702.16 handling; no unrelated scope is evident.
Docstring Coverage ✅ Passed Docstring coverage is 100.00% which is sufficient. The required threshold is 80.00%.
✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests

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

@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

🤖 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/parser/oracle_static/anthem.rs`:
- Around line 1094-1100: Ensure every successful continuous-static construction
in the relevant parser flow passes through with_protection_does_not_remove,
including the preceding repeated-for-each branch that currently returns a bare
StaticDefinition. Prefer a shared final wrapper for all continuous-static return
paths so protection-rule behavior applies consistently to the full supported
card class.
🪄 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: f2034d64-cc86-4178-bc75-2a2d020e1c7d

📥 Commits

Reviewing files that changed from the base of the PR and between c9b9a3a and 9f6fad5.

📒 Files selected for processing (20)
  • PR_6499.md
  • crates/engine/src/ai_support/mod.rs
  • crates/engine/src/game/casting_tests.rs
  • crates/engine/src/game/coverage.rs
  • crates/engine/src/game/dungeon.rs
  • crates/engine/src/game/effects/attach.rs
  • crates/engine/src/game/effects/create_emblem.rs
  • crates/engine/src/parser/oracle_modal.rs
  • crates/engine/src/parser/oracle_static/anthem.rs
  • crates/engine/src/parser/oracle_static/keyword_grant.rs
  • crates/engine/src/parser/oracle_static/mod.rs
  • crates/engine/src/parser/oracle_static/tests.rs
  • crates/engine/src/types/ability.rs
  • crates/engine/tests/integration/issue_6499_flickering_ward_protection_exemption.rs
  • crates/engine/tests/integration/kaito_integration.rs
  • crates/engine/tests/integration/main.rs
  • crates/engine/tests/integration/najeela_extra_combat_grant_2898.rs
  • crates/phase-ai/src/features/aggro_pressure.rs
  • crates/phase-ai/src/policies/planeswalker_loyalty.rs
  • crates/phase-ai/src/policies/reactive_self_protection.rs

Comment thread crates/engine/src/parser/oracle_static/anthem.rs
@github-actions

github-actions Bot commented Jul 23, 2026

Copy link
Copy Markdown

Parse changes introduced by this PR

✓ No card-parse changes detected.

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

Blocked — the CR 702.16p (“already attached”) branch is modeled as a live condition rather than a one-time snapshot.

🔴 Blocker

crates/engine/src/game/effects/attach.rs:871 recomputes already_on_host every time attachment legality is checked. CR 702.16p instead says the exempt objects are those that were attached “when the protection effect starts to apply.” An Aura or Equipment that was attached before the grant but becomes the protected color later is therefore incorrectly retained by this code; it was not a matching object in the required start-time snapshot.

Model the eligible attachment identities when the particular protection grant begins to apply, and consult that per-grant snapshot in the shared attachment-legality authority. Add discriminating runtime coverage for (1) matching controlled attachments present at start, (2) an attachment that becomes matching later, and (3) a second protection instance of the same quality.

Source: CR 702.16p; the rule explicitly fixes the set at the effect-start boundary.

✅ Clean

I refuted the current CodeRabbit inline report: both the repeated and single for each branches in anthem.rs already pass through with_protection_does_not_remove on this head.

Request changes: preserve the one-time CR 702.16p attachment set, then provide the runtime regressions and fresh parse-diff/CI evidence.

andriy-polanski and others added 2 commits July 23, 2026 13:10
702.16p fixes exempt attachments when protection begins applying, not on
every later legality check. Record per-grant attachment IDs on the source,
refresh once from layer evaluation, and add regressions for start-time
retention, late-matching removal, and a second un-ridered grant.

Co-authored-by: Cursor <cursoragent@cursor.com>

@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/game/effects/attach.rs (1)

2685-2842: 🎯 Functional Correctness | 🔵 Trivial | ⚡ Quick win

Missing test coverage for the "you control" restriction on ControlledAttachmentsAlreadyAttached. Both new test suites exercise the grant-start snapshot, the "becomes matching later" case, and the second-un-ridered-grant case, but neither tests the controller restriction that capture_protection_start_attachment_snapshot actually enforces (attachment.controller != grant_controller) — the case where an already-attached Aura/Equipment is controlled by a different player than the grant source's controller and must therefore NOT be exempted, per Benevolent Blessing's actual wording ("Auras and Equipment you control...").

  • crates/engine/src/game/effects/attach.rs#L2685-L2842: add a cr_702_16p_* case where the equipment/aura attached to the host is controlled by a different PlayerId than the grant source, asserting it is still removed by SBA despite matching the protection quality and being attached at grant-start.
  • crates/engine/tests/integration/issue_6499_flickering_ward_protection_exemption.rs#L226-L445: add the same negative-case scenario using apply_host_protection_grant.
🤖 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/effects/attach.rs` around lines 2685 - 2842, Add
negative 702.16p coverage for the controller restriction: in
crates/engine/src/game/effects/attach.rs lines 2685-2842, add a cr_702_16p_*
test where the attached matching Equipment or Aura is controlled by a different
PlayerId than the protection grant source, and assert SBA removes the
attachment; add the equivalent scenario using apply_host_protection_grant in
crates/engine/tests/integration/issue_6499_flickering_ward_protection_exemption.rs
lines 226-445. Ensure both cases are attached at grant start and match the
protection quality, but are not exempted because control differs.

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.

Inline comments:
In `@crates/engine/src/game/effects/attach.rs`:
- Around line 968-998: Update the snapshot application loop in the
protection-start attachment handling to merge each new snapshot into the
existing per-host vector instead of overwriting it with insert. Use the host
entry in protection_start_exempt_attachments to append or extend snapshot
attachments while preserving snapshots from earlier grants for the same host_id.

---

Nitpick comments:
In `@crates/engine/src/game/effects/attach.rs`:
- Around line 2685-2842: Add negative 702.16p coverage for the controller
restriction: in crates/engine/src/game/effects/attach.rs lines 2685-2842, add a
cr_702_16p_* test where the attached matching Equipment or Aura is controlled by
a different PlayerId than the protection grant source, and assert SBA removes
the attachment; add the equivalent scenario using apply_host_protection_grant in
crates/engine/tests/integration/issue_6499_flickering_ward_protection_exemption.rs
lines 226-445. Ensure both cases are attached at grant start and match the
protection quality, but are not exempted because control differs.
🪄 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: d70946bc-9b70-400a-9cf0-2c9f2c74b66f

📥 Commits

Reviewing files that changed from the base of the PR and between a5d0baa and 3ffe952.

📒 Files selected for processing (4)
  • crates/engine/src/game/effects/attach.rs
  • crates/engine/src/game/game_object.rs
  • crates/engine/src/game/layers.rs
  • crates/engine/tests/integration/issue_6499_flickering_ward_protection_exemption.rs

Comment thread crates/engine/src/game/effects/attach.rs
@matthewevans matthewevans self-assigned this Jul 23, 2026
@matthewevans matthewevans added the bug Bug fix label Jul 23, 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.

Requested changes

P1 — Preserve the CR 702.16p snapshot per protection grant

refresh_protection_start_attachment_snapshots builds one ActiveGrant per matching AddKeyword(Protection), but stores only HashMap<host_id, attachment_ids> on the source. Its contains_key(&grant.host_id) guard therefore captures only the first protection grant for a source/host; protection_grant_exempts_attachment likewise cannot distinguish which grant is asking.

That is not a per-effect snapshot. For a supported continuous static that grants protection from red and blue with the rider, a red and a blue controlled attachment may both be present when the effect begins, but only the first quality is captured. The second matching attachment is incorrectly removed. Simply unioning vectors by host is also insufficient when separate same-source grants begin/end independently.

Please key the snapshot by the individual continuous-effect/protection-grant identity (plus host), and add a discriminating runtime test with two qualifying protection grants from the same source to the same host. The test should prove each attachment present at the relevant grant start is retained, and should fail with the current host-only key.

P1 — Cover the printed you control restriction

Add the negative CR 702.16p case: a matching Aura or Equipment controlled by the other player is already attached when the rider grant begins, but must still be removed/unattached. The implementation checks attachment.controller != grant_controller; there is no runtime assertion proving that boundary.

Scope hygiene

Remove PR_6499.md. It is generated implementation narrative duplicated by the PR description, not repository documentation requested for this change.

The parser and core Flickering Ward scenario otherwise trace through the intended shared seams, and current CI/parse-diff evidence is green. These mechanics/rules gaps block approval.

@matthewevans matthewevans removed their assignment Jul 23, 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.

Blocked — CR 702.16p snapshots are keyed to a source/host pair, not the individual protection effect.

🔴 Blocker

crates/engine/src/game/effects/attach.rs:897 groups active ControlledAttachmentsAlreadyAttached grants by source; attach.rs:971 treats any entry for a host as already snapshotted; and attach.rs:1028 looks the exemption up by only source + host. Two separate continuous protection effects from one source can therefore inherit each other’s snapshot. If the first effect captured an attachment and later stops, a second effect on the same host can exempt that attachment even though it was not matching when the second effect began.

CR 702.16p fixes the set “when the protection effect starts to apply” and separately says other protection instances apply normally: official Comprehensive Rules. The existing effect-identity vocabulary is StaticGateKey { source, def_index } at crates/engine/src/types/game_state.rs:6161.

Please key capture, pruning, and lookup by the specific active StaticDefinition effect plus host (not just source + host). Add a runtime regression where one same-source rider effect snapshots a blue attachment, stops, then a second white rider effect begins while the attachment is blue and the attachment later becomes white; SBA must remove it. Also add the controller-negative case CodeRabbit identified: a matching attachment controlled by the other player at effect start is not exempt.

✅ Clean

The earlier CodeRabbit parser thread is refuted on this head: both relevant continuous-static return paths in crates/engine/src/parser/oracle_static/anthem.rs:1094 and anthem.rs:1112 already apply with_protection_does_not_remove. The Oracle text for Flickering Ward, Spectra Ward, and Benevolent Blessing was independently verified with Scryfall.

Request changes: make the CR 702.16p snapshot effect-scoped and cover the two boundary cases above; then rerun the current-head checks.

Key attachment exemptions by (def_index, host) on the grant source rather
than source/host alone, store resolved quality with each snapshot so a
replaced effect at the same slot cannot inherit a prior capture, and add
regressions for same-source effect replacement and opponent control.

Co-authored-by: Cursor <cursoragent@cursor.com>

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

Blocked — the CR 702.16p snapshot key still conflates distinct protection effects inside one static definition.

🔴 Blocker

  1. [HIGH] Key the snapshot by the individual protection modification, not only its static-definition index and host. Evidence: crates/engine/src/game/effects/attach.rs:928 iterates every AddKeyword(Protection) modification, but attach.rs:999 stores each result under only (def_index, host_id); the later quality overwrites the earlier snapshot, and attach.rs:1066 can therefore no longer exempt the first protection effect. The parser deliberately emits multiple protection modifications in one static definition (crates/engine/src/parser/oracle_static/keyword_grant.rs:1959). Why it matters: an effect granting protection from two qualities with the 702.16p rider can incorrectly remove an attachment that was already attached when the first protection instance began applying. Suggested fix: include the protection modification index (or an equivalent stable per-effect identity) in the snapshot key and add a two-quality, two-attachment runtime test.

Recommendation: request changes; preserve a separate CR 702.16p snapshot for every protection instance before re-review.

@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

Caution

Some comments are outside the diff and can’t be posted inline due to platform limitations.

⚠️ Outside diff range comments (1)
crates/engine/src/game/effects/attach.rs (1)

899-1029: 🎯 Functional Correctness | 🟠 Major | ⚡ Quick win

Include the protection modification index in the snapshot key. expand_protection_parts can turn one grant into multiple AddKeyword(Protection(_)) entries, but refresh_protection_start_attachment_snapshots still keys them by (def_index, host_id). The later insert overwrites the earlier quality, so one set of already-attached auras/equipment loses its exemption and gets removed by the 702.16p check. Thread a per-modification discriminator through ActiveGrant, the snapshot map, and the lookup.

🤖 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/effects/attach.rs` around lines 899 - 1029, Update
refresh_protection_start_attachment_snapshots to include each protection
modification’s index in ActiveGrant and ProtectionEffectHostKey, using the index
from def.modifications while iterating protection entries. Propagate this
discriminator through active-key pruning, existing-snapshot lookup, capture
tuples, and map insertion so multiple expanded protections for the same
definition and host retain separate snapshots.

Source: Learnings

🤖 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/game/game_object.rs`:
- Around line 392-396: Implement a serde adapter for the
protection_start_exempt_attachments field in the relevant game-object
serialization code, following the existing custom adapter pattern used by other
tuple-keyed HashMap fields. Apply the adapter to the field’s serde attributes
while preserving its default and skip_serializing_if behavior, so populated
(usize, ObjectId) keys serialize and deserialize correctly.

---

Outside diff comments:
In `@crates/engine/src/game/effects/attach.rs`:
- Around line 899-1029: Update refresh_protection_start_attachment_snapshots to
include each protection modification’s index in ActiveGrant and
ProtectionEffectHostKey, using the index from def.modifications while iterating
protection entries. Propagate this discriminator through active-key pruning,
existing-snapshot lookup, capture tuples, and map insertion so multiple expanded
protections for the same definition and host retain separate snapshots.
🪄 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: 736dc0de-1792-4370-a362-978b6d107a78

📥 Commits

Reviewing files that changed from the base of the PR and between 9caefe3 and 156209b.

📒 Files selected for processing (3)
  • crates/engine/src/game/effects/attach.rs
  • crates/engine/src/game/game_object.rs
  • crates/engine/tests/integration/issue_6499_flickering_ward_protection_exemption.rs

Comment thread crates/engine/src/game/game_object.rs Outdated
@matthewevans

Copy link
Copy Markdown
Member

Blocked — the delta fixes the per-modification snapshot key correctly, but it lands CI red on a clippy error it introduced, and the regression test the last review asked for is non-discriminating.

Reviewed at head abc0d9020d735d7cc1716eedcce194fdf5bc168b.

🔴 Blocker

1. CI is red on a lint this delta introduced. crates/engine/src/game/effects/attach.rs:1035 — adding grant_mod_index pushed protection_grant_exempts_attachment from 7 to 8 parameters:

error: this function has too many arguments (8/7)
  --> crates/engine/src/game/effects/attach.rs:1035:1
   = note: `-D clippy::too-many-arguments` implied by `-D warnings`
error: could not compile `engine` (lib) due to 1 previous error

This is contributor-caused and specific to this commit, not churn from main — the Rust tests shards, Card data, WASM, and Tauri jobs are all green at this same head. Please bundle the grant identity (grant_source_id, grant_def_index, grant_mod_index) into a small struct rather than reaching for #[allow(clippy::too_many_arguments)]; that triple is already the snapshot key and passing it as one value removes the lint at its cause.

2. cr_702_16p_same_source_second_effect_does_not_inherit_first_snapshot cannot fail on revert. Evidence: attach.rs:2905 is the regression I requested at 9caefe3, but its reset_source_statics helper hand-clears the production map at attach.rs:2754:

obj.static_definitions.clear();
obj.base_static_definitions = Arc::new(Vec::new());
obj.base_characteristics_initialized = false;
obj.protection_start_exempt_attachments.clear();   // <-- the thing under test

Because the test empties protection_start_exempt_attachments itself before the white rider is applied, step 4's fresh capture is guaranteed by the helper, not by production. Revert the mod_index keying — or the pruning logic entirely — and this test still passes. It is asserting the helper's behavior.

The inherit-prevention path it claims to cover is the one where production keeps the key live: refresh_protection_start_attachment_snapshots prunes with retain(|key, _| active_keys.contains(key)), and (def_index, mod_index, host_id) is still in active_keys when only the quality changed, so the stale entry is retained. Please drive the second grant through the same production path without clearing the map by hand, and confirm the assertion flips when the keying change is reverted.

🟡 Non-blocking

3. The recapture guard was weakened from a quality check to a presence check. attach.rs:1006 now reads .contains_key(&key), where the prior head compared entry.resolved_quality == grant.resolved_pt. With the key held live by pruning, a grant whose resolved quality changes in place is never re-snapshotted: the stale entry survives, protection_grant_exempts_attachment denies the exemption while the qualities differ (attach.rs:1073), and the original snapshot silently revives if the quality returns to its first value. Not reachable through Flickering Ward — {W}: Return this Aura to its owner's hand gives a new object and therefore a new key — but it is reachable for any ProtectionTarget::ChosenColor or ChosenCardType resolved from a source that stays on the battlefield. Either restore the quality comparison or record why presence alone is the CR 702.16p-correct binding.

4. The required parse-diff evidence is non-discriminating for this change. The <!-- coverage-parse-diff --> sticky comment at the current head reports:

Parse changes introduced by this PR

✓ No card-parse changes detected.

An +88-line parser change that stamps a new rider onto three real cards should be visible there. Every protection_does_not_remove occurrence in crates/engine/src/game/coverage.rs is inside mod tests (12181–13941) — none in the projection path — so the instrument appears blind to the new field rather than reporting a genuine no-op. Please state which it is; a green parse-diff that structurally cannot see the change is not evidence that the blast radius is zero.

5. Mid-file use carrying a stale doc comment. attach.rs:849-850 — the doc comment still describes a type alias ("Composite key for a protection effect (StaticGateKey::def_index on the grant source) plus the host") but now sits above use crate::game::game_object::ProtectionEffectHostKey;, in the middle of the file. Imports belong at the top, and the comment no longer matches either the item or the three-element key.

✅ Clean

  • CR 702.16n is correctly cited and correctly quoted. Verified in docs/MagicCompRules.txt:4067: "Some Auras both give the enchanted creature protection from a quality and say 'this effect doesn't remove' either that specific Aura or all Auras. This means that the specified Auras aren't put into their owners' graveyards as a state-based action." CR 702.16p at :4069 likewise says the exempt set is fixed "when the protection effect starts to apply." Both annotations describe the code they sit on.
  • All three Oracle texts were verified against Scryfall and the parser arms match them verbatim. Flickering Ward: "This effect doesn't remove this Aura."Source, with both the raw phrase and the post-normalization ~ handled. Spectra Ward: "This effect doesn't remove Auras."Auras. Benevolent Blessing: "This effect doesn't remove Auras and Equipment you control that are already attached to it."ControlledAttachmentsAlreadyAttached. Longest-match ordering plus the leftover.trim().is_empty() guard in parse_does_not_remove_object correctly stops Benevolent Blessing collapsing into the bare Auras arm.
  • The P1 from 156209b is genuinely fixed with a discriminating test. The key is now (def_index, mod_index, host_id), and cr_702_16p_two_qualities_in_one_static_each_modification_snapshots_independently asserts distinct (0, 0, host) and (0, 1, host) snapshots and that both Equipment survive SBA. Under the old (def_index, host) key the second modification overwrote the first and the red Equipment was removed, so this test fails on revert. This is the right shape.
  • The integration test is registeredcrates/engine/tests/integration/main.rs:580 declares mod issue_6499_flickering_ward_protection_exemption;, so the 766 lines actually run. No false green.
  • The many +1/-0 files are not scope contamination. ai_support/mod.rs, dungeon.rs, create_emblem.rs, oracle_modal.rs, planeswalker_loyalty.rs, and aggro_pressure.rs each add only protection_does_not_remove: None, which the new non-defaulted StaticDefinition field requires at exhaustive struct literals. layers.rs adds the single required call into refresh_protection_start_attachment_snapshots. That is the correct seam.
  • PR_6499.md was removed as requested.

Recommendation: fix the clippy error at attach.rs:1035 by passing the grant identity as one struct, and make the same_source_second_effect regression exercise production pruning instead of clearing protection_start_exempt_attachments in the helper. Answer #4 in a comment so the parse-diff green is interpretable. The CR work, the per-modification keying, and the Oracle-text fidelity are all correct and do not need revisiting.

Bundle grant identity into ProtectionEffectHostKey to satisfy clippy,
re-snapshot when resolved protection quality changes at the same key,
and stop clearing snapshot maps in the inherit-prevention regression.

Co-authored-by: Cursor <cursoragent@cursor.com>
@andriypolanski
andriypolanski force-pushed the fix/6499-flickering-ward-protection-exemption branch from 362a5e3 to 7b85b13 Compare July 23, 2026 21:53
@matthewevans

Copy link
Copy Markdown
Member

All three prior blockers are resolved on this head — one new HIGH blocker remains (a serialized-surface contract), plus a note that your green parse-diff is structurally blind here. The CR work, the per-modification keying, and the Oracle-text fidelity are all correct; please don't re-litigate those.

Reviewed at head 362a5e3f (work commit d3ea81ca).

✅ Resolved since the last review

Prior finding Evidence
clippy too_many_arguments (8/7) Params bundled into ProtectionEffectHostKeyattach.rs:1034-1045. Rust lint (fmt, clippy, parser gate) = success.
cr_702_16p_same_source_second_effect_does_not_inherit_first_snapshot non-discriminating The helper and integration test no longer hand-clear protection_start_exempt_attachments; with the restored quality comparison, reverting get(&key).is_some_and(quality ==) back to contains_key(&key) now fails the step-4 assertion.
Recapture guard weakened to a presence check attach.rs:1002-1006 restores entry.resolved_quality == grant.resolved_pt.

🔴 Blocker

[HIGH] protection_start_exempt_attachments has a tuple key and no serde adapter — JSON state export will error whenever it is populated.

crates/engine/src/game/game_object.rs:398-401 declares HashMap<ProtectionEffectHostKey, ProtectionStartSnapshot> where the key is (usize, usize, ObjectId). serde_json requires map keys to serialize as strings; a tuple key serializes as a sequence, producing Error: key must be a string. The skip_serializing_if = "HashMap::is_empty" only covers the empty case.

Production reach: crates/engine-wasm/src/lib.rs:1469-1478 export_game_state_json() ("used by the engine worker to transfer state to AI workers for root parallelism") calls serde_json::to_string(...) and throws on error. The same shape backs PersistedGameState for undo/restore.

Trigger: only ProtectionDoesNotRemove::ControlledAttachmentsAlreadyAttached populates the map — i.e. Benevolent Blessing. With it on the battlefield, AI root-parallelism state transfer and undo snapshots fail.

Two in-repo precedents solve exactly this, one of them on GameObject itself:

  • crates/engine/src/types/game_state.rs:126mod tuple_key_map, "JSON requires string keys, so we serialize the tuple as "objectId_index"", applied via #[serde(with = "tuple_key_map")].
  • crates/engine/src/game/game_object.rs:401#[serde(with = "counter_map_serde")] on counters.

Suggested fix: a protection_exempt_map_serde module keyed "defIdx_modIdx_hostId" mirroring tuple_key_map, plus a GameState JSON round-trip test with the map populated.

CodeRabbit raised this on 2026-07-23T15:32:45Z and it is still unaddressed at head.

Honesty note: this was not execution-confirmed — Tilt owns the cargo target lock and I did not run cargo. The evidence is serde_json's documented key-must-be-string constraint plus the two existing in-repo adapters. Confidence high, not observed.

🟡 Non-blocking

  • The parse-diff cannot see this change, so its green result is vacuous. crates/engine/src/game/coverage.rs:4328 static_details() projects only affects, mods, conditional, CDA, zone — not protection_does_not_remove. The repo already documented and fixed this exact failure mode for replacements; see replacement_details' doc comment (~coverage.rs:4360). One-line fix: push ("protection exempt", format!("{x:?}")) in static_details. This is a pre-existing gap you inherited, not one you created. (The sticky is also stale — updated_at 19:11, i.e. head abc0d902.)
  • ProtectionDoesNotRemove::Auras (Spectra Ward) has zero test coverage — it appears once, at attach.rs:1056, and it is the shortest arm in a longest-match alt() chain (keyword_grant.rs:2014-2032) where ordering is load-bearing. An ordering regression would silently collapse Benevolent Blessing into it undetected.
  • with_protection_does_not_remove is stamped at 3 of ~7 continuous-static construction sites (anthem.rs:443, :1094, :1112) but not at :1027-1033, :487-494, :499-504, :551. Not reachable by any of the 15 real cards today, so this is latent — but per the single-authority principle the rider belongs at the continuous-static dispatch seam so new branches inherit it.
  • Mid-file use at attach.rs:845 — global CLAUDE.md requires all imports at the top of the file. Its doc comment also restates game_object.rs:352-353 verbatim.
  • Stale field doc at game_object.rs:395-397 still describes a StaticGateKey::def_index two-part key after the key became a 3-tuple.
  • oracle_static/tests.rs:28669 dropped a pre-existing negative assertion (the "trailing prose must not be swallowed into Protection(CardType(_))" guard) rather than keeping it alongside the new positives. Please restore it.

✅ Clean — verified, not assumed

  • CR annotations grep-verified against docs/MagicCompRules.txt: 702.16n (line 4067) and 702.16p (line 4069) both accurately describe the code they annotate, including the final clause of each, which is implemented and tested.
  • Oracle text re-verified live from Scryfall for Flickering Ward, Spectra Ward, and Benevolent Blessing — all three match the parser arms verbatim.
  • This is a genuine class. Scryfall oracle:"this effect doesn't remove" returns 15 cards; three enum variants cover all of them. Guardian Beast is correctly not misparsed — the leftover.trim().is_empty() guard at keyword_grant.rs:2035 rejects it.
  • Target/scope matrix at protection_blocks_attachment (attach.rs:721-815) is complete across all three reachable protection sources, with a !any_matching_grant fallback so grant-discovery misses cannot open a hole.
  • Discriminating runtime tests that drive the real parse_oracle_textevaluate_layerscheck_state_based_actions path and flip on revert, with a paired negative. The integration test is registered in tests/integration/main.rs — not an inert false-green.
  • The 12 +1/-0 files are compiler-forced exhaustive struct literals, not scope contamination, and the new-field threading is complete.

Recommendation

request-changes on the serde adapter only. Add the tuple-key serde module plus a GameState round-trip test with the map populated, following game_state.rs:126 tuple_key_map. If you're pushing anyway, the one-line static_details projection and a Spectra Ward test are cheap and high-leverage.

Your CI red is not your faultrelease: v0.35.2 bumped client/src-tauri/Cargo.toml without updating the separate client/src-tauri/Cargo.lock, breaking Tauri compile check (and through it the required Rust aggregator) on every open PR. #6568 fixes it on main.

The main merge bumped client/src-tauri/Cargo.toml to 0.35.2 but left the
lockfile package entry at 0.35.1, breaking CI's --locked compile check.

Co-authored-by: Cursor <cursoragent@cursor.com>
@matthewevans matthewevans self-assigned this Jul 23, 2026
@matthewevans

Copy link
Copy Markdown
Member

The HIGH serde blocker is resolved, and the parse-diff is now genuinely discriminating. One small test restore and this is good to go. Reviewed at head 60d62cbc0.

✅ The HIGH blocker is fixed

protection_exempt_map_serde exists at game_object.rs:361 and is actually wired#[serde(default, skip_serializing_if = "HashMap::is_empty", with = "protection_exempt_map_serde")] on the field at :472-478. I checked the attribute rather than just the module's existence; an adapter that isn't wired changes nothing.

It follows the tuple_key_map precedent (types/game_state.rs:126) and is stricter than it: the precedent uses split_once('_'), yours destructures four parts.next() calls and errors on a malformed 4-component key rather than silently mis-splitting.

Rejecting #[serde(skip)] was the right call, and your commit message gives the right reason — dropping the snapshot silently revives the CR 702.16p violation after a restore.

The round-trip test (tests/integration/issue_6499_flickering_ward_protection_exemption.rs:766-864) is properly discriminating: it populates the map through production evaluate_layers rather than hand-insertion, asserts the literal "0_0_<host>" key appears in the JSON, and asserts full round-trip equality. Best part is the explicit non-vacuity probe — asserting before.get(&(0,0,host)) is populated before serializing, because skip_serializing_if elides an empty map and would otherwise false-green. That's the failure mode most people miss. Registered at tests/integration/main.rs:580, so it actually runs.

✅ CR 702.16n / 702.16p verified

Both grep-verified and both describe the code they annotate:

  • CR 702.16n (docs/MagicCompRules.txt:4067) — "Some Auras both give the enchanted creature protection from a quality and say 'this effect doesn't remove' either that specific Aura or all Auras. This means that the specified Auras aren't put into their owners' graveyards as a state-based action."
  • CR 702.16p (:4069) — "One Aura (Benevolent Blessing) gives the enchanted creature protection from a quality and says the effect doesn't remove certain permanents that are 'already attached to' that creature… when the protection effect starts to apply…"

Worth recording why these are worth double-checking: the subrule sequence runs j, k, m, n, p — the CR skips l and o because they're visually confusable with 1 and 0. Any sequential guess invents a nonexistent 702.16l. You got them right.

✅ The parse-diff projection is the highest-leverage thing in this PR

coverage.rs:4350-4356 now projects ("protection_exempt", format!("{exempt:?}")) into static_details, and details is part of the canonical signature (bin/coverage_parse_diff.rs:91,103), so the instrument can finally see the layer this PR lives in.

Heads-up: the sticky currently rendered on this PR is stale and still says "✓ No card-parse changes detected." Don't read that green as evidence — its updated_at is 22:45:32Z, which is commit b3448b1f2a, before the projection landed at 22:47:51Z. The head CI run is still in flight so the workflow_run-gated comment hasn't reposted.

Pulled from the fresh artifact on the head run instead: 14 cards / 3 signatures against baseline 21a53d50cb — 12 × ∅ → Source (the Ward cycle plus Cho-Manno's Blessing, Floating Shield, Pledge of Loyalty, Pentarch Ward, Tattoo Ward, Ward of Lights), 1 × ∅ → Auras (Spectra Ward), 1 × ∅ → ControlledAttachmentsAlreadyAttached (Benevolent Blessing). That reconciles exactly with the 15-card Scryfall population (14 changed + 1 errata-excluded). Every change is a gain, added_cards and removed_cards are both empty — so no card flipped support status and coverage honesty is intact. Guardian Beast correctly absent.

Your other two non-blocking items are resolved too: the Spectra Ward Auras ordering test (oracle_static/tests.rs:28699-28726) is genuinely fail-on-revert — hoisting the Auras arm above ControlledAttachmentsAlreadyAttached makes alt match with non-empty leftover, and the leftover.trim().is_empty().then_some(exemption) guard at keyword_grant.rs:2033 turns that into None. And anthem.rs:1028 now matches :1095/:1113. Declining CodeRabbit's 75-site refactor was correctanthem.rs:487-504 and :551 are parse_additive_type_clause and parse_compound_turn_counter_animation, neither of which can carry a "gets/has protection … + trailing rider" shape.

🟡 One restore before I approve

protection_chosen_color_drops_trailing_this_aura_exemption lost its negative swallow guard. Measured both sides:

occurrences of "trailing prose must not be swallowed into Protection(CardType(_))"
origin/main 2 — at :28663 (Benevolent Blessing) and :28690 (this-Aura)
head 60d62cbc0 1 — only :28663 survives

So the Benevolent Blessing sibling kept its copy and the this-Aura test at :28671 was left with positives only. That's the path all 12 Source cards take.

This isn't a hypothetical failure mode — it's live in the parser today. From card-data.json:

  • Pledge of Loyalty → Protection(CardType("the colors of permanents you control"))
  • Spectra Ward → Protection(CardType("each color"))

Unrecognized trailing quality prose genuinely lands in CardType(String) right now, so a regression swallowing "this aura" into CardType("this aura") would go undetected across twelve cards. That evidence is why I'm rating this above the "non-blocking" I gave it last round. Re-add the two-line negative alongside your new positives — same test, no new file.

🟡 Two nits while you're in there

  • attach.rs:848use crate::game::game_object::ProtectionEffectHostKey; is still mid-file. CLAUDE.md requires imports at the top, and this is its second review.
  • game_object.rs:469-471 — the field doc still reads "Per [StaticGateKey::def_index] on this source and enchanted host", describing a two-part key, while the alias doc at :352-353 correctly says (static_definitions index, modifications index, host object id). The two docs on the same key now disagree, and the field doc is what a reader hits first.

📋 Not yours

b3448b1f2a (client/src-tauri/Cargo.lock, phase-tauri 0.35.1 → 0.35.2) is now redundant — main landed the identical fix in #6568 (cb7d5a25fa). Harmless, collapses on squash, no action.

And a follow-up worth filing but explicitly not a gate here: Spectra Ward's quality parses as ProtectionTarget::CardType("each color") rather than the CR 702.16h expansion ("protection from each [characteristic]" is shorthand for separate protection abilities per colour), so its new Auras exemption is inert at runtime. Pre-existing misparse, and since added_cards/removed_cards are empty this PR doesn't newly claim support for it.

CI: no terminal red at head. Both Rust test shards, lint, card-data, WASM, Tauri, frontend and both AI gates are green; the Rust (fmt, clippy, test, coverage-gate) aggregator is still queued rolling up its now-green children, and Contributor trust is action_required (my approval gate, not a failure). The earlier Tauri red is gone from both sides.


Recommendation: restore the negative assertion and I'll approve. The CR 702.16n/p work, the per-modification keying, and the Oracle-text fidelity are all correct — no need to revisit any of it.

@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 CR 702.16n / CR 702.16p modeling was already correct after your last round — the per-modification snapshot keying, the CR citations, the 15/15 class coverage, and the Oracle fidelity all held up under review. Thanks for turning this around across six rounds; the architecture landed in the right place.

I pushed two maintainer fixup commits rather than sending you back for a seventh round. Both are co-authored to you.

968df738 — the one real blocker. GameObject::protection_start_exempt_attachments is keyed by a (usize, usize, ObjectId) tuple. serde_json only accepts string-like and integer-like map keys; a tuple serializes as a sequence and is rejected outright. So the entire GameState failed to serialize the moment any rider grant was on the battlefield, breaking export_game_state_json (AI root-parallelism state transfer) and PersistedGameState (server persistence and undo/restore). skip_serializing_if = "HashMap::is_empty" is what hid it — the map is empty until a grant actually resolves, and attach.rs inserts an entry for every active rider grant even when attachment_ids is empty, so simply having Benevolent Blessing in play was enough to trip it.

Fixed with a protection_exempt_map_serde adapter encoding the key as "defIdx_modIdx_hostId", mirroring types::game_state::tuple_key_map (same default + skip_serializing_if + with attribute shape). Deserialization requires exactly three components, so a malformed key is an error rather than a silent truncation.

Worth naming explicitly for future reference: #[serde(skip)] would have been the wrong fix. It compiles and it makes the error go away, but it silently drops the CR 702.16p start-time snapshot across a restore — which revives the exact SBA violation this PR exists to fix, and does so invisibly.

Also in that commit:

  • coverage::static_details now projects protection_does_not_remove. Without this the <!-- coverage-parse-diff --> sticky was structurally blind to the entire layer this PR operates on, so its green carried no signal for these 15 cards.
  • A parser test for ProtectionDoesNotRemove::Auras (Spectra Ward), which had zero coverage and is the shortest arm of a longest-match alt() where ordering is load-bearing. The test also pins that ordering: it asserts the longer Benevolent Blessing rider does not truncate into the bare Auras arm.
  • An integration test round-tripping a populated snapshot map through serde_json. The map is populated through the real pipeline (evaluate_layers), and there is an explicit non-vacuity probe asserting it is non-empty before serializing — an empty-map round trip is elided by skip_serializing_if and would have passed against the broken code.

60d62cb — a CodeRabbit follow-up. The repeated-for each branch of parse_continuous_gets_has returned a bare StaticDefinition while its two siblings in the same function were wrapped in with_protection_does_not_remove. Now uniform. I declined the broader ask to route every continuous-static return through one wrapper — there are roughly 75 StaticDefinition::continuous() sites across oracle_static/ and none of the others parses a "gets/has" clause that could carry a trailing rider, so that refactor is out of proportion to this PR. Reasoning is in the resolved thread.

That second commit is behaviorally inert on the current pool: I enumerated all 15 cards carrying a This effect doesn't remove … rider against Scryfall and none uses repeated dynamic P/T. It is consistency insurance, not a live-defect fix, and I'd rather say that plainly than let it read as a bug that was caught.

One note that is on us, not you: the Tauri compile check red you hit came from our own release commit 21a53d50cb, which left client/src-tauri/Cargo.lock at 0.35.1 against a 0.35.2 Cargo.toml. Your b3448b1f2a fixed it correctly and that job is green now. That failure was never attributable to your work.


Verification on head 60d62cb. CI run 30051269883 completed green, every job: Rust lint (fmt, clippy, parser gate), Rust tests (shard 1/2), Rust tests (shard 2/2), Card data (generate, validate, coverage), WASM compile check, Tauri compile check, Frontend, Lobby worker, and the Rust (fmt, clippy, test, coverage-gate) aggregator. AI gate green.

Test collection moved 11787 → 11789 on shard 2/2 while shard 1/2 held at 11801 — +2 exactly, which is the two tests this fixup adds. That is the check that they were actually collected and run; nextest runs at --status-level fail, so a passing test prints nothing and a silently-uncollected test is indistinguishable from a passing one on the log alone.

And the parse-diff is now non-blind. It previously read "✓ No card-parse changes detected"; on this head it reports:

14 card(s), 3 signature(s) (baseline: main 21a53d50cbcb)

  • 12 cards · protection_exempt: Source — Black Ward, Blue Ward, Cho-Manno's Blessing (+9 more)
  • 1 card · protection_exempt: Auras — Spectra Ward
  • 1 card · protection_exempt: ControlledAttachmentsAlreadyAttached — Benevolent Blessing
  • 1 card excluded as Oracle-text errata

14 + 1 excluded = 15, matching the exact Scryfall population for oracle:"this effect doesn't remove". The blast radius is precisely the rider class and nothing else — no scope contamination — and all three enum arms are exercised on real cards. This also answers the open question from the earlier review round about whether that green parse-diff was a genuine no-op or a blind instrument: it was blind, and it isn't now.

@matthewevans matthewevans added enhancement New feature or request and removed bug Bug fix labels Jul 24, 2026
@matthewevans
matthewevans added this pull request to the merge queue Jul 24, 2026
@matthewevans matthewevans removed their assignment Jul 24, 2026
@github-merge-queue
github-merge-queue Bot removed this pull request from the merge queue due to a conflict with the base branch Jul 24, 2026
@andriypolanski
andriypolanski force-pushed the fix/6499-flickering-ward-protection-exemption branch from f6fa1e3 to 1d9ca6a Compare July 24, 2026 04:52
@matthewevans
matthewevans added this pull request to the merge queue Jul 24, 2026
Merged via the queue into phase-rs:main with commit 44cebb0 Jul 24, 2026
17 of 18 checks passed
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.

Flickering Ward — cannot attach after choosing a color

3 participants