Skip to content

Restore sacrificed permanents on activation cancel (fixes #3870)#4896

Open
kiannidev wants to merge 5 commits into
phase-rs:mainfrom
kiannidev:fix/3870-yawgmoth-sacrifice-cancel
Open

Restore sacrificed permanents on activation cancel (fixes #3870)#4896
kiannidev wants to merge 5 commits into
phase-rs:mainfrom
kiannidev:fix/3870-yawgmoth-sacrifice-cancel

Conversation

@kiannidev

Copy link
Copy Markdown
Contributor

Summary

  • Track permanents sacrificed during pending cast/activation cost payment on PendingCast
  • Return those permanents from the graveyard when the player cancels before the ability completes
  • Add an integration regression for Yawgmoth sacrificing an Eldrazi Spawn token then cancelling

Test plan

  • cargo test -p engine --test integration issue_3870
  • cargo build -p engine --tests

Fixes #3870

Made with Cursor

@kiannidev kiannidev requested a review from matthewevans as a code owner July 2, 2026 09:16
@matthewevans matthewevans self-assigned this Jul 2, 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.

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.

@matthewevans matthewevans removed their assignment Jul 2, 2026

@gemini-code-assist gemini-code-assist Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Code Review

This pull request implements 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.

Comment thread crates/engine/src/game/casting.rs Outdated
Comment on lines +13982 to +13990
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);
}
}

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.

high

[HIGH] Missing CR annotation, incorrect zone check, and event propagation during rollback

Why it matters:

  1. CR Annotation: Every rules-touching line of engine code must carry a verified CR <number>: <description> comment per Rule R6.
  2. 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::Exile rather than Zone::Graveyard, causing the rollback to fail to restore it.
  3. 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
  1. Every rules-touching line of engine code must carry a comment of the form CR : . (link)

Comment thread crates/engine/src/types/game_state.rs Outdated
Comment on lines +1881 to +1882
/// CR 601.2i: Permanents sacrificed to pay a cost component during this
/// pending cast/activation. Restored to the battlefield on `CancelCast`.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

[MEDIUM] 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.

Suggested change
/// 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
  1. Every rules-touching line of engine code must carry a comment of the form CR : . (link)

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

@kiannidev kiannidev requested a review from matthewevans July 2, 2026 14:53

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

@kiannidev kiannidev force-pushed the fix/3870-yawgmoth-sacrifice-cancel branch from 252e45f to fe87530 Compare July 2, 2026 16:18

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

@kiannidev

Copy link
Copy Markdown
Contributor Author

@matthewevans Thanks for the follow-up — addressed on b3feb1406:

  1. Restored issue_4358_hama_pashar_dungeon in integration/main.rs.
  2. Removed merge-contamination mods (issue_4356_trouble_in_pairs, issue_3882_ixhel_corrupted) so the branch diff is scoped to the Yawgmoth, Thran Physician — I began to activate Yawgmoth, Thran Physician's sacrifice ability sacrificing an eldrazi sp… #3870 rollback fix + regression only.
  3. Prior stale-rollback fix (feb404359): sacrifice snapshots are cleared on every activation stack-commit path (push_ability_entry, casting_targets.rs, spell finalize), with regression completed_activation_does_not_restore_prior_sacrifice_on_later_cancel.

CI is running on the new head. Ready for another look when you have a moment.

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

@kiannidev

Copy link
Copy Markdown
Contributor Author

@matthewevans Understood — restored both unrelated harness registrations on d61092993:

  • mod issue_3882_ixhel_corrupted;
  • mod issue_4356_trouble_in_pairs;

git diff upstream/main -- crates/engine/tests/integration/main.rs is now a single addition: mod issue_3870_yawgmoth_sacrifice_cancel;. CI is running on this head.

@github-actions

github-actions Bot commented Jul 3, 2026

Copy link
Copy Markdown

Parse changes introduced by this PR · 10 card(s), 7 signature(s) (baseline: main 4ed615d175eb)

