From 33fe4730aee539ff6a93813610e4cdc8b5677f2d Mon Sep 17 00:00:00 2001 From: xenohedron Date: Sun, 26 May 2024 20:01:01 -0400 Subject: [PATCH] Fix first strike damage logic (#12297) * add tests for first strike rules * fix first strike damage logic per 702.7c * add more test cases * update logic to not check actual damage dealt * add another test case * adjust naming and docs --- .../org/mage/test/combat/FirstStrikeTest.java | 405 +++++++++++++++++- Mage/src/main/java/mage/game/GameImpl.java | 1 + .../java/mage/game/combat/CombatGroup.java | 82 ++-- .../watchers/common/FirstStrikeWatcher.java | 57 +++ 4 files changed, 489 insertions(+), 56 deletions(-) create mode 100644 Mage/src/main/java/mage/watchers/common/FirstStrikeWatcher.java diff --git a/Mage.Tests/src/test/java/org/mage/test/combat/FirstStrikeTest.java b/Mage.Tests/src/test/java/org/mage/test/combat/FirstStrikeTest.java index d14691f3d55..89b2d4e434b 100644 --- a/Mage.Tests/src/test/java/org/mage/test/combat/FirstStrikeTest.java +++ b/Mage.Tests/src/test/java/org/mage/test/combat/FirstStrikeTest.java @@ -1,40 +1,417 @@ package org.mage.test.combat; +import mage.abilities.keyword.DoubleStrikeAbility; +import mage.abilities.keyword.FirstStrikeAbility; import mage.constants.PhaseStep; import mage.constants.Zone; +import mage.counters.CounterType; import org.junit.Test; import org.mage.test.serverside.base.CardTestPlayerBase; public class FirstStrikeTest extends CardTestPlayerBase { + /* + 702.7b. If at least one attacking or blocking creature has first strike or double strike (see rule 702.4) + as the combat damage step begins, the only creatures that assign combat damage in that step + are those with first strike or double strike. After that step, instead of proceeding to the end of combat step, + the phase gets a second combat damage step. The only creatures that assign combat damage in that step + are the remaining attackers and blockers that had neither first strike nor double strike + as the first combat damage step began, as well as the remaining attackers and blockers + that currently have double strike. After that step, the phase proceeds to the end of combat step. + + 702.7c. Giving first strike to a creature without it after combat damage has already been dealt + in the first combat damage step won't preclude that creature from assigning combat damage + in the second combat damage step. Removing first strike from a creature after it has already + dealt combat damage in the first combat damage step won't allow it to also assign combat damage + in the second combat damage step (unless the creature has double strike). + + 702.4c. Removing double strike from a creature during the first combat damage step will stop it from + assigning combat damage in the second combat damage step. + + 702.4d. Giving double strike to a creature with first strike after it has already dealt combat damage + in the first combat damage step will allow the creature to assign combat damage in the second combat damage step. + */ + + private static final String knight = "White Knight"; // 2/2 first strike, protection from black + private static final String bears = "Grizzly Bears"; // 2/2 + private static final String piker = "Goblin Piker"; // 2/1 + private static final String ghoul = "Warpath Ghoul"; // 3/2 + private static final String centaur = "Centaur Courser"; // 3/3 + private static final String sentry = "Dragon's Eye Sentry"; // 1/3, defender, first strike + private static final String bodyguard = "Anaba Bodyguard"; // 2/3 first strike + private static final String blademaster = "Markov Blademaster"; // 1/1 double strike, +1/+1 counter when deals damage + private static final String wolverine = "Spelleater Wolverine"; // 3/2, double strike as long as 3+ instants/sorceries in your graveyard + private static final String fury = "Kindled Fury"; // R: target gets +1/+0 and first strike + private static final String runemark = "Mardu Runemark"; // 2R Aura + // Enchanted creature gets +2/+2. + // Enchanted creature has first strike as long as you control a white or black permanent. + private static final String urchin = "Bile Urchin"; // 1/1 + // Sacrifice Bile Urchin: Target player loses 1 life + private static final String cleave = "Double Cleave"; // 1{R/W}: target gains double strike @Test - public void firstStrikeAttacker(){ - addCard(Zone.BATTLEFIELD, playerA, "Silver Knight", 1); - addCard(Zone.BATTLEFIELD, playerB, "Grizzly Bears", 1); + public void firstStrikeAttacker() { + addCard(Zone.BATTLEFIELD, playerA, knight, 1); + addCard(Zone.BATTLEFIELD, playerB, bears, 1); - attack(1, playerA, "Silver Knight"); - block(1, playerB, "Grizzly Bears", "Silver Knight"); + attack(1, playerA, knight, playerB); + block(1, playerB, bears, knight); + setStrictChooseMode(true); setStopAt(1, PhaseStep.POSTCOMBAT_MAIN); execute(); - assertGraveyardCount(playerB, "Grizzly Bears", 1); - assertGraveyardCount(playerA, "Silver Knight", 0); + assertGraveyardCount(playerB, bears, 1); + assertGraveyardCount(playerA, knight, 0); } @Test - public void firstStrikeBlocker(){ - addCard(Zone.BATTLEFIELD, playerB, "Silver Knight", 1); - addCard(Zone.BATTLEFIELD, playerA, "Grizzly Bears", 1); + public void firstStrikeBlocker() { + addCard(Zone.BATTLEFIELD, playerB, knight, 1); + addCard(Zone.BATTLEFIELD, playerA, bears, 1); - attack(1, playerA, "Grizzly Bears"); - block(1, playerB, "Silver Knight", "Grizzly Bears"); + attack(1, playerA, bears, playerB); + block(1, playerB, knight, bears); + setStrictChooseMode(true); setStopAt(1, PhaseStep.POSTCOMBAT_MAIN); execute(); - assertGraveyardCount(playerA, "Grizzly Bears", 1); - assertGraveyardCount(playerB, "Silver Knight", 0); + assertGraveyardCount(playerA, bears, 1); + assertGraveyardCount(playerB, knight, 0); } + + @Test + public void firstStrikeBoth() { + addCard(Zone.BATTLEFIELD, playerA, bodyguard); + addCard(Zone.BATTLEFIELD, playerB, sentry); + + attack(1, playerA, bodyguard, playerB); + block(1, playerB, sentry, bodyguard); + + checkDamage("after first strike", 1, PhaseStep.FIRST_COMBAT_DAMAGE, playerA, bodyguard, 1); + checkDamage("after first strike", 1, PhaseStep.FIRST_COMBAT_DAMAGE, playerB, sentry, 2); + + setStrictChooseMode(true); + setStopAt(1, PhaseStep.POSTCOMBAT_MAIN); + execute(); + + assertPermanentCount(playerA, bodyguard, 1); + assertPermanentCount(playerB, sentry, 1); + } + + @Test + public void doubleStrikePump() { + addCard(Zone.BATTLEFIELD, playerA, blademaster); // 1/1 + // Double strike; Whenever Markov Blademaster deals combat damage to a player, put a +1/+1 counter on it. + + attack(1, playerA, blademaster, playerB); + + checkLife("after first strike damage", 1, PhaseStep.FIRST_COMBAT_DAMAGE, playerB, 19); + + setStrictChooseMode(true); + setStopAt(1, PhaseStep.POSTCOMBAT_MAIN); + execute(); + + assertLife(playerB, 17); + assertPowerToughness(playerA, blademaster, 3, 3); + } + + @Test + public void firstStrikeGainedAttacker() { + addCard(Zone.BATTLEFIELD, playerA, piker, 1); + addCard(Zone.BATTLEFIELD, playerB, bears, 1); + addCard(Zone.BATTLEFIELD, playerA, "Mountain"); + addCard(Zone.HAND, playerA, fury); + + attack(1, playerA, piker, playerB); + block(1, playerB, bears, piker); + + castSpell(1, PhaseStep.DECLARE_BLOCKERS, playerA, fury, piker); + + setStrictChooseMode(true); + setStopAt(1, PhaseStep.POSTCOMBAT_MAIN); + execute(); + + assertGraveyardCount(playerB, bears, 1); + assertGraveyardCount(playerA, piker, 0); + assertAbility(playerA, piker, FirstStrikeAbility.getInstance(), true); + } + + @Test + public void firstStrikeGainedBlocker() { + addCard(Zone.BATTLEFIELD, playerB, piker, 1); + addCard(Zone.BATTLEFIELD, playerA, bears, 1); + addCard(Zone.BATTLEFIELD, playerB, "Mountain"); + addCard(Zone.HAND, playerB, fury); + + attack(1, playerA, bears, playerB); + block(1, playerB, piker, bears); + + castSpell(1, PhaseStep.DECLARE_BLOCKERS, playerB, fury, piker); + + setStrictChooseMode(true); + setStopAt(1, PhaseStep.POSTCOMBAT_MAIN); + execute(); + + assertGraveyardCount(playerA, bears, 1); + assertGraveyardCount(playerB, piker, 0); + assertAbility(playerB, piker, FirstStrikeAbility.getInstance(), true); + } + + @Test + public void firstStrikeGainedBothDie() { + addCard(Zone.BATTLEFIELD, playerA, knight, 1); + addCard(Zone.BATTLEFIELD, playerB, bears, 1); + addCard(Zone.BATTLEFIELD, playerB, "Mountain"); + addCard(Zone.HAND, playerB, fury); + + attack(1, playerA, knight, playerB); + block(1, playerB, bears, knight); + + castSpell(1, PhaseStep.DECLARE_BLOCKERS, playerB, fury, bears); + + setStrictChooseMode(true); + setStopAt(1, PhaseStep.POSTCOMBAT_MAIN); + execute(); + + assertGraveyardCount(playerB, bears, 1); + assertGraveyardCount(playerA, knight, 1); + } + + @Test + public void firstStrikeGainedMidCombat() { + addCard(Zone.BATTLEFIELD, playerA, knight, 1); + addCard(Zone.BATTLEFIELD, playerA, ghoul, 1); + addCard(Zone.BATTLEFIELD, playerA, "Mountain"); + addCard(Zone.HAND, playerA, fury); + + attack(1, playerA, knight, playerB); + attack(1, playerA, ghoul, playerB); + + checkLife("after first strike", 1, PhaseStep.FIRST_COMBAT_DAMAGE, playerB, 18); + castSpell(1, PhaseStep.FIRST_COMBAT_DAMAGE, playerA, fury, ghoul); + + setStrictChooseMode(true); + setStopAt(1, PhaseStep.POSTCOMBAT_MAIN); + execute(); + + assertLife(playerB, 14); + } + + @Test + public void firstStrikeLostMidCombat() { + addCard(Zone.BATTLEFIELD, playerA, centaur, 1); + addCard(Zone.BATTLEFIELD, playerA, urchin, 1); + addCard(Zone.BATTLEFIELD, playerA, "Mountain", 3); + addCard(Zone.HAND, playerA, runemark); + + castSpell(1, PhaseStep.PRECOMBAT_MAIN, playerA, runemark, centaur); + + checkAbility("first strike gained", 1, PhaseStep.BEGIN_COMBAT, playerA, centaur, FirstStrikeAbility.class, true); + + attack(1, playerA, centaur, playerB); + + checkLife("after first strike", 1, PhaseStep.FIRST_COMBAT_DAMAGE, playerB, 15); + activateAbility(1, PhaseStep.FIRST_COMBAT_DAMAGE, playerA, "Sacrifice "); + addTarget(playerA, playerA); + + setStrictChooseMode(true); + setStopAt(1, PhaseStep.POSTCOMBAT_MAIN); + execute(); + + assertLife(playerA, 19); + assertLife(playerB, 15); + assertAbility(playerA, centaur, FirstStrikeAbility.getInstance(), false); + } + + @Test + public void doubleStrikeGainedMidCombat() { + addCard(Zone.BATTLEFIELD, playerA, knight, 1); + addCard(Zone.BATTLEFIELD, playerA, "Mountain", 2); + addCard(Zone.HAND, playerA, cleave); + + attack(1, playerA, knight, playerB); + + checkLife("after first strike", 1, PhaseStep.FIRST_COMBAT_DAMAGE, playerB, 18); + castSpell(1, PhaseStep.FIRST_COMBAT_DAMAGE, playerA, cleave, knight); + + setStrictChooseMode(true); + setStopAt(1, PhaseStep.POSTCOMBAT_MAIN); + execute(); + + assertLife(playerB, 16); + assertAbility(playerA, knight, DoubleStrikeAbility.getInstance(), true); + } + + @Test + public void doubleStrikeLostMidCombat() { + addCard(Zone.BATTLEFIELD, playerA, wolverine, 1); + addCard(Zone.GRAVEYARD, playerA, "Lightning Bolt"); + addCard(Zone.GRAVEYARD, playerA, "Divination"); + addCard(Zone.GRAVEYARD, playerA, "Prey Upon"); + addCard(Zone.BATTLEFIELD, playerA, "Heap Doll"); // sac to exile target from graveyard + + checkAbility("has double strike", 1, PhaseStep.BEGIN_COMBAT, playerA, wolverine, DoubleStrikeAbility.class, true); + + attack(1, playerA, wolverine, playerB); + + checkLife("after first strike", 1, PhaseStep.FIRST_COMBAT_DAMAGE, playerB, 17); + activateAbility(1, PhaseStep.FIRST_COMBAT_DAMAGE, playerA, "Sacrifice "); + addTarget(playerA, "Divination"); + + setStrictChooseMode(true); + setStopAt(1, PhaseStep.POSTCOMBAT_MAIN); + execute(); + + assertLife(playerB, 17); + assertAbility(playerA, wolverine, DoubleStrikeAbility.getInstance(), false); + } + + @Test + public void firstStrikeLostDoubleStrikeGained() { + addCard(Zone.BATTLEFIELD, playerA, centaur, 1); + addCard(Zone.BATTLEFIELD, playerA, urchin, 1); + addCard(Zone.BATTLEFIELD, playerA, "Mountain", 5); + addCard(Zone.HAND, playerA, runemark); + addCard(Zone.HAND, playerA, cleave); + + castSpell(1, PhaseStep.PRECOMBAT_MAIN, playerA, runemark, centaur); + + checkAbility("first strike gained", 1, PhaseStep.BEGIN_COMBAT, playerA, centaur, FirstStrikeAbility.class, true); + checkAbility("no double strike", 1, PhaseStep.BEGIN_COMBAT, playerA, centaur, DoubleStrikeAbility.class, false); + + attack(1, playerA, centaur, playerB); + + checkLife("after first strike", 1, PhaseStep.FIRST_COMBAT_DAMAGE, playerB, 15); + activateAbility(1, PhaseStep.FIRST_COMBAT_DAMAGE, playerA, "Sacrifice "); + addTarget(playerA, playerA); + waitStackResolved(1, PhaseStep.FIRST_COMBAT_DAMAGE); + castSpell(1, PhaseStep.FIRST_COMBAT_DAMAGE, playerA, cleave, centaur); + + setStrictChooseMode(true); + setStopAt(1, PhaseStep.POSTCOMBAT_MAIN); + execute(); + + assertLife(playerA, 19); + assertLife(playerB, 10); + assertAbility(playerA, centaur, FirstStrikeAbility.getInstance(), false); + assertAbility(playerA, centaur, DoubleStrikeAbility.getInstance(), true); + } + + @Test + public void damageDealtInBetween() { + // To check that the first strike damage watcher doesn't count noncombat damage + + String fall = "Fall of the Hammer"; // 1R Instant + // Target creature you control deals damage equal to its power to another target creature. + String hatchling = "Kraken Hatchling"; // 0/4 + + addCard(Zone.BATTLEFIELD, playerA, knight, 1); + addCard(Zone.BATTLEFIELD, playerA, ghoul, 1); + addCard(Zone.BATTLEFIELD, playerB, hatchling); + addCard(Zone.BATTLEFIELD, playerA, "Mountain", 2); + addCard(Zone.HAND, playerA, fall); + + attack(1, playerA, knight, playerB); + attack(1, playerA, ghoul, playerB); + + checkLife("after first strike", 1, PhaseStep.FIRST_COMBAT_DAMAGE, playerB, 18); + castSpell(1, PhaseStep.FIRST_COMBAT_DAMAGE, playerA, fall); + addTarget(playerA, ghoul); + addTarget(playerA, hatchling); + + setStrictChooseMode(true); + setStopAt(1, PhaseStep.POSTCOMBAT_MAIN); + execute(); + + assertLife(playerB, 15); + assertDamageReceived(playerB, hatchling, 3); + } + + @Test + public void firstStrikeDamagePrevented() { + String prevention = "Dazzling Reflection"; // 1W Instant + // You gain life equal to target creature’s power. The next time that creature would deal damage this turn, prevent that damage. + + addCard(Zone.BATTLEFIELD, playerA, knight, 1); + addCard(Zone.BATTLEFIELD, playerA, ghoul, 1); + addCard(Zone.BATTLEFIELD, playerA, "Plains", 2); + addCard(Zone.HAND, playerA, prevention); + + attack(1, playerA, knight, playerB); + attack(1, playerA, ghoul, playerB); + + castSpell(1, PhaseStep.DECLARE_BLOCKERS, playerA, prevention, knight); + + checkLife("life gained", 1, PhaseStep.FIRST_COMBAT_DAMAGE, playerA, 22); + checkLife("damage prevented", 1, PhaseStep.FIRST_COMBAT_DAMAGE, playerB, 20); + + setStrictChooseMode(true); + setStopAt(1, PhaseStep.POSTCOMBAT_MAIN); + execute(); + + assertLife(playerB, 17); + } + + @Test + public void firstStrikeZeroDamage() { + String rograkh = "Rograkh, Son of Rohgahh"; // 0/1 first strike + String battlegrowth = "Battlegrowth"; // G: put a +1/+1 counter on target creature + + addCard(Zone.BATTLEFIELD, playerA, rograkh, 1); + addCard(Zone.BATTLEFIELD, playerA, "Forest"); + addCard(Zone.HAND, playerA, battlegrowth); + + attack(1, playerA, rograkh, playerB); + + castSpell(1, PhaseStep.FIRST_COMBAT_DAMAGE, playerA, battlegrowth, rograkh); + // no combat damage should be dealt here + + setStrictChooseMode(true); + setStopAt(1, PhaseStep.POSTCOMBAT_MAIN); + execute(); + + assertLife(playerB, 20); + assertPowerToughness(playerA, rograkh, 1, 2); + } + + @Test + public void ninjutsuTwice() { + /* If a creature in combat has first strike or double strike, + * you can activate the ninjutsu ability during the first-strike combat damage step. + * The Ninja will deal combat damage during the regular combat damage step, even if it has first strike. + */ + + String moonblade = "Moonblade Shinobi"; // 3/2, Ninjutsu 2U + // Whenever Moonblade Shinobi deals combat damage to a player, create a 1/1 blue Illusion creature token with flying. + String ambusher = "Mukotai Ambusher"; // 3/2 Lifelink; Ninjutsu 1B + + addCard(Zone.BATTLEFIELD, playerA, moonblade, 1); + addCard(Zone.BATTLEFIELD, playerA, "Underground Sea", 5); + addCard(Zone.HAND, playerA, ambusher); + addCard(Zone.BATTLEFIELD, playerA, "Celebrity Fencer"); + // Whenever another creature enters the battlefield under your control, put a +1/+1 counter on Celebrity Fencer. + addCard(Zone.BATTLEFIELD, playerA, "Knighthood"); // Creatures you control have first strike + + attack(1, playerA, moonblade, playerB); + + checkLife("first strike damage dealt", 1, PhaseStep.FIRST_COMBAT_DAMAGE, playerB, 17); + activateAbility(1, PhaseStep.FIRST_COMBAT_DAMAGE, playerA, "Ninjutsu {1}{B}"); + setChoice(playerA, moonblade); + waitStackResolved(1, PhaseStep.FIRST_COMBAT_DAMAGE); + activateAbility(1, PhaseStep.FIRST_COMBAT_DAMAGE, playerA, "Ninjutsu {2}{U}"); + setChoice(playerA, ambusher); + + setStrictChooseMode(true); + setStopAt(1, PhaseStep.POSTCOMBAT_MAIN); + execute(); + + // the moonblade deals 3 damage each step because it is a different object after zone change + assertLife(playerA, 20); + assertLife(playerB, 14); + assertPermanentCount(playerA, "Illusion Token", 2); + assertCounterCount(playerA, "Celebrity Fencer", CounterType.P1P1, 4); // two tokens and both ninjutsu + } + } diff --git a/Mage/src/main/java/mage/game/GameImpl.java b/Mage/src/main/java/mage/game/GameImpl.java index 9b548e4098c..80723d76af6 100644 --- a/Mage/src/main/java/mage/game/GameImpl.java +++ b/Mage/src/main/java/mage/game/GameImpl.java @@ -1386,6 +1386,7 @@ public abstract class GameImpl implements Game { List newWatchers = new ArrayList<>(); newWatchers.add(new CastSpellLastTurnWatcher()); newWatchers.add(new PlayerLostLifeWatcher()); + newWatchers.add(new FirstStrikeWatcher()); // required for combat code newWatchers.add(new BlockedAttackerWatcher()); newWatchers.add(new PlanarRollWatcher()); // needed for RollDiceTest (planechase code needs improves) newWatchers.add(new AttackedThisTurnWatcher()); diff --git a/Mage/src/main/java/mage/game/combat/CombatGroup.java b/Mage/src/main/java/mage/game/combat/CombatGroup.java index ddd9b8378f8..32e09122066 100644 --- a/Mage/src/main/java/mage/game/combat/CombatGroup.java +++ b/Mage/src/main/java/mage/game/combat/CombatGroup.java @@ -15,6 +15,7 @@ import mage.game.events.GameEvent; import mage.game.permanent.Permanent; import mage.players.Player; import mage.util.Copyable; +import mage.watchers.common.FirstStrikeWatcher; import java.io.Serializable; import java.util.*; @@ -60,10 +61,9 @@ public class CombatGroup implements Serializable, Copyable { public boolean hasFirstOrDoubleStrike(Game game) { return Stream.concat(attackers.stream(), blockers.stream()) - .map(id -> game.getPermanent(id)) + .map(game::getPermanent) .filter(Objects::nonNull) - .anyMatch(this::hasFirstOrDoubleStrike); - + .anyMatch(CombatGroup::hasFirstOrDoubleStrike); } /** @@ -89,27 +89,27 @@ public class CombatGroup implements Serializable, Copyable { return blockerOrder; } - private boolean hasFirstOrDoubleStrike(Permanent perm) { - return perm.getAbilities().containsKey(FirstStrikeAbility.getInstance().getId()) || perm.getAbilities().containsKey(DoubleStrikeAbility.getInstance().getId()); + private static boolean hasFirstOrDoubleStrike(Permanent perm) { + return hasFirstStrike(perm) || hasDoubleStrike(perm); } - private boolean hasFirstStrike(Permanent perm) { + private static boolean hasFirstStrike(Permanent perm) { return perm.getAbilities().containsKey(FirstStrikeAbility.getInstance().getId()); } - private boolean hasDoubleStrike(Permanent perm) { + private static boolean hasDoubleStrike(Permanent perm) { return perm.getAbilities().containsKey(DoubleStrikeAbility.getInstance().getId()); } - private boolean hasTrample(Permanent perm) { + private static boolean hasTrample(Permanent perm) { return perm.getAbilities().containsKey(TrampleAbility.getInstance().getId()); } - private boolean hasTrampleOverPlaneswalkers(Permanent perm) { + private static boolean hasTrampleOverPlaneswalkers(Permanent perm) { return perm.getAbilities().containsKey(TrampleOverPlaneswalkersAbility.getInstance().getId()); } - private boolean hasBanding(Permanent perm) { + private static boolean hasBanding(Permanent perm) { return perm.getAbilities().containsKey(BandingAbility.getInstance().getId()); } @@ -229,38 +229,33 @@ public class CombatGroup implements Serializable, Copyable { } /** - * Determines if permanent can damage in current (First Strike or not) - * combat damage step + * Determines if permanent is to deal damage this step based on whether it has first/double strike + * and whether it did during the first combat damage step of this phase. + * Info is stored in FirstStrikeWatcher. * * @param perm Permanent to check - * @param first First strike or common combat damage step - * @return + * @param first true for first strike damage step, false for normal damage step + * @return true if permanent should deal damage this step */ - private boolean canDamage(Permanent perm, boolean first) { + private boolean dealsDamageThisStep(Permanent perm, boolean first, Game game) { if (perm == null) { return false; } - // if now first strike combat damage step if (first) { - // should have first strike or double strike - return hasFirstOrDoubleStrike(perm); - } // if now not first strike combat - else { - if (hasFirstStrike(perm)) { - // if it has first strike in non FS combat damage step - // then it can damage only if it has ALSO double strike - // Fixes Issue 200 - return hasDoubleStrike(perm); + if (hasFirstOrDoubleStrike(perm)) { + FirstStrikeWatcher.recordFirstStrikingCreature(perm.getId(), game); + return true; } - // can damage otherwise - return true; + return false; + } else { // 702.7c + return hasDoubleStrike(perm) || !FirstStrikeWatcher.wasFirstStrikingCreature(perm.getId(), game); } } private void unblockedDamage(boolean first, Game game) { for (UUID attackerId : attackers) { Permanent attacker = game.getPermanent(attackerId); - if (canDamage(attacker, first)) { + if (dealsDamageThisStep(attacker, first, game)) { //20091005 - 510.1c, 702.17c if (!blocked || hasTrample(attacker)) { defenderDamage(attacker, getDamageValueFromPermanent(attacker, game), game, false); @@ -274,7 +269,7 @@ public class CombatGroup implements Serializable, Copyable { Permanent attacker = game.getPermanent(attackers.get(0)); if (blocker != null && attacker != null) { int blockerDamage = getDamageValueFromPermanent(blocker, game); // must be set before attacker damage marking because of effects like Test of Faith - if (blocked && canDamage(attacker, first)) { + if (blocked && dealsDamageThisStep(attacker, first, game)) { int damage = getDamageValueFromPermanent(attacker, game); if (hasTrample(attacker)) { int lethalDamage = getLethalDamage(blocker, attacker, game); @@ -292,7 +287,7 @@ public class CombatGroup implements Serializable, Copyable { blocker.markDamage(damage, attacker.getId(), null, game, true, true); } } - if (canDamage(blocker, first)) { + if (dealsDamageThisStep(blocker, first, game)) { if (checkSoleBlockerAfter(blocker, game)) { // blocking several creatures handled separately if (!assignsDefendingPlayerAndOrDefendingCreaturesDividedDamage(blocker, blocker.getControllerId(), first, game, false)) { attacker.markDamage(blockerDamage, blocker.getId(), null, game, true, true); @@ -309,12 +304,12 @@ public class CombatGroup implements Serializable, Copyable { } boolean oldRuleDamage = (Objects.equals(player.getId(), defendingPlayerId)); int damage = getDamageValueFromPermanent(attacker, game); - if (canDamage(attacker, first)) { + if (dealsDamageThisStep(attacker, first, game)) { // must be set before attacker damage marking because of effects like Test of Faith Map blockerPower = new HashMap<>(); for (UUID blockerId : blockerOrder) { Permanent blocker = game.getPermanent(blockerId); - if (canDamage(blocker, first)) { + if (dealsDamageThisStep(blocker, first, game)) { if (checkSoleBlockerAfter(blocker, game)) { // blocking several creatures handled separately blockerPower.put(blockerId, getDamageValueFromPermanent(blocker, game)); } @@ -375,7 +370,7 @@ public class CombatGroup implements Serializable, Copyable { } else { for (UUID blockerId : blockerOrder) { Permanent blocker = game.getPermanent(blockerId); - if (canDamage(blocker, first)) { + if (dealsDamageThisStep(blocker, first, game)) { if (!assignsDefendingPlayerAndOrDefendingCreaturesDividedDamage(blocker, blocker.getControllerId(), first, game, false)) { attacker.markDamage(getDamageValueFromPermanent(blocker, game), blocker.getId(), null, game, true, true); } @@ -391,12 +386,12 @@ public class CombatGroup implements Serializable, Copyable { return; } int damage = getDamageValueFromPermanent(attacker, game); - if (canDamage(attacker, first)) { + if (dealsDamageThisStep(attacker, first, game)) { // must be set before attacker damage marking because of effects like Test of Faith Map blockerPower = new HashMap<>(); for (UUID blockerId : blockerOrder) { Permanent blocker = game.getPermanent(blockerId); - if (canDamage(blocker, first)) { + if (dealsDamageThisStep(blocker, first, game)) { if (checkSoleBlockerAfter(blocker, game)) { // blocking several creatures handled separately blockerPower.put(blockerId, getDamageValueFromPermanent(blocker, game)); } @@ -440,7 +435,7 @@ public class CombatGroup implements Serializable, Copyable { if (isAttacking) { for (UUID blockerId : blockerOrder) { Permanent blocker = game.getPermanent(blockerId); - if (canDamage(blocker, first)) { + if (dealsDamageThisStep(blocker, first, game)) { if (!assignsDefendingPlayerAndOrDefendingCreaturesDividedDamage(blocker, blocker.getControllerId(), first, game, false)) { attacker.markDamage(getDamageValueFromPermanent(blocker, game), blocker.getId(), null, game, true, true); } @@ -488,7 +483,7 @@ public class CombatGroup implements Serializable, Copyable { Permanent blocker = game.getPermanent(blockers.get(0)); Permanent attacker = game.getPermanent(attackers.get(0)); if (blocker != null && attacker != null) { - if (canDamage(blocker, first)) { + if (dealsDamageThisStep(blocker, first, game)) { int damage = getDamageValueFromPermanent(blocker, game); attacker.markDamage(damage, blocker.getId(), null, game, true, true); } @@ -514,7 +509,7 @@ public class CombatGroup implements Serializable, Copyable { Player player = game.getPlayer(oldRuleDamage ? game.getCombat().getAttackingPlayerId() : blocker.getControllerId()); int damage = getDamageValueFromPermanent(blocker, game); - if (canDamage(blocker, first)) { + if (dealsDamageThisStep(blocker, first, game)) { Map assigned = new HashMap<>(); for (UUID attackerId : attackerOrder) { Permanent attacker = game.getPermanent(attackerId); @@ -881,7 +876,10 @@ public class CombatGroup implements Serializable, Copyable { } for (UUID attackerId : attackers) { // changing defender will remove a banded attacker from its current band Permanent attacker = game.getPermanent(attackerId); - if (attacker != null && attacker.getBandedCards() != null) { + if (attacker == null) { + continue; + } + if (attacker.getBandedCards() != null) { for (UUID bandedId : attacker.getBandedCards()) { Permanent banded = game.getPermanent(bandedId); if (banded != null) { @@ -958,7 +956,7 @@ public class CombatGroup implements Serializable, Copyable { Player player = game.getPlayer(defenderAssignsCombatDamage(game) ? defendingPlayerId : (!isAttacking && attackerAssignsCombatDamage(game) ? game.getCombat().getAttackingPlayerId() : playerId)); // 10/4/2004 If it is blocked but then all of its blockers are removed before combat damage is assigned, then it won't be able to deal combat damage and you won't be able to use its ability. // (same principle should apply if it's blocking and its blocked attacker is removed from combat) - if (!((blocked && blockers.isEmpty() && isAttacking) || (attackers.isEmpty() && !isAttacking)) && canDamage(creature, first)) { + if (!((blocked && blockers.isEmpty() && isAttacking) || (attackers.isEmpty() && !isAttacking)) && dealsDamageThisStep(creature, first, game)) { if (player.chooseUse(Outcome.Damage, "Have " + creature.getLogName() + " assign its combat damage divided among defending player and/or any number of defending creatures?", null, game)) { defendingPlayerAndOrDefendingCreaturesDividedDamage(creature, player, first, game, isAttacking); return true; @@ -968,7 +966,7 @@ public class CombatGroup implements Serializable, Copyable { return false; } - private static int getLethalDamage(Permanent blocker, Permanent attacker, Game game) { - return blocker.getLethalDamage(attacker.getId(), game); + private static int getLethalDamage(Permanent damaged, Permanent damaging, Game game) { + return damaged.getLethalDamage(damaging.getId(), game); } } diff --git a/Mage/src/main/java/mage/watchers/common/FirstStrikeWatcher.java b/Mage/src/main/java/mage/watchers/common/FirstStrikeWatcher.java new file mode 100644 index 00000000000..eb124cd030a --- /dev/null +++ b/Mage/src/main/java/mage/watchers/common/FirstStrikeWatcher.java @@ -0,0 +1,57 @@ +package mage.watchers.common; + +import mage.MageObjectReference; +import mage.constants.WatcherScope; +import mage.game.Game; +import mage.game.events.GameEvent; +import mage.watchers.Watcher; + +import java.util.HashSet; +import java.util.Set; +import java.util.UUID; + +/** + * @author xenohedron + */ +public class FirstStrikeWatcher extends Watcher { + + // creatures that had first strike or double strike for the first strike combat damage step of this combat phase + // (note, due to 0 power or prevention, they may not necessarily have dealt damage) + private final Set firstStrikingCreatures; + + /** + * Game default watcher, required for combat code + */ + public FirstStrikeWatcher() { + super(WatcherScope.GAME); + this.firstStrikingCreatures = new HashSet<>(); + } + + @Override + public void watch(GameEvent event, Game game) { + if (event.getType() == GameEvent.EventType.COMBAT_PHASE_POST) { + firstStrikingCreatures.clear(); + } + } + + @Override + public void reset() { + super.reset(); + firstStrikingCreatures.clear(); + } + + public static void recordFirstStrikingCreature(UUID creatureId, Game game) { + game.getState() + .getWatcher(FirstStrikeWatcher.class) + .firstStrikingCreatures + .add(new MageObjectReference(creatureId, game)); + } + + public static boolean wasFirstStrikingCreature(UUID creatureId, Game game) { + return game.getState() + .getWatcher(FirstStrikeWatcher.class) + .firstStrikingCreatures + .contains(new MageObjectReference(creatureId, game)); + } + +}