Restore sacrificed permanents on activation cancel (fixes #3870)#4896
Restore sacrificed permanents on activation cancel (fixes #3870)#4896kiannidev wants to merge 5 commits into
Conversation
matthewevans
left a comment
There was a problem hiding this comment.
The rollback needs to carry enough zone-change state to undo the cost payment correctly. Right now PendingCast stores only object ids and handle_cancel_cast restores only objects currently in Zone::Graveyard, always by moving them to Zone::Battlefield (crates/engine/src/game/casting.rs:13982). But sacrifice is routed through the replacement pipeline (game/sacrifice.rs), so a paid sacrifice can be redirected/prevented or end up somewhere other than the graveyard. In those cases the pending activation would have consumed the cost, but CancelCast would not restore it.
Please snapshot/restore the actual paid sacrifice outcome through the same rollback model as the rest of pending-cost cancellation, and cover a replacement/redirected-sacrifice case. There is also a formatting failure in this head: crates/engine/src/game/visibility.rs:968-970 lost indentation, which matches the failing Rust lint check.
There was a problem hiding this comment.
Code Review
This pull request implements a rollback mechanism for permanents sacrificed to pay costs when a spell or ability activation is cancelled, addressing issue #3870. It introduces a cost_paid_sacrifices field to PendingCast to track these permanents and restores them to the battlefield during handle_cancel_cast. Feedback on the changes highlights a missing CR annotation and incorrect event propagation during rollback, as well as the need to handle cases where sacrificed permanents are moved to exile instead of the graveyard due to replacement effects. Additionally, a correction was suggested for a CR annotation in game_state.rs to reference CR 730.1 instead of CR 601.2i.
Important
The consumer version of Gemini Code Assist on GitHub is being sunset. Starting June 18, 2026, new organization installations will be blocked, and all code review activity will officially cease on July 17, 2026.
For more details on the timeline and next steps, please review the Help Documentation.
| for object_id in &pending.cost_paid_sacrifices { | ||
| if state | ||
| .objects | ||
| .get(object_id) | ||
| .is_some_and(|obj| obj.zone == Zone::Graveyard) | ||
| { | ||
| super::zones::move_to_zone(state, *object_id, Zone::Battlefield, _events); | ||
| } | ||
| } |
There was a problem hiding this comment.
[HIGH] Missing CR annotation, incorrect zone check, and event propagation during rollback
Why it matters:
- CR Annotation: Every rules-touching line of engine code must carry a verified
CR <number>: <description>comment per Rule R6. - Replacement Effects: If a replacement effect (such as Rest in Peace or Leyline of the Void) exiled the sacrificed permanent, it will be in
Zone::Exilerather thanZone::Graveyard, causing the rollback to fail to restore it. - Trigger Prevention: Under CR 730.1, no abilities trigger and no effects apply as a result of an undone action. Propagating the zone-change events to
_events(which was previously unused, as indicated by the leading underscore) could cause ETB or leaves-the-battlefield abilities to illegally trigger during the rollback.
Suggested fix:
Add the CR 730.1 annotation, check both Graveyard and Exile zones, and use a local temporary event vector to discard the rollback events.
// CR 730.1: If a player starts to take an action but can't legally complete it, the entire action is reversed and any payments already made are canceled.
let mut rollback_events = Vec::new();
for object_id in &pending.cost_paid_sacrifices {
if state
.objects
.get(object_id)
.is_some_and(|obj| obj.zone == Zone::Graveyard || obj.zone == Zone::Exile)
{
super::zones::move_to_zone(state, *object_id, Zone::Battlefield, &mut rollback_events);
}
}References
- Every rules-touching line of engine code must carry a comment of the form CR : . (link)
| /// CR 601.2i: Permanents sacrificed to pay a cost component during this | ||
| /// pending cast/activation. Restored to the battlefield on `CancelCast`. |
There was a problem hiding this comment.
[MEDIUM] Incorrect CR annotation for reversed sacrifices
Why it matters:
The cited rule CR 601.2i describes when a spell becomes cast and triggers are put on the stack. The rule governing the reversal of illegal or cancelled actions and their payments is CR 730.1.
Suggested fix:
Update the CR annotation to reference CR 730.1.
| /// CR 601.2i: Permanents sacrificed to pay a cost component during this | |
| /// pending cast/activation. Restored to the battlefield on `CancelCast`. | |
| /// CR 730.1: Permanents sacrificed to pay a cost component during this | |
| /// pending cast/activation. Restored to the battlefield on CancelCast. |
References
- Every rules-touching line of engine code must carry a comment of the form CR : . (link)
matthewevans
left a comment
There was a problem hiding this comment.
[HIGH] The rollback still stores only object ids and restores only graveyard objects to the battlefield. Evidence: crates/engine/src/types/game_state.rs:1881 adds cost_paid_sacrifices: Vec<ObjectId>, and handle_cancel_cast only checks obj.zone == Zone::Graveyard before move_to_zone(..., Zone::Battlefield) at crates/engine/src/game/casting.rs:13982; this is the same missing provenance called out on the prior head. Why it matters: sacrifice cost payment can move objects through replacement effects or zone-specific outcomes, and cancel rollback must undo the actual cost-payment zone change instead of assuming every paid sacrifice is still in the graveyard and should return to the battlefield. Suggested fix: record the produced ZoneChanged/movement provenance for sacrificed cost payments and rollback from that recorded transition, with tests for a non-graveyard replacement path rather than only the simple token-to-graveyard case.
The parser/card-data parse-diff sticky is also absent on this current head, so this PR remains unapprovable until the required evidence exists.
matthewevans
left a comment
There was a problem hiding this comment.
[HIGH] Cancel rollback emits restore zone changes into the normal post-action trigger pipeline. Evidence: crates/engine/src/game/casting.rs:13999 passes rollback moves into _events, and crates/engine/src/game/engine.rs:4764 scans action events when the action returns to Priority; CR 733.1 says no abilities trigger and no effects apply from an undone action. Why it matters: restoring a sacrificed permanent during CancelCast can incorrectly fire ETB/LTB observers, so the current test covers the simple zone result but not the rules-critical side effect. Suggested fix: perform rollback with suppressed/local events or an explicit no-trigger rollback path, and add a regression with an ETB/dies observer proving cancel restoration queues no triggers.
252e45f to
fe87530
Compare
matthewevans
left a comment
There was a problem hiding this comment.
[HIGH] Cancel leaves already-collected sacrifice triggers queued. Evidence: crates/engine/src/game/casting_costs.rs:1550 collects cost-payment events into state.deferred_triggers before the later CancelCast, and crates/engine/src/game/engine_priority.rs:135 drains that queue once cancel returns to priority. Why it matters: a dies/LTB observer from the sacrificed cost can still fire from an action that was rolled back. Suggested fix: make cost rollback purge or suppress deferred triggers and state records produced by the canceled payment, and add a dies/LTB observer regression.
[HIGH] Rollback restores only the zone, not the pre-payment permanent state. Evidence: crates/engine/src/types/game_state.rs:1885 stores only (ObjectId, Zone), while crates/engine/src/game/casting.rs:13999 restores through normal move_to_zone; that normal path clears face-down identity, counters, attachments, and battlefield-entry state at crates/engine/src/game/zones.rs:649, crates/engine/src/game/zones.rs:658, and crates/engine/src/game/game_object.rs:1479. Why it matters: canceling after sacrificing a face-down, countered, tapped, attached, or otherwise stateful permanent leaks/loses state even though the action should be reversed. Suggested fix: snapshot and restore the full pre-payment object/zone-list state through a rollback-specific path, with hidden-state and stateful-permanent coverage.
[HIGH] Real sacrificed tokens still do not restore, and the "token" test is not a token. Evidence: crates/engine/tests/integration/issue_3870_yawgmoth_sacrifice_cancel.rs:83 creates Eldrazi Spawn with add_creature, but crates/engine/src/game/scenario.rs:344 does not set is_token; real tokens outside the battlefield are rejected by move_to_zone at crates/engine/src/game/zones.rs:538. Why it matters: token sacrifice fodder remains in the graveyard/exile on cancel instead of being restored by the rollback. Suggested fix: do not use normal token zone-change guards for rollback restoration, and mark the regression fixture is_token = true.
matthewevans
left a comment
There was a problem hiding this comment.
[HIGH] Full sacrifice rollback snapshots leak hidden permanent identity to non-controllers. Evidence: crates/engine/src/types/game_state.rs:1898 serializes cost_paid_sacrifices inside PendingCast, while crates/engine/src/game/visibility.rs:670 leaves pending casts visible to opponents under the now-false assumption that they carry only public data. Why it matters: sacrificing a face-down permanent and then pausing before cancel/commit can expose its full GameObject snapshot through filtered multiplayer state. Suggested fix: redact cost_paid_sacrifices in every filtered PendingCast surface, or store rollback snapshots outside serialized/view-filtered state.
[HIGH] Battlefield-redirected sacrifice rollback duplicates the object in the battlefield zone list. Evidence: crates/engine/src/game/zones.rs:514 skips removal when the current zone equals the snapshot zone, then crates/engine/src/game/zones.rs:522 unconditionally re-adds the object. Why it matters: canceling a sacrifice cost that was redirected back to the battlefield can leave duplicate ObjectIds in state.battlefield, corrupting filters, targets, and trigger scans. Suggested fix: remove the object id from its current zone before every restore re-add, or make rollback insertion idempotent and add a battlefield-redirect cancel regression.
[MED] The PR disables an unrelated integration test module. Evidence: crates/engine/tests/integration/main.rs:395 now jumps from issue_4357_molecule_man to issue_4379_convoke_cancel_untap while crates/engine/tests/integration/issue_4358_hama_pashar_dungeon.rs still exists. Why it matters: merging this silently drops issue 4358 coverage from the production integration suite. Suggested fix: restore mod issue_4358_hama_pashar_dungeon;.
matthewevans
left a comment
There was a problem hiding this comment.
[HIGH] Successful activations leave stale sacrifice rollback snapshots behind. Evidence: crates/engine/src/game/casting_costs.rs:1512 inserts snapshots into state.pending_cast_sacrifice_rollbacks keyed by the pending cast object, but the only removal I can find is the cancel path at crates/engine/src/game/casting.rs:13993; the successful stack-finalization path inserts stack_paid_facts around crates/engine/src/game/casting_costs.rs:6052 and continues without clearing the rollback entry. Why it matters: after one Yawgmoth activation completes normally, a later activation from the same source that gets canceled can consume the stale entry and restore a permanent that was sacrificed for the already-completed activation. Suggested fix: clear pending_cast_sacrifice_rollbacks.remove(&object_id) at every non-cancel commit point for the pending cast/activation, and add a regression that completes one sacrifice activation before canceling a second activation from the same source.
matthewevans
left a comment
There was a problem hiding this comment.
[HIGH] The current branch still drops an unrelated integration test module. Evidence: crates/engine/tests/integration/main.rs removes mod issue_4358_hama_pashar_dungeon;, while origin/main still contains that module registration. Why it matters: this PR is scoped to sacrifice-cost cancel rollback for #3870, but the diff disables an unrelated regression test, so the branch is contaminated and cannot be approved/enqueued as-is. Suggested fix: restore the issue_4358_hama_pashar_dungeon module registration and keep the PR diff limited to the rollback fix and its #3870 regression.
|
@matthewevans Thanks for the follow-up — addressed on
CI is running on the new head. Ready for another look when you have a moment. |
matthewevans
left a comment
There was a problem hiding this comment.
[HIGH] The branch is still dropping unrelated integration modules from the harness. Evidence: current origin/main contains mod issue_3882_ixhel_corrupted; and mod issue_4356_trouble_in_pairs;, but current head b3feb1406 removes both registrations from crates/engine/tests/integration/main.rs while only adding the #3870 test. Why it matters: this PR is scoped to sacrifice-cost cancel rollback, but the diff disables unrelated regression coverage, so it is still contaminated and cannot be approved/enqueued. Suggested fix: restore both unrelated module registrations and keep main.rs limited to adding mod issue_3870_yawgmoth_sacrifice_cancel;.
|
@matthewevans Understood — restored both unrelated harness registrations on
|
Parse changes introduced by this PR · 10 card(s), 7 signature(s) (baseline: main
|
matthewevans
left a comment
There was a problem hiding this comment.
[MED] The new rollback code cites the wrong Comprehensive Rules section in multiple places. Evidence: crates/engine/src/game/casting.rs:14190, crates/engine/src/game/casting.rs:14282, crates/engine/src/game/zones.rs:506, crates/engine/src/types/game_state.rs:1785, and crates/engine/src/types/game_state.rs:7379 cite CR 730.1, but local docs/MagicCompRules.txt says 730.1 is Mutate; the reversal rule this code is trying to implement is CR 733.1 alongside the existing CR 601.2i cast-cancel path. Why it matters: this project treats CR annotations as executable review evidence for engine logic, and a wrong rule number is worse than a missing one. Suggested fix: replace the CR 730.1 citations with the verified rollback/cancel rules that actually describe the code.
|
Addressed the CR citation feedback in 686827d:
Files touched: |
|
Holding review on current head I’m not approving or enqueueing from this pass. Once the current-head evidence lands, this should resurface for review. |
686827d to
5a993a4
Compare
matthewevans
left a comment
There was a problem hiding this comment.
Thanks for the updates. This is still not ready on current head 5a993a496caa713c33915f007a6a36b01e3a885d.
The remaining blocker is in the rollback path for sacrificed permanents when a replacement/prevention effect leaves the object on the battlefield. The cost path records rollback snapshots for selected sacrifices, but restore_object_snapshot_to_zone skips removing the object when its current zone already equals the snapshot zone and then calls add_to_zone, which pushes unconditionally. That can leave duplicate entries for the same object in state.battlefield after canceling a cast/activation whose sacrifice was redirected/prevented back onto the battlefield.
Evidence:
crates/engine/src/game/casting_costs.rsrecords rollback snapshots for chosen sacrifice costs.crates/engine/src/game/zones.rsskips removal whencurrent_zone == snapshot.zone, then re-adds the object.add_to_zoneappends to the zone vector unconditionally.- The existing sacrifice pipeline already supports the object remaining/returning on the battlefield via replacement effects, so this is reachable behavior, not a theoretical impossible state.
Please make snapshot restore idempotent for same-zone snapshots, or otherwise remove the current zone membership before re-adding, and add a production-path regression covering cancel after a battlefield-redirected/prevented sacrifice. Also move the inline Entry import in casting_costs.rs to the file-level imports.
|
Addressed the latest review on current head:
All 7 |
|
Holding review on current head Because this PR changes engine cost-cancel behavior, I need current-head coverage/parser evidence before approving. I’ll re-review once the sticky is refreshed for this SHA. |
matthewevans
left a comment
There was a problem hiding this comment.
Thanks for the update. I found two remaining rollback correctness blockers:
[HIGH] Cancel rollback state is lost across persisted/resumed games. Evidence: crates/engine/src/types/game_state.rs:7380 marks pending_cast_sacrifice_rollbacks as #[serde(skip)], while crates/server-core/src/persist.rs:16 persists GameState and crates/server-core/src/session.rs:595 restores only other skipped fields. Why it matters: if a game is persisted or a P2P host resumes after a sacrifice-cost activation pauses before cancel, CancelCast no longer has the snapshot and the sacrificed permanent stays gone. Suggested fix: keep rollback snapshots in a persisted server-authoritative sidecar or serialize them with viewer redaction, and add a serde round-trip cancel regression.
[MED] Rollback does not restore attachment graph side effects for sacrificed attachments. Evidence: crates/engine/src/game/zones.rs:912 removes an attachment from its host on zone exit, but restore_object_for_cancel at crates/engine/src/game/zones.rs:509 restores only the sacrificed object snapshot and zone membership. Why it matters: canceling after sacrificing an attached Aura/Equipment can restore the attachment's attached_to field while leaving the host's attachments list missing it, breaking attachment-based layers, filters, and UI state. Suggested fix: snapshot and restore the related attachment graph edges, with a regression that sacrifices an attached Equipment/Aura and then cancels.
692894e to
e483277
Compare
|
Addressed the Jul 5 review on head [HIGH] Persisted rollback snapshots
[MED] Attachment graph restoration
All 9 |
Snapshot pre-payment sacrifices outside serializable PendingCast, roll back on CancelCast via restore_object_for_cancel (CR 733.1), purge deferred sacrifice triggers, and clear stale rollback on stack commit. Co-authored-by: Cursor <cursoragent@cursor.com>
Always remove the sacrificed permanent from its current zone before re-adding on cancel rollback, add a CantBeSacrificed regression, and hoist the hash_map::Entry import in casting_costs. Co-authored-by: Cursor <cursoragent@cursor.com>
… on cancel Store pending_cast_sacrifice_rollbacks in PersistedSession so cancel rollback survives server resume, redact snapshots from opponent views, and replay host/attachment edges when restoring sacrificed Equipment. Co-authored-by: Cursor <cursoragent@cursor.com>
|
Holding review on current head |
|
Rebased onto current Linear 3-commit history on |
57df4bd to
b6a7647
Compare
|
Holding review on current head Because this PR changes engine cost-cancel behavior, I need current-head parse-diff evidence and completed Rust/card-data checks before doing the next judgment pass. |
matthewevans
left a comment
There was a problem hiding this comment.
[HIGH] Browser/P2P resume still drops the sacrifice rollback sidecar. Evidence: crates/engine/src/types/game_state.rs:7964 marks pending_cast_sacrifice_rollbacks as #[serde(skip)], while the browser/P2P persistence path stores only GameState in IndexedDB (client/src/services/gamePersistence.ts:101) and resumes the P2P host from that saved state (client/src/adapter/p2p-adapter.ts:381); export_game_state_json also serializes only GameState (crates/engine-wasm/src/lib.rs:1166). Why it matters: if a P2P host or local browser game is persisted/reloaded after a sacrifice cost is paid but before CancelCast, the resumed engine has no rollback snapshots, so cancelling cannot restore the sacrificed permanent even though the server-side PersistedSession path now works. Suggested fix: mirror the server sidecar for the browser/WASM persisted-session path, or expose an internal persisted wrapper that carries rollback snapshots for authoritative resume without leaking them through filtered client state.
I rechecked the earlier blockers on current head b6a76470b4ea1fa4ce652470b3436598f95dcd2b: zone/exile restore, same-zone duplication, trigger suppression, stale successful-commit cleanup, CR citations, and server PersistedSession are addressed by the current diff and tests. The remaining blocker is the non-server persistence path above.
Mirror the server PersistedSession sidecar with PersistedGameState for IndexedDB saves and WASM resume paths so CancelCast can restore sacrificed permanents after a browser or P2P host reload. Co-authored-by: Cursor <cursoragent@cursor.com>
|
Addressed the remaining [HIGH] blocker on browser/P2P resume: PersistedGameState sidecar (mirrors server
Regression: 10/10 |
matthewevans
left a comment
There was a problem hiding this comment.
[HIGH] Main-thread fallback calls the new WASM export as an unbound identifier. Evidence: client/src/adapter/wasm-adapter.ts:742 defines exportPersistedState as enqueue(() => export_persisted_game_state_json()), while every other fallback WASM call in this scope goes through the dynamic import object wasm (wasm.restore_game_state, wasm.resume_multiplayer_host_state, etc.). Why it matters: this path is type-check/compile broken and the fallback adapter cannot persist the new rollback sidecar if Web Worker creation fails. Suggested fix: call wasm.export_persisted_game_state_json() inside the queued operation.
…owing Use wasm.export_persisted_game_state_json in the main-thread fallback, avoid redeclaring adapter in dispatch save paths, and apply rustfmt. Co-authored-by: Cursor <cursoragent@cursor.com>
|
Fixed CI + latest review on head after
Local |
matthewevans
left a comment
There was a problem hiding this comment.
[HIGH] P2P browser-host saves still drop the rollback sidecar. Evidence: client/src/game/dispatch.ts:316 persists every action through saveGameWithAdapter, and client/src/services/gamePersistence.ts:170 only writes the new persisted wrapper when the active adapter exposes exportPersistedState; but P2PHostAdapter only delegates getState() to its inner WASM adapter at client/src/adapter/p2p-adapter.ts:1106 and does not implement exportPersistedState, while the local WasmAdapter does at client/src/adapter/wasm-adapter.ts:440. Why it matters: after a P2P host pays a sacrifice cost and the browser saves before cancel/resume, IndexedDB still receives bare GameState, whose serde skips pending_cast_sacrifice_rollbacks, so the resumed host cannot undo the paid sacrifice. Suggested fix: add P2PHostAdapter.exportPersistedState() delegating to this.wasm.exportPersistedState() and cover the P2P save/resume path, not only the wrapper serde helper.
Summary
PendingCastTest plan
cargo test -p engine --test integration issue_3870cargo build -p engine --testsFixes #3870
Made with Cursor