Skip to content

fix(engine): gather copy-granted static abilities in the pass that copied them - #6932

Merged
matthewevans merged 7 commits into
phase-rs:mainfrom
mcbradd:podlab/copy-granted-static-same-pass
Aug 3, 2026
Merged

fix(engine): gather copy-granted static abilities in the pass that copied them#6932
matthewevans merged 7 commits into
phase-rs:mainfrom
mcbradd:podlab/copy-granted-static-same-pass

Conversation

@mcbradd

@mcbradd mcbradd commented Aug 2, 2026

Copy link
Copy Markdown
Contributor

Summary

A static ability a permanent has only because a layer-1 copy effect gave it never applied under full layer evaluation — not on the pass that applied the copy, and not on any later pass either. A token copying a lord ran as a permanent with no ability.

Two halves, both of CR 613.2c. The first revision closed the layers-2-7 half (refresh the generator index after copies, so an anthem granted by a copy fans out on that same pass). Maintainer review found the layer-1 half still open and self-perpetuating: a copy-granted static that itself generates a Layer::Copy effect was gathered into a bucket nothing ever applied. Layer 1 now runs to a fixed point.

Files changed

  • crates/engine/src/game/layers.rs — extend the post-layer-1 StaticSourceIndex refresh to cover copy effects, not only stickers; move it below the layer-1b face-down reseed; add copy_grants_continuous_static. Replace the single-shot layer-1 apply with apply_copy_sublayer_to_fixed_point; add copy_sublayer_effect_id and copy_grants_copy_layer_static.
  • crates/engine/src/game/static_source_index.rs — expose defs_source_continuous_effect so the payload-side predicate mirrors the index's own rather than restating it.
  • crates/engine/src/types/layers.rsContinuousModification::is_copy_layer, so "is this a layer-1 modification?" has one name instead of an inline matches! at each seam.
  • crates/engine/tests/integration/vizier_of_many_faces_embalm_copy_panic_5278.rsbuild_lord_scenario helper; a_copy_granted_static_ability_applies_in_the_pass_that_applied_the_copy; a_copy_granted_layer_one_static_applies_inside_the_same_layer_one_pass; bounded drive loops; the copy token's own P/T pinned against its self-excluding anthem.

Track

Developer

LLM

Model: claude-opus-5
Tier: Frontier
Thinking: high

Implementation method (required)

Method: /engine-implementer

CR references

CR 613.2a + CR 613.2c (the defect, both halves); CR 707.2c / CR 707.9a / CR 707.9b (the copy-layer classification in copy_grants_continuous_static and copy_grants_copy_layer_static); CR 604.2 + CR 611.2 (what makes a definition a continuous-static generator); CR 613.7 (timestamp order, retained across generations)

The defect

evaluate_layers gathers the pass's continuous effects below the copy application — Step 3 already sits after Step 2, so the ordering was right. What was wrong is the index it consults. StaticSourceIndex decides which permanents get scanned as static-ability generators, and it is rebuilt at the top of the pass, from the just-reset base definitions, which predate the copy.

An Embalm token's base is Vizier of Many Faces': a 0/0 Clone carrying no static of its own. So a token copying a lord was scanned as a permanent with no ability. Every later pass repeats the reset, so this is not a one-pass-behind stale read — the copied anthem never applied at all.

CR 613.2a puts copy effects in layer 1a, and CR 613.2c says that after all of layer 1 has been applied the object's characteristics are its copiable values. The abilities layers 2–7 are derived from are therefore the post-layer-1 ones, and a static granted there has to generate its effect on that same pass.

Fix, part 1 — the generator index (layers 2–7)

The sticker branch immediately below had this exact problem and shipped this exact remedy — "a sticker can turn a non-generator into a continuous static source mid-pass" is the same sentence with a different layer-1 producer. The copy case now shares that guard instead of growing a second one:

if copy_added_generator || stickers_applied {
    StaticSourceIndex::rebuild_from_state(state);
}

The copy side is narrowed by payload, not by "did layer 1a run". ContinuousModification::CopyValues carries the whole copiable static set in the modification, and apply_copiable_values assigns that set wholesale, so copy_grants_continuous_static can mirror the index's own generator predicate — any StaticMode::Continuous def — exactly, with no id plumbing out of the apply path and no battlefield rescan. That matters for cost, not just tidiness: a clone of a vanilla creature is a permanent-duration TCE that outlives the clone, so a coarse "any copy ran" guard would charge every later pass on that board a second whole-battlefield rebuild, inside a module whose entire stated purpose is keeping per-flush work off |battlefield|. The other copy-layer modifications either provably write no static definitions (SetName, CopyChosen, RetainPrintedTriggerFromSource) or read the live source instead of a payload (RetainPrintedAbilityFromSource, RetainAllOtherAbilitiesFromSource), and are answered conservatively — over-including is the safe direction under this index's own stated doctrine.

The rebuild sits below the layer-1b face-down reseed rather than above it, so it observes the whole of layer 1 — the invariant CR 613.2c actually states. The other order is inert today only because 1b just clears statics; that is a non-local fact about apply_face_down_creature_characteristics, not the rule cited at the seam, and I would rather the placement rest on the rule.

Boards with neither copies nor stickers pay nothing, and the top-of-pass rebuild contract — including the rebuild_static_index_at_top placement-regression toggle — is unchanged.

Fix, part 2 — layer 1 reaches a fixed point (the review finding)

Part 1 refreshed the index and then the pass moved on. If the copy-granted static is itself a layer-1 effect (CR 707.2c: a static "other creatures are copies of …"), it lands in a Layer::Copy bucket that has already been applied — so it never applies, and every full pass resets the source to its non-generator base and repeats the skip. Permanently inert, exactly as the review states.

apply_copy_sublayer_to_fixed_point replaces the single-shot apply: gather Layer::Copy, apply in order_active_continuous_effects order, repeat while a generation put a copy-layer static on the board, rebuilding StaticSourceIndex between generations so the next gather sees it.

Ordering is retained, not approximated. Each generation re-orders the accumulated set rather than the fresh effects alone, and re-applies from scratch after clearing the Layer::Copy attribution bucket, so a second-generation effect that should sort before a first-generation one still does (CR 613.7).

Identity is provenance, never payload. copy_sublayer_effect_id is built on ContinuousEffectGroupKey — source, target, group — and deliberately does not hash the modification. Keying on payload would make the loop non-convergent by construction: applying a copy rewrites the very characteristics the payload describes, so the same effect re-hashes each generation and re-enters the fresh set forever. That is the failure a generation cap papers over, so there is no cap; convergence is structural — started_ids is monotone over a finite battlefield.

