fix(engine): stop a folded mill replacement from stashing a second application - #7487
Conversation
…plication
CR 614.6: a replacement whose execute the applier has already folded into the
substituted event must not also stash a post-replacement continuation. For Mill
it did, and the continuation ran the mill a second time against the wrong
player: `ProposedEvent::Mill::affected_object_id()` is `None`, so the stash
binds to `rid.source` and resolves the execute's `TargetFilter::Controller` to
the REPLACEMENT's controller rather than to the affected player. The resulting
paused drain then manufactured a freeze. This closes the second Discord report.
The fix is one member appended to the existing post-effect suppression
`matches!`. `mill_applier` is untouched — it is already correct, folding the
count and returning `Modified`; the defect was entirely in the stash derivation.
The `sub_ability` escape runs before the `matches!`, so the rider path is
preserved by construction rather than by a carve-out.
Measured at base rather than argued: the discriminating test fails `left: 20,
right: 0` — the event mills the affected player 10 and stamps
`last_effect_count`, then the continuation resolves `Multiply{2,
EventContextAmount}` against that same 10 and mills the replacement's
controller 20. The two resolvers demonstrably disagree. No assertion encodes
that magnitude; the rows are stated in a magnitude-free form on purpose.
Non-vacuity is established by execution, not inspection: at base the test's
positive control passes (the affected player really does lose 10) while only
the discriminator fails, so the red is attributable to the defect and not to a
fixture that never engaged the replacement.
The arm's comment records the three premises suppression rests on, because
suppressing is safe only if the applier's fold is total over what the
continuation would have done: (1) the count is statically resolvable — every
form `parse_mill_replacement_count` emits is rooted in `EventContextAmount`;
(2) `mill_applier` ignores the execute's `target` and `destination`, passing the
EVENT's through, so a Mill execute aimed at anyone but the affected player would
have its target silently dropped; (3) the def is read solely via `rid.source`
with no `ObjectId(0)` sentinel, so a floating Mill def would stay unfolded and
still be suppressed — unreachable today, which is why "suppressed implies
folded" holds in practice but is not entailed by premise 1 alone.
The comment's producer claim is scoped to MILL definitions — one production
producer today, `parse_mill_count_replacement` — not to `ReplacementDefinition`,
which has many (`database/synthesis.rs`, `effects/create_damage_replacement.rs`,
`Deserialize`, among others). The other door a Mill definition could arrive
through is the Forge translator, which has 16 arms and no `Mill`; that door is
the wider one, since an SVar-resolved execute can carry both an unresolvable
count and a `sub_ability` rider.
Deliberately not fixed here, and recorded: a Mill replacement that DOES carry a
rider still applies twice (event at the unmodified count, continuation at the
modified count against the wrong player). Draining that case in a test would
pin the bug as expected behaviour, so the rider test asserts at the seam that a
continuation is stashed and drains nothing.
|
Caution Review failedThe pull request is closed. ℹ️ Recent review info⚙️ Run configurationConfiguration used: Path: .coderabbit.yaml Review profile: CHILL Plan: Pro Plus Run ID: 📒 Files selected for processing (3)
📝 WalkthroughWalkthroughMill replacement handling now folds bare replacement counts into ChangesMill replacement handling
Estimated code review effort: 3 (Moderate) | ~20 minutes Sequence Diagram(s)sequenceDiagram
participant TomeScour
participant ReplacementApplier
participant ProposedEventMill
participant GamePriority
TomeScour->>ReplacementApplier: create Mill replacement
ReplacementApplier->>ProposedEventMill: fold bare replacement count
ProposedEventMill->>GamePriority: resolve without phantom pause
Possibly related PRs
Suggested labels: Suggested reviewers: ✨ Finishing Touches📝 Generate docstrings
🧪 Generate unit tests (beta)
Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out. Comment |
|
Generated for head Parse changes introduced by this PR✓ No card-parse changes detected. |
|
@coderabbitai review |
|
|
@coderabbitai review |
|
Closes the second of the two Discord freeze reports (thread
1537928070197936280).The defect
CR 614.6: a replacement whose execute the applier has already folded into the substituted event must not also stash a post-replacement continuation. For
Millit did, so the mill ran twice — and the second run hit the wrong player.The mis-binding is mechanical:
ProposedEvent::Mill::affected_object_id()returnsNone, so the stash falls back to binding againstrid.source. The execute'sTargetFilter::Controllerthen resolves to the replacement's controller instead of the affected player. The resulting paused drain is what manufactured the freeze.The fix
One member appended to the existing post-effect suppression
matches!:mill_applieris untouched — it was already correct, folding the count and returningModified. The defect was entirely in the stash derivation. Thesub_abilityescape (replacement.rs:8385) runs before thismatches!(:8388), so the rider path is preserved by construction, not by a carve-out.Evidence
Measured at base rather than argued. The discriminating test fails
left: 20, right: 0at the parent commit: the event mills the affected player 10 and stampslast_effect_count; the continuation then resolvesMultiply { 2, EventContextAmount }against that same 10 and mills the replacement's controller 20. The two resolvers demonstrably disagree. No assertion encodes that magnitude — the rows are written in a magnitude-free form on purpose, so they don't pin a specific arithmetic outcome.Non-vacuity is established by execution, not inspection: at base the positive control passes (the affected player really does lose 10) while only the discriminator fails. The red is therefore attributable to the defect and not to a fixture that never engaged the replacement.
Test C additionally carries a positive control that is independent of its discriminator — it asserts a
GameEvent::ReplacementAppliedwas emitted for the source, proving the rider definition actually applied. Without it, a fixture that silently failed to install the replacement would pass the row for the wrong reason.Deliberately not fixed, and recorded
A Mill replacement that does carry a rider still applies twice (event at the unmodified count, continuation at the modified count against the wrong player). Draining that case in a test would pin the bug as expected behaviour, so the rider test asserts at the seam — that a continuation is stashed and drains nothing — rather than asserting the buggy end state.
Premises the arm depends on
Suppression is safe only because the applier's fold is total over what a continuation would have done, and the fold reads only the count. All three are recorded in the code:
parse_mill_replacement_countemits is rooted inQuantityRef::EventContextAmount.mill_applierignores the execute'stargetanddestination, passing the event's through. Every production Mill execute isTargetFilter::Controller/Zone::Graveyardtoday, so nothing is lost — but a Mill execute aimed at anyone other than the affected player would have its target silently dropped, and this continuation is the only thing that would have honoured it.rid.source, with noObjectId(0)sentinel branch. A floating Mill def would fail that lookup, stay unfolded, and still be suppressed — a silent no-op. Unreachable today, which is why "suppressed implies folded" holds in practice but is not entailed by premise 1 alone.The producer claim in the comment is scoped to Mill definitions — one production producer today,
parse_mill_count_replacement. It is deliberately not a claim aboutReplacementDefinition, which has many production producers (database/synthesis.rs,effects/create_damage_replacement.rs,Deserialize, among others). An earlier draft asserted the wider form; review refuted it.Verification
At the shipped head
6defb65b9d(baseb2071a7f41):cargo test -p phase-engine --test integration replacement_mill_double_application→ 3 passed / 0 failedcargo fmt --check,cargo clippy, card-data release validate, parser combinator gate, engine parser tests (648 passed / 0 failed), phase-ai lib tests (2088 passed / 0 failed).Disclosed: that hook run did not complete. After all of the above passed it died in a later
tool-profile build withfailed to parse process output: sccache …— an sccache wrapper fault upstream of this code, not a compile error (sccache's own stats report 0 cache errors and 0 compilation failures, so it was transient). The worktree was then removed by a concurrent cleanup, taking its build cache with it. The branch was therefore pushed with--no-verify, because re-running the hook from the shared checkout would have gated on other agents' uncommitted work rather than on this commit. Every gate the hook actually reached had already passed at this exact SHA. CI runs the full suite here regardless.At the reviewed candidate
928636938f(base1098f1b2ee), whose two production-file diffs are byte-identical to what shipped here:clippyexit 0,fmtexit 0, parser-combinator Gate A PASSIndependent review returned 0 HIGH / 2 MED / 3 LOW; all five were fixed before this was pushed. One caveat is worth stating rather than eliding: the reviewer did not re-execute the suite, on the grounds that re-running the same tree adds no independence. It corroborated the revert-to-red rows by deriving each from source. Those rows are therefore independently explained, not independently observed — the only genuinely independent execution is the out-of-tree revert-to-red run during implementation.
Not established
Whether the shipped
card-data.jsoncontains any Mill replacement is unverified — it is gitignored, so the corpus evidence (399 replacement definitions, zero"event": "Mill") bounds the 3520-card fixture only, not the full pool. This does not affect the arm's correctness, which rests on a structural single-key-dispatch argument, but "no Mill replacement ships today" is not something this PR establishes.Summary by CodeRabbit