3 card(s) · ability/Attach · field target: any targetparent target

Examples: Auratouched Mage, Curse of Misfortunes, Sovereigns of Lost Alara

3 card(s) · ability/Attach · field target: any targetself

Examples: Boonweaver Giant, Light-Paws, Emperor's Voice, Runed Crown

2 card(s) · ability/AddTargetReplacement · added: AddTargetReplacement (destination=Battlefield, event=ChangeZone, expiry=EndOfTurn)

Examples: Due Respect, Nahiri's Lithoforming

1 card(s) · ability/Attach · field target: any targetcreature

Examples: Arachnus Spinner

1 card(s) · ability/Attach · field target: any targetplayer

Examples: Bitterheart Witch

1 card(s) · ability/lands · removed: lands (duration=until end of turn)

Examples: Nahiri's Lithoforming

1 card(s) · ability/permanents · removed: permanents (duration=until end of turn)

Examples: Due Respect

1 card(s) had Oracle-text changes (errata/reprint) — excluded as non-parser.

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

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

@kiannidev

Copy link
Copy Markdown
Contributor Author

Addressed the CR citation feedback in 686827d:

  • Replaced all incorrect CR 730.1 (Mutate/day-night) references with the correct rules for this path:
    • CR 601.2 — rollback state cleared once cast commits to stack
    • CR 601.2i + CR 733.1 — cancel reverses paid sacrifices without queuing zone-change triggers
    • CR 733.1restore_object_for_cancel replays snapshot without normal ETB/zone side effects

Files touched: casting.rs, zones.rs, game_state.rs.

@matthewevans

Copy link
Copy Markdown
Member

Holding review on current head 686827dd97d5779d2594f0dad7ac1e6193350a0f: the current engine head was pushed after the latest coverage-parse-diff sticky, and Card data is still running for this head. Because this PR changes engine cost-cancel behavior, so I need current-head CI/parser evidence before re-review or approval.

I’m not approving or enqueueing from this pass. Once the current-head evidence lands, this should resurface for review.

@kiannidev kiannidev force-pushed the fix/3870-yawgmoth-sacrifice-cancel branch from 686827d to 5a993a4 Compare July 3, 2026 03:06

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

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.rs records rollback snapshots for chosen sacrifice costs.
  • crates/engine/src/game/zones.rs skips removal when current_zone == snapshot.zone, then re-adds the object.
  • add_to_zone appends 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.

@kiannidev

Copy link
Copy Markdown
Contributor Author

Addressed the latest review on current head:

  1. Same-zone snapshot restorerestore_object_for_cancel now always removes the object from its current zone before re-inserting, so a sacrifice prevented/redirected back to the battlefield cannot leave duplicate ObjectIds in state.battlefield after cancel.

  2. Production regressionyawgmoth_cancel_after_prevented_sacrifice_does_not_duplicate_battlefield drives Yawgmoth sacrifice cost selection on a CantBeSacrificed creature (stays on BF), then cancels and asserts exactly one battlefield list entry.

  3. casting_costs.rs — moved hash_map::Entry to file-level imports.

All 7 issue_3870 integration tests pass. Ready for re-review.

@matthewevans

Copy link
Copy Markdown
Member

Holding review on current head 692894eb5ffc6ed36be9e583ba98e0897d7d7262: this head was pushed after the latest <!-- coverage-parse-diff --> sticky, and Card data / coverage is still running for this 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 matthewevans self-assigned this Jul 5, 2026
@matthewevans matthewevans added the bug Bug fix label Jul 5, 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.

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.

@matthewevans matthewevans removed their assignment Jul 5, 2026
@kiannidev kiannidev force-pushed the fix/3870-yawgmoth-sacrifice-cancel branch from 692894e to e483277 Compare July 7, 2026 02:32
@kiannidev

Copy link
Copy Markdown
Contributor Author

Addressed the Jul 5 review on head e48327752 (rebased onto current main):

