fix Cathedral Membrane, add test

use game default BlockedAttackerWatcher
This commit is contained in:
xenohedron 2025-06-21 22:25:56 -04:00
parent b29ece2125
commit 7883d90cc8
8 changed files with 152 additions and 77 deletions

View file

@ -1,21 +1,22 @@
package mage.cards.c;
import mage.MageInt;
import mage.MageObjectReference;
import mage.abilities.Ability;
import mage.abilities.common.ZoneChangeTriggeredAbility;
import mage.abilities.common.DiesSourceTriggeredAbility;
import mage.abilities.effects.OneShotEffect;
import mage.abilities.keyword.DefenderAbility;
import mage.cards.CardImpl;
import mage.cards.CardSetInfo;
import mage.constants.*;
import mage.constants.CardType;
import mage.constants.Outcome;
import mage.constants.SubType;
import mage.constants.TurnPhase;
import mage.game.Game;
import mage.game.events.GameEvent;
import mage.game.permanent.Permanent;
import mage.watchers.Watcher;
import mage.watchers.common.BlockedAttackerWatcher;
import java.util.HashSet;
import java.util.Set;
import java.util.UUID;
/**
@ -35,8 +36,7 @@ public final class CathedralMembrane extends CardImpl {
this.addAbility(DefenderAbility.getInstance());
// When Cathedral Membrane dies during combat, it deals 6 damage to each creature it blocked this combat.
this.addAbility(new CathedralMembraneAbility(), new CathedralMembraneWatcher());
this.addAbility(new CathedralMembraneAbility());
}
private CathedralMembrane(final CathedralMembrane card) {
@ -49,10 +49,11 @@ public final class CathedralMembrane extends CardImpl {
}
}
class CathedralMembraneAbility extends ZoneChangeTriggeredAbility {
class CathedralMembraneAbility extends DiesSourceTriggeredAbility {
CathedralMembraneAbility() {
super(Zone.BATTLEFIELD, Zone.GRAVEYARD, new CathedralMembraneEffect(), "When {this} dies during combat, ", false);
super(new CathedralMembraneEffect());
setTriggerPhrase("When {this} dies during combat, ");
}
private CathedralMembraneAbility(CathedralMembraneAbility ability) {
@ -66,12 +67,7 @@ class CathedralMembraneAbility extends ZoneChangeTriggeredAbility {
@Override
public boolean checkTrigger(GameEvent event, Game game) {
if (super.checkTrigger(event, game)) {
if (game.getTurnPhaseType() == TurnPhase.COMBAT) {
return true;
}
}
return false;
return game.getTurnPhaseType() == TurnPhase.COMBAT && super.checkTrigger(event, game);
}
}
@ -94,41 +90,13 @@ class CathedralMembraneEffect extends OneShotEffect {
@Override
public boolean apply(Game game, Ability source) {
CathedralMembraneWatcher watcher = game.getState().getWatcher(CathedralMembraneWatcher.class, source.getSourceId());
if (watcher != null) {
for (UUID uuid : watcher.getBlockedCreatures()) {
Permanent permanent = game.getPermanent(uuid);
if (permanent != null) {
permanent.damage(6, source.getSourceId(), source, game, false, true);
}
Permanent permanent = source.getSourcePermanentOrLKI(game);
BlockedAttackerWatcher watcher = game.getState().getWatcher(BlockedAttackerWatcher.class);
if (watcher != null && permanent != null) {
for (Permanent p : watcher.getBlockedCreatures(new MageObjectReference(permanent, game), game)) {
p.damage(6, source.getSourceId(), source, game, false, true);
}
}
return true;
}
}
class CathedralMembraneWatcher extends Watcher {
private final Set<UUID> blockedCreatures = new HashSet<>();
CathedralMembraneWatcher() {
super(WatcherScope.CARD);
}
@Override
public void watch(GameEvent event, Game game) {
if (event.getType() == GameEvent.EventType.BLOCKER_DECLARED && event.getSourceId().equals(sourceId)) {
blockedCreatures.add(event.getTargetId());
}
}
@Override
public void reset() {
super.reset();
blockedCreatures.clear();
}
Set<UUID> getBlockedCreatures() {
return blockedCreatures;
}
}

View file

@ -102,7 +102,7 @@ class GazeOfTheGorgonEffect extends OneShotEffect {
List<Permanent> toDestroy = new ArrayList<>();
for (Permanent creature : game.getBattlefield().getActivePermanents(StaticFilters.FILTER_PERMANENT_CREATURE, source.getControllerId(), source, game)) {
if (!creature.getId().equals(targetCreature.getSourceId())) {
if (watcher.creatureHasBlockedAttacker(new MageObjectReference(creature, game), targetCreature, game) || watcher.creatureHasBlockedAttacker(targetCreature, new MageObjectReference(creature, game), game)) {
if (watcher.creatureHasBlockedAttacker(new MageObjectReference(creature, game), targetCreature) || watcher.creatureHasBlockedAttacker(targetCreature, new MageObjectReference(creature, game))) {
toDestroy.add(creature);
}
}

View file

@ -20,7 +20,6 @@ import mage.filter.common.FilterCreaturePermanent;
import mage.game.Game;
import mage.game.permanent.Permanent;
import mage.target.TargetPermanent;
import mage.target.common.TargetCreaturePermanent;
import mage.target.targetpointer.FixedTarget;
import mage.watchers.common.BlockedAttackerWatcher;
@ -82,7 +81,7 @@ class GlyphOfDelusionSecondTarget extends TargetPermanent {
if (targetSource != null) {
for (Permanent creature : game.getBattlefield().getActivePermanents(StaticFilters.FILTER_PERMANENT_CREATURE, sourceControllerId, source, game)) {
if (!targets.containsKey(creature.getId()) && creature.canBeTargetedBy(targetSource, sourceControllerId, source, game)) {
if (watcher.creatureHasBlockedAttacker(new MageObjectReference(creature, game), new MageObjectReference(firstTarget, game), game)) {
if (watcher.creatureHasBlockedAttacker(new MageObjectReference(creature, game), new MageObjectReference(firstTarget, game))) {
possibleTargets.add(creature.getId());
}
}

View file

@ -20,7 +20,6 @@ import mage.game.Game;
import mage.game.permanent.Permanent;
import mage.players.Player;
import mage.target.TargetPermanent;
import mage.target.common.TargetCreaturePermanent;
import mage.watchers.common.BlockedAttackerWatcher;
/**
@ -110,7 +109,7 @@ class GlyphOfDoomEffect extends OneShotEffect {
List<Permanent> toDestroy = new ArrayList<>();
for (Permanent creature : game.getBattlefield().getActivePermanents(StaticFilters.FILTER_PERMANENT_CREATURE, source.getControllerId(), source, game)) {
if (!creature.getId().equals(targetCreature.getSourceId())) {
if (watcher.creatureHasBlockedAttacker(new MageObjectReference(creature, game), targetCreature, game)) {
if (watcher.creatureHasBlockedAttacker(new MageObjectReference(creature, game), targetCreature)) {
toDestroy.add(creature);
}
}

View file

@ -22,7 +22,6 @@ import mage.players.Player;
import mage.target.Target;
import mage.target.TargetPermanent;
import mage.target.common.TargetCardInGraveyard;
import mage.target.common.TargetCreaturePermanent;
import mage.watchers.common.BlockedAttackerWatcher;
import java.util.HashMap;
@ -87,7 +86,7 @@ class GlyphOfReincarnationEffect extends OneShotEffect {
Map<UUID, Player> destroyed = new HashMap<>();
for (Permanent creature : game.getBattlefield().getActivePermanents(StaticFilters.FILTER_PERMANENT_CREATURE, source.getControllerId(), source, game)) {
if (!creature.getId().equals(targetWall.getId())) {
if (watcher.creatureHasBlockedAttacker(new MageObjectReference(creature, game), new MageObjectReference(targetWall, game), game)) {
if (watcher.creatureHasBlockedAttacker(new MageObjectReference(creature, game), new MageObjectReference(targetWall, game))) {
if (creature.destroy(source, game, true)
&& game.getState().getZone(creature.getId()) == Zone.GRAVEYARD) { // If a commander is replaced to command zone, the creature does not die
Player permController = game.getPlayer(creature.getControllerId());

View file

@ -101,7 +101,7 @@ class VenomousBreathEffect extends OneShotEffect {
List<Permanent> toDestroy = new ArrayList<>();
for (Permanent creature : game.getBattlefield().getActivePermanents(StaticFilters.FILTER_PERMANENT_CREATURE, source.getControllerId(), source, game)) {
if (!creature.getId().equals(targetCreature.getSourceId())) {
if (watcher.creatureHasBlockedAttacker(new MageObjectReference(creature, game), targetCreature, game) || watcher.creatureHasBlockedAttacker(targetCreature, new MageObjectReference(creature, game), game)) {
if (watcher.creatureHasBlockedAttacker(new MageObjectReference(creature, game), targetCreature) || watcher.creatureHasBlockedAttacker(targetCreature, new MageObjectReference(creature, game))) {
toDestroy.add(creature);
}
}

View file

@ -0,0 +1,109 @@
package org.mage.test.cards.watchers;
import mage.constants.PhaseStep;
import mage.constants.Zone;
import org.junit.Test;
import org.mage.test.serverside.base.CardTestPlayerBase;
/**
* @author xenohedron
*/
public class CathedralMembraneTest extends CardTestPlayerBase {
// see issue #13774
private static final String cathedralMembrane = "Cathedral Membrane"; // 0/6
// Defender
// When this creature dies during combat, it deals 6 damage to each creature it blocked this combat.
private static final String wurm = "Autochthon Wurm"; // 9/14 convoke trample
private static final String gigantosaurus = "Gigantosaurus"; // 10/10
private static final String moraug = "Moraug, Fury of Akoum"; // 6/6
// Each creature you control gets +1/+0 for each time it has attacked this turn.
// Landfall Whenever a land you control enters, if it's your main phase,
// there's an additional combat phase after this phase. At the beginning of that combat, untap all creatures you control.
private static final String recovery = "Miraculous Recovery"; // 4W instant
// Return target creature card from your graveyard to the battlefield. Put a +1/+1 counter on it.
@Test
public void testMembraneTrigger() {
addCard(Zone.HAND, playerA, "Mountain");
addCard(Zone.BATTLEFIELD, playerA, wurm);
addCard(Zone.BATTLEFIELD, playerA, gigantosaurus);
addCard(Zone.BATTLEFIELD, playerA, moraug);
addCard(Zone.HAND, playerB, cathedralMembrane);
addCard(Zone.HAND, playerB, recovery);
addCard(Zone.BATTLEFIELD, playerB, "Plains", 6);
castSpell(2, PhaseStep.PRECOMBAT_MAIN, playerB, cathedralMembrane);
setChoice(playerB, true); // pay life
attack(3, playerA, wurm, playerB);
block(3, playerB, cathedralMembrane, wurm);
setChoiceAmount(playerA, 6); // assign trample damage
setStrictChooseMode(true);
setStopAt(3, PhaseStep.END_TURN);
execute();
assertTapped(wurm, true);
assertTapped(gigantosaurus, false);
assertTapped(moraug, false);
assertGraveyardCount(playerB, cathedralMembrane, 1);
assertLife(playerA, 20);
assertLife(playerB, 20 - 2 - 4);
assertDamageReceived(playerA, wurm, 6);
assertDamageReceived(playerA, gigantosaurus, 0);
assertDamageReceived(playerA, moraug, 0);
}
@Test
public void testMembraneTriggerAgain() {
addCard(Zone.HAND, playerA, "Mountain");
addCard(Zone.BATTLEFIELD, playerA, wurm);
addCard(Zone.BATTLEFIELD, playerA, gigantosaurus);
addCard(Zone.BATTLEFIELD, playerA, moraug);
addCard(Zone.HAND, playerB, cathedralMembrane);
addCard(Zone.HAND, playerB, recovery);
addCard(Zone.BATTLEFIELD, playerB, "Plains", 6);
castSpell(2, PhaseStep.PRECOMBAT_MAIN, playerB, cathedralMembrane);
setChoice(playerB, true); // pay life
attack(3, playerA, wurm, playerB);
block(3, playerB, cathedralMembrane, wurm);
setChoiceAmount(playerA, 6); // assign trample damage
setStrictChooseMode(true);
setStopAt(3, PhaseStep.END_COMBAT);
execute(); // separate execute needed to separate commands from second combat phase
playLand(3, PhaseStep.POSTCOMBAT_MAIN, playerA, "Mountain");
castSpell(3, PhaseStep.POSTCOMBAT_MAIN, playerB, recovery, cathedralMembrane);
attack(3, playerA, wurm, playerB);
attack(3, playerA, gigantosaurus, playerB);
block(3, playerB, cathedralMembrane, gigantosaurus);
setStopAt(3, PhaseStep.END_TURN);
execute();
assertTapped(wurm, true);
assertTapped(gigantosaurus, true);
assertTapped(moraug, false);
assertGraveyardCount(playerB, cathedralMembrane, 1);
assertLife(playerA, 20);
assertLife(playerB, 20 - 2 - 4 - 11);
assertDamageReceived(playerA, wurm, 6);
assertDamageReceived(playerA, gigantosaurus, 1 + 6);
assertDamageReceived(playerA, moraug, 0);
}
}