The re-entry test is strictly narrower than the index test, and the two are not interchangeable. copy_grants_copy_layer_static asks "does this payload grant a static that is itself a Layer::Copy effect?"; copy_grants_continuous_static asks "does the recipient become a generator at all?". An anthem makes its recipient a generator for layers 2–7 but can never appear in a Layer::Copy gather, so it must not buy a discovery generation. copy_grants_continuous_static_covers_every_copy_layer_variant pins the whole Layer::Copy set against both columns, and the anthem row is where they must disagree.

apply_layers_incremental is untouched, and now says why

An earlier revision of this branch carried the same disjunct into the incremental arm for symmetry. That was wrong twice over, and I removed it.

It guards nothing reachable. A copy applied in that arm could only add a generator by landing on a recipient, and it cannot:

  • recipient_ids is entered_ids alone. The one way a pre-existing host joins the set is an attached entrant, and entered_object_blocks_incremental guard (3) rejects any entrant with attached_to.is_some() outright, escalating to a full pass.
  • A copy TCE naming a fresh entrant can only have been installed by that same entry, and every TCE install ends in layers_dirty.mark_full() (apply_resolved_continuous_effect, game_state.rs) — so that flush is a full pass, not this arm.

And it would not even have produced agreement. The incremental arm applies layers 2–7 only to recipient_ids, so a granted static would fan out over the recipients alone while the full pass derives it board-wide — a different wrong answer, not the same one. Escalating instead of rebuilding would have been the correct shape, but it is equally unreachable, so it would have been an untestable guard shipped on speculation.

What ships instead is a comment at the seam recording both blockers by name. Everything executable in this PR has a test that fails without it.

How it was found

The differential harness on this branch's sibling, comparing the entry-incremental flush against a full re-evaluation of the same post-entry board. The disagreement is also what identifies the culprit: the incremental arm resets only the entering objects, so a copy token's post-layer-1 static was still live at gather time and its grant applied; the full arm reset the copy first and lost it. The incremental arm was the correct one.

The layer-1 half was not found that way — it came from maintainer review, and the harness would not have caught it, because both arms skip the bucket identically and therefore agree.

Verification

  • Required checks ran clean, or the exact CI-owned alternative is stated below.
  • Gate A output below is for the current committed head.
  • Final review-impl below is clean for the current committed head.
  • Both anchors cite existing analogous code at the same seam.