[HIGH] Persisted rollback snapshots

  • PendingCastSacrificeRollback now serializes via PersistedSession::pending_cast_sacrifice_rollbacks (server sidecar); from_persisted restores into GameState while GameState serde still skips the field for client views.
  • filter_state_for_viewer clears rollback snapshots for non-controllers (CR 400.2).
  • Regression: yawgmoth_cancel_restore_survives_persisted_session_roundtrip.

[MED] Attachment graph restoration

  • restore_object_for_cancel replays host attachments and attachment attached_to edges severed during sacrifice zone exit.
  • Regression: yawgmoth_cancel_restores_attached_equipment_host_graph (equipped host sacrificed, then cancel).

All 9 issue_3870 integration tests pass. Branch is rebased on latest main with only the #3870 rollback diff (+ main.rs mod).

kiannidev and others added 3 commits July 7, 2026 04:48
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>
@matthewevans

Copy link
Copy Markdown
Member

Holding review on current head 57df4bd8da7e7a30a4f252f6a9ce13cc383ca39d: this head was just updated onto current main, while the latest <!-- coverage-parse-diff --> sticky still predates it and Rust/card-data checks are still running. Because this PR changes engine cost-cancel behavior, I need current-head parse-diff evidence and completed Rust/card-data checks before the next judgment pass.

@kiannidev

Copy link
Copy Markdown
Contributor Author

Rebased onto current main and resolved the casting_costs.rs import conflict (kept upstream CounterRemoveChoice alongside PendingCastSacrificeRollback).

Linear 3-commit history on 4ed615d17; 9/9 issue_3870 tests pass, clippy clean.

@kiannidev kiannidev force-pushed the fix/3870-yawgmoth-sacrifice-cancel branch from 57df4bd to b6a7647 Compare July 7, 2026 02:54
@matthewevans

Copy link
Copy Markdown
Member

Holding review on current head b6a76470b4ea1fa4ce652470b3436598f95dcd2b: this head was pushed after the latest <!-- coverage-parse-diff --> sticky (2026-07-03T03:33:40Z), and Rust test shards plus card-data are still running for the new 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 matthewevans self-assigned this Jul 7, 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.

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

@matthewevans matthewevans removed their assignment Jul 7, 2026
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>
@kiannidev

Copy link
Copy Markdown
Contributor Author

Addressed the remaining [HIGH] blocker on browser/P2P resume:

PersistedGameState sidecar (mirrors server PersistedSession)

  • New engine::types::persisted_game_state::PersistedGameState carries pending_cast_sacrifice_rollbacks outside filtered GameState serde (CR 400.2).
  • WASM: export_persisted_game_state_json() for authoritative saves; restore_game_state / resume_multiplayer_host_state deserialize wrapper or legacy bare GameState.
  • Client: saveGameWithAdapter writes persisted envelope to IndexedDB; P2P host resume passes full JSON to resumeMultiplayerHostState; local resume uses loadPersistedGameJson.

Regression: yawgmoth_cancel_restore_survives_persisted_game_state_roundtrip.

10/10 issue_3870 tests pass; engine + engine-wasm clippy clean.

@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] 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>
@kiannidev

Copy link
Copy Markdown
Contributor Author

Fixed CI + latest review on head after c87d9197b:

  1. Main-thread fallbackexportPersistedState now calls wasm.export_persisted_game_state_json() (was an unbound identifier).
  2. TypeScript — removed duplicate adapter destructuring in dispatch.ts that shadowed the outer binding.
  3. Rust fmtpersisted_game_state.rs, engine-wasm/src/lib.rs, and integration test formatting.

Local cargo fmt --check, tsc -b --noEmit, and 10/10 issue_3870 tests pass.

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

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

bug Bug fix

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Yawgmoth, Thran Physician — I began to activate Yawgmoth, Thran Physician's sacrifice ability sacrificing an eldrazi sp…

2 participants