View file

@ -1,9 +1,8 @@
package mage.watchers.common;
import java.util.HashMap;
import java.util.HashSet;
import java.util.Map;
import java.util.Set;
import java.util.*;
import java.util.stream.Collectors;
import mage.MageObjectReference;
import mage.constants.WatcherScope;
import mage.game.Game;
@ -17,7 +16,9 @@ import mage.watchers.Watcher;
*/
public class BlockedAttackerWatcher extends Watcher {
private final Map<MageObjectReference, Set<MageObjectReference>> blockData = new HashMap<>();
// key: blocking creatures
// value: set of creatures blocked
private final Map<MageObjectReference, Set<MageObjectReference>> blockerMap = new HashMap<>();
/**
* Game default watcher
@ -29,31 +30,31 @@ public class BlockedAttackerWatcher extends Watcher {
@Override
public void watch(GameEvent event, Game game) {
if (event.getType() == GameEvent.EventType.BLOCKER_DECLARED) {
MageObjectReference blocker = new MageObjectReference(event.getSourceId(), game);
Set<MageObjectReference> blockedAttackers = blockData.get(blocker);
if (blockedAttackers != null) {
blockedAttackers.add(new MageObjectReference(event.getTargetId(), game));
} else {
blockedAttackers = new HashSet<>();
blockedAttackers.add(new MageObjectReference(event.getTargetId(), game));
blockData.put(blocker, blockedAttackers);
}
blockerMap.computeIfAbsent(new MageObjectReference(event.getSourceId(), game), k -> new HashSet<>())
.add(new MageObjectReference(event.getTargetId(), game));
}
}
@Override
public void reset() {
super.reset();
blockData.clear();
blockerMap.clear();
}
public boolean creatureHasBlockedAttacker(Permanent attacker, Permanent blocker, Game game) {
Set<MageObjectReference> blockedAttackers = blockData.get(new MageObjectReference(blocker, game));
return blockedAttackers != null && blockedAttackers.contains(new MageObjectReference(attacker, game));
return blockerMap.getOrDefault(new MageObjectReference(blocker, game), Collections.emptySet())
.contains(new MageObjectReference(attacker, game));
}
public boolean creatureHasBlockedAttacker(MageObjectReference attacker, MageObjectReference blocker, Game game) {
Set<MageObjectReference> blockedAttackers = blockData.get(blocker);
return blockedAttackers != null && blockedAttackers.contains(attacker);
public boolean creatureHasBlockedAttacker(MageObjectReference attacker, MageObjectReference blocker) {
return blockerMap.getOrDefault(blocker, Collections.emptySet()).contains(attacker);
}
public Set<Permanent> getBlockedCreatures(MageObjectReference blocker, Game game) {
return blockerMap.getOrDefault(blocker, Collections.emptySet())
.stream()
.map(m -> m.getPermanent(game))
.filter(Objects::nonNull)
.collect(Collectors.toSet());
}
}