Tilt was down for this session — tilt get uiresource clippy exits 1 — so the cargo lines below were run directly rather than read off Tilt resources.

  • Branch is rebased onto current main (7fa1fc4a1), so the parse-diff baseline is not stale.

  • cargo fmt --all -- --check — clean, exit 0.

  • ./scripts/check-parser-combinators.sh — exit 0; Gate A PASS, Gate G PASS (full output below).

  • cargo clippy --workspace --exclude phase-tauri --all-targets --features engine/proptest -- -D warnings — clean, exit 0. (CI's exact lint line.)

  • cargo test -p phase-engine22769 passed; 0 failed; 8 ignored, across all five engine binaries.

  • Revert-check chore: update coverage stats and badges #1 (discriminating, run) — the generator index. Drop copy_added_generator || from the guard, keep the tests:

    ---- a_copy_granted_static_ability_applies_in_the_pass_that_applied_the_copy stdout ----
    assertion `left == right` failed: the vanilla 2/2 must get +1/+1 from BOTH the original lord and its copy
      left: (Some(3), Some(3))
     right: (Some(4), Some(4))
    

    left: (Some(3), Some(3)) is the bug exactly: only the original lord's anthem applied; the copy's contributed nothing. Restore the disjunct and the module is green. The two pre-existing tests in the file stay green in both directions — neither copies a permanent that carries a static ability, which is why the module's existing coverage never caught this.

  • Revert-check chore: update coverage stats and badges #2 (discriminating, run) — the layer-1 fixed point. Cap apply_copy_sublayer_to_fixed_point at a single generation (return right after the first apply):

    ---- a_copy_granted_layer_one_static_applies_inside_the_same_layer_one_pass stdout ----
    assertion `left == right` failed: a copy-granted layer-1 static must be applied inside the SAME layer-1 pass that applied the copy
      left: "Bear Umbra Lord"
     right: "Faceless Reflection"
    test result: FAILED. 0 passed; 1 failed; 0 ignored; 4378 filtered out
    

    Restored: test result: ok. 1 passed; 0 failed. The vanilla is renamed in both directions — generation 1, the lord's own instance, reaches it either way — so the lord.name assertion carries the discrimination alone. That is deliberate: SetName is the only Layer::Copy modification with an observable no later layer can also write, and FilterProp::Another means only the token's instance can rename the lord.

  • The first revert-check also demonstrates the narrowed predicate is exercised positively: this board has no stickers, so stickers_applied is false and the test can only pass via copy_grants_continuous_static returning true.

  • All CR numbers cited in the diff (613.2a, 613.2c, 613.7, 604.2, 611.2, 707.2c, 707.9a, 707.9b) were grep-verified against docs/MagicCompRules.txt and each describes the code it annotates.

  • ai-gate does not trigger on this PR: .github/workflows/ai-gate.yml's pull_request paths: filter excludes crates/engine/**. No AI behavior changed and no baseline was refreshed.

Gate A

Gate G PASS (router/grant architecture: strict router vs permissive grant boundary intact)
Gate A PASS head=2087430704e3663caabe9c588bf653983f7ea4cd base=7fa1fc4a106f8356b7b5c8cf4c7f97df3cb1de56

Anchored on

  • crates/engine/src/game/layers.rs:2134 (at base 70c6f4c51) — the sticker branch: the pre-existing authority for "a layer-1 producer turned a non-generator into a continuous static source mid-pass, so refresh the generator index before the main gather." Same seam, same remedy, now shared rather than duplicated.
  • crates/engine/src/game/static_source_index.rs:66 (at base 70c6f4c51) — object_sources_continuous_effect, the index's own generator predicate. copy_grants_continuous_static is deliberately the same question asked of a CopyValues payload instead of a live object, which is why the narrowing is exact rather than heuristic.

Claimed parse impact

None. No parser file is touched and no card's parse result changes.

Scope Expansion

The layer-1 fixed point (apply_copy_sublayer_to_fixed_point, copy_sublayer_effect_id, copy_grants_copy_layer_static, is_copy_layer, and the second regression test) is beyond the PR as originally opened. It is the direct remedy for the [HIGH] finding on the first review and is declared rather than folded in silently.

Validation Failures

None.

CI Failures

None.

🤖 Generated with Claude Code

Summary by CodeRabbit

  • Bug Fixes

    • Fixed copied creatures and tokens failing to retain or apply static abilities correctly.
    • Copied abilities now take effect immediately and consistently during gameplay.
    • Resolved incorrect effects and crashes when copying creatures with ongoing abilities.
    • Improved handling of chained and second-generation copy effects.
    • Prevented unnecessary evaluation delays when copied effects cannot introduce new continuous abilities.
  • Tests

    • Added coverage for copied creatures granting effects to themselves and nearby creatures.
    • Added regression tests for chained copy effects, decline choices, and stable resolution.

@mcbradd
mcbradd requested a review from matthewevans as a code owner August 2, 2026 18:26
@github-actions github-actions Bot added the needs-maintainer AI-contribution PR requires human triage (Non-dev track or unresolved gaps) label Aug 2, 2026
@coderabbitai

coderabbitai Bot commented Aug 2, 2026

Copy link
Copy Markdown
Contributor

Review Change Stack

📝 Walkthrough

Walkthrough

Layer evaluation now applies Layer 1 copy effects to a provenance-based fixed point. It rebuilds static-source data when copy effects or stickers add continuous generators. Incremental evaluation escalates only for reachable copies that can add such generators. Tests cover same-pass effects and Embalm copies.

Changes

Copy-generated static abilities

Layer / File(s) Summary
Process copy-layer fixed points
crates/engine/src/game/layers.rs, crates/engine/src/types/layers.rs
Copy modifications use panic-free classification, provenance-based identities, ordered application, and fixed-point processing.
Rebuild sources and select incremental evaluation
crates/engine/src/game/layers.rs, crates/engine/src/game/static_source_index.rs
Layer evaluation rebuilds static sources when copy effects or stickers add generators. Incremental evaluation filters unreachable copies and re-collects effects after copied characteristics change.
Validate fixed-point and Embalm behavior
crates/engine/src/game/layers.rs, crates/engine/tests/integration/vizier_of_many_faces_embalm_copy_panic_5278.rs
Tests cover classification, escalation, termination, mutually copying permanents, same-pass copied statics, and bounded Embalm scenarios.

Estimated code review effort: 5 (Critical) | ~90 minutes

Sequence Diagram(s)

sequenceDiagram
  participant LayerEvaluation
  participant CopyEffects
  participant StaticSourceIndex
  LayerEvaluation->>CopyEffects: collect and apply ordered Layer 1 effects
  CopyEffects->>LayerEvaluation: expose copied characteristics and new provenance
  LayerEvaluation->>StaticSourceIndex: rebuild sources for new continuous generators
  StaticSourceIndex-->>LayerEvaluation: return sources for further copy discovery
  LayerEvaluation->>CopyEffects: repeat until the fixed point
Loading

Possibly related PRs

  • phase-rs/phase#6539: Both update Layer 1 copy-modification handling and static ability evaluation.
  • phase-rs/phase#6580: Both modify layers.rs and ContinuousModification handling for newly granted effects.
  • phase-rs/phase#6846: Both refine incremental layer evaluation and escalation logic.

Suggested reviewers: matthewevans, lgray

🚥 Pre-merge checks | ✅ 5
✅ Passed checks (5 passed)
Check name Status Explanation
Docstring Coverage ✅ Passed No functions found in the changed files to evaluate docstring coverage. Skipping docstring coverage check.
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title clearly describes the main fix: gathering static abilities granted by copy effects during the same evaluation pass.
✨ 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

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

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

Prefer an exhaustive match over the other => wildcard.

copy_grants_continuous_static matches specific ContinuousModification variants, then falls back to a wildcard other => arm for everything else. The debug_assert_eq! documents intent, but it only fires in debug and test builds. If a future Layer::Copy variant is added to ContinuousModification, this function silently classifies it via the wildcard instead of forcing a compile error that flags the new variant for explicit review.

List every remaining variant explicitly (even if several arms return true for the same reason) so the compiler enforces exhaustiveness the next time ContinuousModification grows.

As per path instructions, "wildcard _ match arms where the enum is known and an exhaustive match would let the compiler catch missing variants" is a flagged pattern for crates/**/*.rs.

🤖 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/layers.rs` around lines 3855 - 3885, Replace the
wildcard `other` arm in `copy_grants_continuous_static` with explicit arms for
every remaining `ContinuousModification` variant, preserving each variant’s
current classification and removing the debug assertion. Keep the existing
special-case arms unchanged, so the match becomes exhaustive and future enum
additions require explicit handling.

Source: Path instructions

crates/engine/tests/integration/vizier_of_many_faces_embalm_copy_panic_5278.rs (1)

466-485: 📐 Maintainability & Code Quality | 🔵 Trivial

Consider asserting the token's own final P/T.

The test checks vanilla and lord power/toughness, but not the copied token itself. Adding an assertion that the token ends at (2, 2) (excluded from its own copied anthem, but pumped by the original lord's "other creatures" anthem) would tighten the regression against a future bug where the token's own anthem incorrectly excludes or includes the wrong recipients.

🤖 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/tests/integration/vizier_of_many_faces_embalm_copy_panic_5278.rs`
around lines 466 - 485, Extend the regression assertions after the existing
vanilla and lord checks to validate the copied anthem token’s final power and
toughness are both 2. Use the existing token object identifier and preserve the
expected recipient behavior: the token receives the original lord’s anthem but
not its own copied anthem.
🤖 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/tests/integration/vizier_of_many_faces_embalm_copy_panic_5278.rs`:
- Around line 401-429: Bound the 'drive loop that waits for
WaitingFor::CopyTargetChoice using the same iteration-limited pattern as
activate_embalm. Keep the existing state handling, but add a finite limit and
panic with a clear diagnostic if the target-choice state is not reached before
the limit.

---

Nitpick comments:
In `@crates/engine/src/game/layers.rs`:
- Around line 3855-3885: Replace the wildcard `other` arm in
`copy_grants_continuous_static` with explicit arms for every remaining
`ContinuousModification` variant, preserving each variant’s current
classification and removing the debug assertion. Keep the existing special-case
arms unchanged, so the match becomes exhaustive and future enum additions
require explicit handling.

In
`@crates/engine/tests/integration/vizier_of_many_faces_embalm_copy_panic_5278.rs`:
- Around line 466-485: Extend the regression assertions after the existing
vanilla and lord checks to validate the copied anthem token’s final power and
toughness are both 2. Use the existing token object identifier and preserve the
expected recipient behavior: the token receives the original lord’s anthem but
not its own copied anthem.
🪄 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: 1cbe5458-d879-4d88-ad09-91a96f76a046

📥 Commits

Reviewing files that changed from the base of the PR and between 70c6f4c and 1620a32.

📒 Files selected for processing (2)
  • crates/engine/src/game/layers.rs
  • crates/engine/tests/integration/vizier_of_many_faces_embalm_copy_panic_5278.rs

Comment thread crates/engine/tests/integration/vizier_of_many_faces_embalm_copy_panic_5278.rs Outdated
@matthewevans matthewevans self-assigned this Aug 2, 2026
@github-actions

github-actions Bot commented Aug 2, 2026

Copy link
Copy Markdown

Generated for head 2087430704e3663caabe9c588bf653983f7ea4cd.

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.

[HIGH] Copy-granted Layer-1 statics are never applied. Evidence: crates/engine/src/game/layers.rs:2180-2192 rebuilds StaticSourceIndex after the initial copy pass, gathers the copied source's static effects, then unconditionally skips the resulting Layer::Copy bucket. Why it matters: copying a permanent whose copied static itself creates a copy effect (for example, an "other creatures are copies of …" static) leaves that static permanently inert; every full pass resets the source to its non-generator base before repeating the same skip. Suggested fix: make Layer 1's source discovery/application reach a rules-correct fixed point (while retaining timestamp/dependency ordering), and add a real-pipeline regression where the copied static produces a Layer-1 copy effect.

[LOW] The newly added priority driver can hang indefinitely. Evidence: crates/engine/tests/integration/vizier_of_many_faces_embalm_copy_panic_5278.rs:401-429 uses an unbounded 'drive loop even though activate_embalm directly above uses a bounded wait. Why it matters: a regression that leaves the runner in WaitingFor::Priority turns a failing test into a hung CI shard. Suggested fix: bound this loop (as activate_embalm does) and panic with the last unexpected progression state when CopyTargetChoice is not reached.

@matthewevans matthewevans added the bug Bug fix label Aug 2, 2026
@matthewevans matthewevans removed their assignment Aug 2, 2026
mcbradd and others added 7 commits August 2, 2026 21:18
…pied them

CR 613.2a + CR 613.2c. Layer 1a applies copy effects, and after all of layer 1
has been applied the object's characteristics ARE its copiable values. A static
ability a permanent has only because a copy effect gave it is therefore part of
the board that layers 2-7 of that SAME pass must be derived from.

`evaluate_layers` already gathers the pass's continuous effects below the copy
application (Step 3 sits after Step 2), but the generator index that decides
which permanents get scanned for statics is built at the TOP of the pass, from
the just-reset BASE definitions -- which predate the copy. An Embalm token's
base is Vizier of Many Faces': a 0/0 Clone carrying no static of its own. A
token copying a lord was therefore scanned as a permanent with no ability, and
stayed that way on every later pass too, because each one resets to that same
base first. Under full evaluation the copy's anthem never applied at all.

The sticker branch immediately below had this exact problem and shipped this
exact remedy -- "a sticker can turn a non-generator into a continuous static
source mid-pass" is the same sentence with a different layer-1 producer. The
copy case now shares that guard instead of growing a second one, so the
top-of-pass rebuild contract is unchanged.

The copy side is narrowed by payload, not by "did layer 1a run".
`ContinuousModification::CopyValues` carries the whole copiable static set in
the modification, and `apply_copiable_values` assigns that set wholesale, so
`copy_grants_continuous_static` mirrors the index's own generator predicate
(any `StaticMode::Continuous` def) exactly -- no id plumbing out of the apply
path and no battlefield rescan. A clone of a vanilla creature is a
permanent-duration TCE that outlives the clone, so a coarse "any copy ran"
guard would have charged every later pass on that board a second whole-
battlefield rebuild, inside a module whose entire stated purpose is keeping
per-flush work off `|battlefield|`. The remaining copy-layer modifications
either provably write no static definitions (`SetName`, `CopyChosen`,
`RetainPrintedTriggerFromSource`) or read the live source instead of a payload
(`RetainPrintedAbilityFromSource`, `RetainAllOtherAbilitiesFromSource`) and are
answered conservatively; over-including is the safe direction under the index's
stated doctrine.

The rebuild sits below the layer-1b face-down reseed rather than above it, so it
observes the whole of layer 1 -- the invariant CR 613.2c actually states. The
other order is inert today only because 1b just clears statics, a non-local fact
rather than the rule cited at the seam.

`apply_layers_incremental` is deliberately left alone, and now says why. A copy
applied there could only add a generator by landing on a recipient, and it
cannot: `recipient_ids` is `entered_ids` alone, because the one way a
pre-existing host joins the set -- an attached entrant -- is rejected outright by
`entered_object_blocks_incremental` guard (3); and a copy TCE naming a fresh
entrant can only have been installed by that same entry, which called
`layers_dirty.mark_full()` and so made this flush a full pass. An earlier draft
of this change carried the disjunct into that arm for symmetry. That was wrong
twice over: it guards nothing reachable, and because the arm applies layers 2-7
only to `recipient_ids`, the granted static would fan out over the recipients
alone while the full pass derives it board-wide -- a different wrong answer
rather than agreement. A comment at the seam is the honest artifact; an
untestable guard is not.

Found by the differential harness on this branch's sibling: a copy of Painter's
Servant granted its chosen color under the entry-incremental arm -- which resets
only the entering objects, so the copy's post-layer-1 static was still live at
gather time -- and granted nothing under the full arm. The incremental arm was
the correct one.

The new test copies a lord rather than the harness's Painter's Servant because
an anthem needs no as-enters choice, so the assertion is over P/T alone. It
reuses the existing Embalm scaffolding in the same file.

Verification: `cargo fmt --all -- --check` clean; `cargo clippy --workspace
--exclude phase-tauri --all-targets --features engine/proptest -- -D warnings`
clean; `./scripts/check-parser-combinators.sh upstream/main` Gate A PASS,
Gate G PASS; `cargo test-all` green apart from the pre-existing `mtgish-import`
set this branch does not touch, zero engine failures. Revert-check ran and
discriminates: dropping `copy_added_generator ||` from the guard fails the new
test with `left: (Some(3), Some(3)) right: (Some(4), Some(4))` -- only the
ORIGINAL lord's anthem applied -- while the two pre-existing tests in the file
stay green, which is why the module's existing coverage never caught this.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
CR 613.2a + CR 613.2c: applying a copy effect can hand its recipient a
static ability that itself generates a layer-1 (copy) continuous effect,
and CR 613.2c makes the characteristics standing after layer 1 the object's
copiable values -- so that second-generation effect belongs to the SAME
layer 1, not to the next pass. Sublayer 1a now re-gathers and re-applies to
a fixed point (`apply_copy_sublayer_to_fixed_point`), rebuilding
`StaticSourceIndex` between generations and bounded by
`MAX_COPY_SUBLAYER_GENERATIONS` so a mutually-copying pair cannot spin.
Each generation re-orders and re-applies the WHOLE started set (CR 613.2
timestamp order + CR 613.8a dependency order), because `depends_on` sorts
every `CopyValues` ahead of other layer-1 modifications; the `Layer::Copy`
attribution bucket is cleared first so a re-applied effect is not listed
twice. A board with no copy-granted copy ability pays exactly today's cost.

The incremental arm cannot absorb the same state. It applies effects only
to `recipient_ids`, so a board-wide static a recipient acquires from a copy
would never reach the pre-existing objects it must apply to -- the state
`entered_object_blocks_incremental` already escalates for, reached one step
later. And a PRE-EXISTING copy effect reaches a fresh entrant by FILTER
MATCH, since `apply_continuous_effect_to` narrows an ordinary board-wide
`affected_filter` to the recipients rather than requiring
`TargetFilter::SpecificObject`; neither the magnitude/population classifier
nor the source-side guard sees that case. `prepare_incremental_flush` now
escalates whenever an active copy effect's payload grants a continuous
static, before any copy is applied, which also keeps the generator set of
the index it just built true for the whole flush.

Also:

- `copy_grants_continuous_static` enumerates the six `Layer::Copy` variants
  explicitly instead of an `other =>` catch-all that called `other.layer()`.
  Six of that method's arms are `unreachable!()` panics
  (`AddCounterOnEnter`, `SetStartingLoyalty`, `RemoveManaCost`, and the
  three combat-assignment variants), so the `debug_assert_eq!` meant to
  make the arm safe could abort inside itself.
- CR 707.9a: `RetainPrintedAbilityFromSource` no longer claims to add a
  generator. Its apply arm pushes one `AbilityDefinition` onto
  `obj.abilities` and never touches `static_definitions`. Only
  `RetainAllOtherAbilitiesFromSource`, which merges the source's
  `base_static_definitions`, still does.
- The `CopyValues` payload question routes through the index's own
  classification predicate, shared as
  `static_source_index::defs_source_continuous_effect`, so the two answers
  cannot drift apart.
- Bound the `CopyTargetChoice` drive loop in the phase-rs#5278 integration test (and
  share it between tests) so an engine change that stops surfacing the
  prompt fails instead of hanging CI.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
`apply_copy_sublayer_to_fixed_point` identified an already-applied copy
effect with `same_copy_effect`, which compared provenance AND the boxed
`CopiableValues` payload AND the timestamp. Payload-in-identity makes the
loop non-convergent by construction: applying a copy rewrites its
recipient, the next gather re-reads the rewritten payload, and the "same"
effect looks new forever. That is why `MAX_COPY_SUBLAYER_GENERATIONS = 8`
and its `debug_assert!` existed — and in release/WASM, where the assert is
compiled out, exhausting the range silently returned a WRONG board with no
diagnostic.

CR 707.2c: "If a static ability generates a continuous effect that's a copy
effect, the copiable values that effect grants are determined only at the
time that effect first starts to apply." Identity is therefore PROVENANCE
ONLY. `CopySublayerEffectId` wraps the engine's existing canonical identity,
`ContinuousEffectGroupKey` — the same key `started_effect_sets` uses for CR
613.6 affected-set retention, which already distinguishes a `GrantedStatic`
by grant origin and recipient. `mod_index` rides alongside because the group
key is deliberately `mod_index`-blind and would otherwise collapse two
modifications of one definition; `source_id` keeps the id total for the
synthetic producers whose group key is `None`. The linear
`iter().any(same_copy_effect)` scan and its deep payload compare are gone
with it — membership is now one `HashSet::insert`.

Termination, in place of the cap:

- The provenance space is finite and does not GROW during the layer-1 pass.
  It is (battlefield object x definition index x modification index), plus
  the fixed set of transient continuous-effect ids, plus granted-static
  origins. No object enters the battlefield inside the pass.
- No arm can mint unbounded fresh provenance. `apply_copiable_values`
  ASSIGNS `static_definitions` wholesale from the payload (it does not
  append), so a definition index cannot climb; and
  `RetainAllOtherAbilitiesFromSource` dedupes against what is already there.
- Each iteration either inserts at least one identity into a monotonically
  growing set, or returns.

Also splits the loop-continuation question from the index-rebuild question,
which the previous commit conflated. `copy_grants_continuous_static` answers
"does this payload grant ANY continuous static", which is the right gate for
rebuilding `StaticSourceIndex` — but it was also gating whether to take
another generation, and a cloned LORD answers it yes while being unable to
extend a layer-1 chain (an anthem is layer 7c). Cloning a lord is common, so
that mis-gate bought a `StaticSourceIndex::rebuild_from_state` plus a
board-wide `collect_shared_active_continuous_effects` on boards that need
neither, and the previous commit message's "a board with no copy-granted
copy ability pays exactly today's cost" was false. Continuation now asks the
narrow `copy_grants_copy_layer_static`.

`ContinuousModification::is_copy_layer` is the panic-free companion to
`layer()`, six of whose arms are `unreachable!()` — asking an arbitrary
modification read out of a payload for its layer could abort.

`started` is deliberately still never pruned: under provenance-only identity
CR 707.2c says the latched snapshot is the correct thing to keep applying.

Tests:

- `mutually_copying_permanents_reach_a_layer_one_fixed_point` — the exact
  construction the deleted cap named. Renames land on neutral watchers, not
  on the mutually-copying pair, because two permanents copying each other is
  a CR 613.8b dependency LOOP whose timestamp fallback would otherwise be
  what the assertion pinned.
- `only_a_copy_layer_payload_buys_a_layer_one_discovery_generation` —
  three boards differing only in the payload's static set, counting real
  `collect_shared_active_continuous_effects` calls: the anthem board must
  match the vanilla board exactly, the copy-layer board must buy one more.
- `copy_grants_continuous_static_covers_every_copy_layer_variant` grows a
  third column so the two questions must disagree on the anthem row.

Both new tests were revert-probed: forcing a single generation fails both.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
…recipient

The entry-incremental escalation guard fired on the mere PRESENCE of an
active copy effect whose payload grants a continuous static. That includes
the ordinary shape — a resolved Clone / Vizier carries a `SelfRef` or
`SpecificObject` copy naming one pre-existing permanent — so any board that
had ever resolved a clone paid the O(|battlefield|) full pass on every
subsequent entry, which is exactly the cost this module exists to keep off
the flush.

Such a copy cannot hand a recipient anything: the incremental arm applies
copies through `apply_continuous_effect_to(state, effect, &recipient_ids,
..)`, and a filter naming a non-recipient intersects `recipient_ids` to the
empty set. The clone's own copy-granted static is already live on it from
the earlier full pass, is never reset here, and is picked up by the
top-of-pass `StaticSourceIndex` rebuild like any other generator.

The guard now also asks `effect_can_reach_incremental_recipients`, and
`copy_effects` is filtered by the same predicate so `copy_effects.is_empty()`
— which decides whether the flush pays a second board-wide gather — is
accurate again. A copy that DOES name a recipient still escalates, and so
does any copy whose affected set is a predicate rather than an id, since
that set is not decidable here.

The review asked for `effect_is_restricted_to_incremental_recipients` to be
reused directly. It cannot be: the two questions agree on an id-naming
filter (a set of one is confined iff it is reached) but their conservative
answers for an unknown affected set are OPPOSITE, because both must escalate
on doubt and doubt sits on different sides. They now share the id-naming
classifier `effect_names_single_affected_object` and differ only in
`is_some_and` vs `is_none_or`, which is where the asymmetry belongs.

Tests:

- `entry_incremental_stays_incremental_for_a_copy_naming_a_pre_existing_object`
  is the negative sibling of the existing escalation test, with a positive
  reach-guard proving the clone really carries the payload's static (so
  `copy_grants_continuous_static` answers true and only the reach test is
  keeping the flush incremental), and a CR 613.2c assertion that the entrant
  still comes out wearing the clone's copy-granted anthem.
- Both escalation tests now reach-guard on `entered_object_blocks_incremental`
  itself rather than on `base_static_definitions`. The guard reads the LIVE
  `static_definitions`; asserting the neighbouring field was not vacuous but
  was not the predicate under test either.

Revert-probed: dropping the reach term flips `layers_incremental` to 0 and
`layers_full_eval` to 1 and fails the new test.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
…tic SetName board

Two unbounded `loop`s survived the previous commit's bounding pass. Both act
on their `Priority` arm every trip, so an engine change that stops surfacing
`CopyTargetChoice` / the enter-as-copy `ReplacementChoice` would spin
forever and hang CI instead of failing. Both are now `for _ in 0..64` with
an `expect` naming the prompt that never arrived — the same bound and shape
`activate_embalm` and `resolve_embalm_copy_of` already use. The previous
commit message claimed this was already done for the whole file; it was true
of one loop.

`a_copy_granted_layer_one_static_applies_inside_the_same_layer_one_pass`
builds a board-wide `SetName` static, which no printed card has — on real
cards `SetName` appears as a copy EXCEPTION paired with `CopyValues` inside
`additional_modifications`. The doc comment now says so, and says why the
synthetic shape is the right one to test the building block with: `SetName`
is the only `Layer::Copy` modification with an observable no later layer can
also write, which is what lets the assertion attribute the change to
sublayer 1a and nothing else. The mechanism it stands in for — a copy
handing its recipient a static that is itself a layer-1 effect — is real and
is what CR 707.2c is about.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
`CopySublayerEffectId` claimed more than provenance-only identity delivers. It
wraps `ContinuousEffectGroupKey`, and one of that key's three arms —
`Static { source, definition_index }` — is a POSITION in `obj.static_definitions`,
a vector `apply_copiable_values` assigns wholesale. So the old wording ("once a
slot has started applying, re-reading it later is the SAME effect") is exact for
`Transient` and `GrantedStatic` but only exact-under-a-caveat for `Static`: in
principle slot i could denote a different ability in generation N+1, and the
newcomer would be filtered out as already-started while the effect it displaced
kept re-applying. That is the case the deleted `same_copy_effect` payload compare
used to cover.

The payload compare stays gone — it is what made termination unprovable and what
the generation cap was hiding. The residual is documented as accepted-unreachable
instead, with the invariant stated and the argument spelled out rather than
asserted: no board can produce a `Static`-keyed `Layer::Copy` effect. Both engine
construction sites of a copy-layer modification install through
`add_transient_continuous_effect`; `expand_granted_static_effects` sets
`def_index: None`; and card data — the only other producer of `StaticDefinition`s
— never puts a copy-layer modification in a printed `static_abilities`. In the
generated pool the six `is_copy_layer` variants appear inside a `StaticDefinition`
in exactly four places, all `GenericEffect` payloads (Awakening of Vitu-Ghazi,
Tenth District Hero, The Curse of Fenric, The Irencrag, all `SetName`), which
resolve through `register_transient_effect`. So no route into an object's
`static_definitions` — copy payload, `GrantStaticAbility` graft,
`RetainPrintedAbilityFromSource` graft — can carry one either.

No test: closing this means de-positioning a key shared by all seven layers, and
pinning today's behaviour in an assertion would encode the wrong answer and turn
the suite red on the day it is fixed.

Two smaller notes on seams a reviewer read as claiming more than they do:

- `copy_grants_copy_layer_static` and `apply_continuous_effect` disagree about
  whether a `CopyValues` nested in a payload's `static_definitions` is possible —
  this one buys a discovery generation for it, that one panics on it. Recorded as
  deliberate: the expect is where the invariant is enforced, so a future reachable
  case is a construction-site bug, not a case to admit by special-casing it out of
  `is_copy_layer`'s totality.
- The `GrantedStatic` half of the "why build on `ContinuousEffectGroupKey`"
  argument is forward-looking, not load-bearing: that arm is live in the layers
  sharing the key, but reaching it from the copy sublayer needs a
  `GrantStaticAbility` whose inner definition is copy-layer, and no card prints one.

Comments only; no behavioural change.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
… anthem

The anthem regression asserted the vanilla (both instances apply) and the
original lord (the copy's instance only), but never the token itself — the
third recipient class, and the only object the two anthem instances must
disagree about. The token is excluded from the anthem it copied
(`FilterProp::Another`, CR 613.1g) and pumped by the original lord's, so a
bug that swapped which instance excludes which recipient would move this
total while leaving both existing assertions plausible.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
@mcbradd
mcbradd force-pushed the podlab/copy-granted-static-same-pass branch from 1620a32 to 2087430 Compare August 3, 2026 05:09
@mcbradd

mcbradd commented Aug 3, 2026

Copy link
Copy Markdown
Contributor Author

Re-review requested on new head 2087430704e3663caabe9c588bf653983f7ea4cd. Branch rebased onto current main (7fa1fc4a1).

Both confirmed and fixed. The [HIGH] was real and the diagnosis was exact — the rebuild handed layer 1 a fresh index and then the pass moved on, so a copy-granted layer-1 static was gathered into a bucket nothing ever applied. Thanks for catching it; the first revision closed the layers-2-7 half of CR 613.2c and left the layer-1 half open, which is the worse of the two halves to leave open because it is self-perpetuating exactly as you describe.

[HIGH] — layer 1 now reaches a fixed point.

apply_copy_sublayer_to_fixed_point replaces the single-shot apply. It gathers Layer::Copy, applies in order_active_continuous_effects order, and repeats while a generation put a copy-layer static on the board, rebuilding StaticSourceIndex between generations so the next gather can see it. Timestamp/dependency ordering is retained rather than approximated: each generation re-orders the accumulated set, not just the fresh effects, and re-applies from scratch after clearing the Layer::Copy attribution bucket — so a second-generation effect that should sort before a first-generation one still does.

Two design points I want to state rather than have you infer:

  • Identity is provenance, never payload. copy_sublayer_effect_id is built on ContinuousEffectGroupKey — source, target, group — and deliberately does not hash the modification. Keying on payload would make the loop non-convergent by construction: applying a copy rewrites the very characteristics the payload describes, so the same effect re-hashes differently each generation and re-enters the fresh set forever. That is the failure a generation cap papers over, so the cap is gone; convergence is now structural (the started_ids set is monotone over a finite battlefield) rather than bounded by a magic number.
  • The re-entry test is narrower than the index test. copy_grants_copy_layer_static asks "does this payload grant a static that is itself a Layer::Copy effect?", which is strictly narrower than copy_grants_continuous_static's "does the recipient become a generator?". An anthem makes its recipient a generator for layers 2-7 but can never appear in a Layer::Copy gather, so it must not buy a discovery generation. copy_grants_continuous_static_covers_every_copy_layer_variant pins the whole Layer::Copy set against both columns, and the anthem row is where they must disagree.

Real-pipeline regression, with the discrimination run. a_copy_granted_layer_one_static_applies_inside_the_same_layer_one_pass drives Embalm through the runner — activate, resolve, answer the copy choice — and reads the board after. The lord's printed base carries "each other creature is named Faceless Reflection" (FilterProp::Another), so the lord's own instance can never rename the lord; only the token's copied instance covers it, and only if sublayer 1a ran a second generation. SetName is the one Layer::Copy modification with an observable no later layer can also write, which is what makes the assertion attribute the change to 1a and nothing else.

Capping apply_copy_sublayer_to_fixed_point at a single generation (return right after the first apply):

---- a_copy_granted_layer_one_static_applies_inside_the_same_layer_one_pass stdout ----
assertion `left == right` failed: a copy-granted layer-1 static must be applied inside the SAME layer-1 pass that applied the copy
  left: "Bear Umbra Lord"
 right: "Faceless Reflection"
test result: FAILED. 0 passed; 1 failed

Restored: 1 passed. The vanilla is renamed in both directions — generation 1 (the lord's own instance) reaches it either way — so the lord.name assertion is carrying the discrimination alone, which is the point.

The board is synthetic and the test says so at length: no printed card carries a board-wide SetName static; on real cards SetName appears as a copy exception paired with CopyValues (Vizier's own Embalm line is one). What is under test is the building block — a copy handing its recipient a static that is itself a layer-1 effect, which is what CR 707.2c is about — not a card.

[LOW] — bounded. Both 'drive loops now use the same iteration-limited pattern as activate_embalm and panic with the last unexpected progression state. Correct call on the CI-shard consequence.

Branch is rebased onto current main. Full verification at the new head is in the PR body.

@mcbradd

mcbradd commented Aug 3, 2026

Copy link
Copy Markdown
Contributor Author

Both nitpicks addressed — one taken, one refuted with evidence.

Token's own final P/T — taken. Added. The token is a 1/1 copy of the lord that its own copied anthem excludes (FilterProp::Another) and the original lord's anthem pumps, so (2, 2) is the third recipient class and the only one where the two anthem instances must disagree about the recipient. The existing vanilla (4/4, both anthems) and lord (2/2, the copy's anthem only) assertions never exercise self-exclusion on the copy itself. Good catch — it closes the axis where a future bug would flip which instance excludes which recipient while both totals stayed plausible.

Exhaustive match over the _ => arm — refuting, with the reason.

The concern is that a future Layer::Copy variant slips through the wildcard. That cannot happen silently here, because every existing Layer::Copy variant is already matched explicitly, by name, with its own CR-annotated rationale:

  • CopyValues — classified on its payload's static_definitions.
  • SetName (CR 707.9b), CopyChosen (CR 707.2c), RetainPrintedTriggerFromSource (CR 707.9a), RetainPrintedAbilityFromSource (CR 707.9a) — each false, each with the structural reason it provably writes no static_definitions.
  • RetainAllOtherAbilitiesFromSource (CR 707.9a) — true, conservatively, because the retained set lives on the live source rather than in the payload.

That is the complete Layer::Copy set as ContinuousModification::layer() and is_copy_layer define it. The wildcard therefore covers exactly the layers-2-7 complement — 49 variants that write no copied static_definitions and that both call sites have already filtered out before reaching this function. Replacing it would mean listing 49 unrelated variant names, twice (copy_grants_copy_layer_static has the same shape), to restate a fact that layer() already enforces exhaustively — and layers.rs carries 32 justified wildcard arms today, so this would also be locally inconsistent.

Also worth noting: the debug_assert_eq! the comment refers to is no longer there. It was removed earlier in this branch precisely because a debug-only guard is not a guarantee, and because it consulted other.layer() — six of whose arms are unreachable!() panics (AddCounterOnEnter, SetStartingLoyalty, RemoveManaCost, and the three combat-assignment variants), so the guard meant to make the arm safe could abort inside itself. The arm now rests on a stated structural argument instead.

The residual risk you are pointing at is real but sits one seam up and predates this PR: ContinuousModification::is_copy_layer (types/layers.rs:91) is a matches!, so a new copy-layer variant would answer false there too. Closing it properly means making that one function the compiler-enforced authority, which is a types/layers.rs change with its own blast radius — not something to smuggle into a layer-1 fixed-point fix. Flagging it rather than silently leaving it.

@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/layers.rs (1)

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

Guard the wildcard arms with debug_assert!(!modification.is_copy_layer()).

Both classifiers end in _ => false. The comment argues the arm is exact for layers 2-7. That is true today. If a future Layer::Copy variant is added, both classifiers answer false silently, and a copy that grants a generator becomes invisible to layers 2-7 again — the exact defect this PR repairs. The pinning test copy_grants_continuous_static_covers_every_copy_layer_variant uses a hand-maintained table, so it would not catch the new variant either.

ContinuousModification::is_copy_layer is total and panic-free, so it is safe to assert on inside these arms, unlike layer().

♻️ Proposed hardening for both wildcard arms
         // Layers 2-7. `ContinuousModification` spans all eleven layers in one
         // enum, so this arm is required for totality; it is EXACT rather than a
         // fallback guess — no modification outside layer 1 writes copied
         // `static_definitions`, and both callers filter to `Layer::Copy` first.
-        // Deliberately does not consult `other.layer()`: six of that method's
-        // arms are `unreachable!()` panics (`AddCounterOnEnter`,
-        // `SetStartingLoyalty`, `RemoveManaCost`, and the three
-        // combat-assignment variants), so the guard that was meant to make this
-        // arm safe could abort inside itself.
-        _ => false,
+        // Deliberately does not consult `other.layer()`: six of that method's
+        // arms are `unreachable!()` panics (`AddCounterOnEnter`,
+        // `SetStartingLoyalty`, `RemoveManaCost`, and the three
+        // combat-assignment variants), so the guard that was meant to make this
+        // arm safe could abort inside itself. `is_copy_layer` is the panic-free
+        // companion, so a new `Layer::Copy` variant fails a debug build here
+        // rather than being silently classified as a non-generator.
+        other => {
+            debug_assert!(
+                !other.is_copy_layer(),
+                "a new Layer::Copy modification must be classified explicitly: {other:?}"
+            );
+            false
+        }

Apply the same change to the _ => false arm of copy_grants_copy_layer_static.

Also applies to: 4024-4028

🤖 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/layers.rs` around lines 3959 - 3968, Update both
wildcard `_ => false` arms in the classifiers near
`copy_grants_copy_layer_static` and its companion function to first
`debug_assert!(!modification.is_copy_layer())`. Keep returning false afterward,
using the total, panic-free `ContinuousModification::is_copy_layer` check to
detect future copy-layer variants.

Source: Coding guidelines

🤖 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/layers.rs`:
- Around line 3959-3968: Update both wildcard `_ => false` arms in the
classifiers near `copy_grants_copy_layer_static` and its companion function to
first `debug_assert!(!modification.is_copy_layer())`. Keep returning false
afterward, using the total, panic-free `ContinuousModification::is_copy_layer`
check to detect future copy-layer variants.

ℹ️ Review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: CHILL

Plan: Pro Plus

Run ID: 0ca3f440-74d6-4b7d-ba2e-c5d8ad7b6a65

📥 Commits

Reviewing files that changed from the base of the PR and between 1620a32 and 2087430.

📒 Files selected for processing (4)
  • crates/engine/src/game/layers.rs
  • crates/engine/src/game/static_source_index.rs
  • crates/engine/src/types/layers.rs
  • crates/engine/tests/integration/vizier_of_many_faces_embalm_copy_panic_5278.rs

@mcbradd

mcbradd commented Aug 3, 2026

Copy link
Copy Markdown
Contributor Author

You corrected the weakest part of my last reply — the pinning test is a hand-maintained table and would not catch a new variant, so pointing at it as coverage was wrong. But the proposed guard does not close the gap either, and it fails on exactly the scenario it names.

ContinuousModification::is_copy_layer (types/layers.rs:91) is a matches! over six listed variants. It has no wildcard to fall through — it simply returns false for anything not in the list. So for a newly added Layer::Copy variant that nobody remembered to add there:

  • other.is_copy_layer()false
  • debug_assert!(!false)debug_assert!(true) → passes
  • the arm returns false, silently, exactly as today

The assert can only fire in the narrower case where someone adds the variant to is_copy_layer but not to the two classifiers. That case is real and worth catching, so this is not a nothing suggestion — but it is not "a new Layer::Copy variant fails a debug build here". It is "a new variant that was half-registered fails a debug build here," and only in debug.

Your premise is right, though: the residual risk is real and my last reply under-answered it. The fix that actually closes it is one seam up — make is_copy_layer an exhaustive match rather than a matches!, so the compiler refuses to build until a new variant is classified, and every consumer of it (including these two arms, and the pinning test's assumptions) inherits that guarantee for free. That is a types/layers.rs change with its own blast radius across every is_copy_layer call site, and folding it into a layer-1 fixed-point fix — one that already carries a declared scope expansion for the maintainer's [HIGH] — would be the wrong PR for it. Tracking it as the follow-up rather than shipping the debug-only half-measure here.

@matthewevans matthewevans self-assigned this Aug 3, 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.

Approved: the Layer 1 fixed-point repair is at the source-index/layer-evaluation seam, preserves provenance and ordering, and has discriminating production-pipeline coverage for both copied statics and chained Layer-1 effects.

@matthewevans
matthewevans added this pull request to the merge queue Aug 3, 2026
@matthewevans matthewevans removed their assignment Aug 3, 2026
Merged via the queue into phase-rs:main with commit d8856f1 Aug 3, 2026
16 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

bug Bug fix needs-maintainer AI-contribution PR requires human triage (Non-dev track or unresolved gaps)

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants