Skip to content

Finish #9484: a single-card leaves-the-battlefield trigger checks look-back for itself - #12198

Open
haksanlulz wants to merge 2 commits into
Card-Forge:masterfrom
haksanlulz:fix/9484-until-leaves-lookback
Open

haksanlulz wants to merge 2 commits into
Card-Forge:masterfrom
haksanlulz:fix/9484-until-leaves-lookback

Conversation

@haksanlulz

Copy link
Copy Markdown
Contributor

Finish #9484: a single-card leaves-the-battlefield trigger checks look-back for itself

Fixes #9484

Summary

A card exiled "until this leaves the battlefield" comes back during the host's own zone change (Card.runLeavesPlayCommands() inside GameAction.changeZone), so its triggers are registered before the host's dies trigger finds its watchers. In #9484 a Slaughter Specialist exiled by Brutal Cathar returned when Brutal Cathar died, saw it die, and got a +1/+1 counter.

It shouldn't. Brutal Cathar's ruling (2021-09-24): "The exiled card returns to the battlefield immediately after this creature leaves the battlefield. Nothing happens between the two events, including state-based actions. The two creatures aren't on the battlefield at the same time." A dies trigger looks back in time (CR 603.10a), so it sees the game as it was just before the event, with the Specialist still in exile.

#9491 ("Part for #9484") handles this wherever the death goes through a zone table's batch event: CardZoneTable.triggerChangesZoneAll rebuilds the active triggers from the event's last-state battlefield, so a permanent that is in play now but absent from that snapshot is left out. This covers the other part, a ChangesZone trigger evaluated without that batch event, where the trigger itself had no look-back check while TriggerChangesZoneAll.performTest already has one.

What changed

  • TriggerChangesZone.performTest makes the check TriggerChangesZoneAll already makes: a trigger that works on the battlefield, for an event whose origin is the battlefield, doesn't fire when its host is in play now but isn't in the event's last-state battlefield. A delayed trigger (no active zone) is left alone, and a host that has itself left is not in play, so its own leaves trigger, registered from its last known information, still fires.
  • The check reads AbilityKey.LastStateBattlefield from the run params, which effects (AbilityKey.addCardZoneTableParams), costs and state-based actions already pass, and does nothing when the params carry none.

Verification

  • UntilHostLeavesLookBackTest (forge-gui-desktop, 3 tests): Brutal Cathar exiles a watcher, a second copy of the watcher enters as the control, Brutal Cathar dies. The copy on the battlefield gets 1 counter, the returned one gets 0. (1) The death arrives through destroy with a zone table and no batch event: on master it fails with expected:<0> but was:<1>, here it passes. (2) Murder cast from hand, then state-based actions, for both Slaughter Specialist (ChangesZone) and Sengir Connoisseur (ChangesZoneAll): guards Only register LTB trigger that are in lastStateBattlefield #9491's prune. (3) The other direction: Zombie Apocalypse returns Midnight Reaper and then destroys a Human in the same resolution, and the Reaper still takes its 1 damage, so the check does not suppress a permanent that entered after the resolution's snapshot was taken.
  • mvn -U -B clean -P windows-linux install, Java 17, on this branch (based on master bb00615): BUILD SUCCESS, 0 Checkstyle violations, 773 tests, 0 failures, 6 skipped (NetworkPlayIntegrationTest).

Size

136+ / 0- across 2 files, one commit; 126 of the additions are the test.

Disclosure

Written with an AI coding agent (Claude Code); tested as described above. The commit carries the co-author line.

🤖 Generated with Claude Code

…e battlefield before the event

A card exiled "until this leaves the battlefield" returns inside the host's
own zone change, so its triggers were registered before the host's dies
trigger collected its watchers. A returned Slaughter Specialist therefore
saw Brutal Cathar die and got a counter (Card-Forge#9484), although the two are never
on the battlefield together (Brutal Cathar ruling) and leaves-the-battlefield
triggers look back to the game state before the event (CR 603.10a).

TriggerChangesZone now applies the check TriggerChangesZoneAll already makes:
a battlefield trigger of a permanent in play now but absent from the event's
last-state battlefield does not fire. The check needs that snapshot in the
run params, which effects and state-based actions already supply.

Fixes Card-Forge#9484

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Comment on lines +99 to +101
CardCollectionView lastStateBattlefield = (CardCollectionView) runParams.get(AbilityKey.LastStateBattlefield);
if (lastStateBattlefield != null && !lastStateBattlefield.contains(getHostCard())) {
return false;

@Hanmac Hanmac Oct 9, 2026 •

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.

should probably be combined with the block below at line 111-117?

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

I folded them in, thanks.

@tool4ever tool4ever added the Rules compliance Bringing the engine or cards closer to CR label Oct 9, 2026
game.copyLastState();
Map<AbilityKey, Object> params = AbilityKey.newMap();
AbilityKey.addCardZoneTableParams(params, new CardZoneTable(game.getLastStateBattlefield(), game.getLastStateGraveyard()));
game.getAction().destroy(game.getCardState(c[0]), null, false, params);

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.

I don't know, this isn't how it'd happen in the engine normally and that's the only reason you get this test to fail...

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

That’s fair. I think it's still worth keeping the route tests, #9491 has none and they’d be the only thing that notices if the prune ever regresses. The check itself I can keep as is now that it’s folded, or drop, either works. The original issue can be closed in anycase.

Review feedback: TriggerChangesZone had two blocks of the same shape, the new
battlefield one and the graveyard one for Boneyard Scourge. One block now keys
on the event's origin and picks the last-state zone by where the trigger works.
The graveyard branch keeps its condition on the trigger's own Origin parameter
and gains the null guard the battlefield branch has.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
@haksanlulz
haksanlulz force-pushed the fix/9484-until-leaves-lookback branch from 30ec120 to a001228 Compare October 9, 2026 19:30

This branch has not been deployed

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

Labels

Rules compliance Bringing the engine or cards closer to CR

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Brutal Cathar + Slaughter Specialist.

3 participants