From 91312228d712662eaf76152cba29eba9e5ba4bd7 Mon Sep 17 00:00:00 2001 From: Matthew Wilson Date: Sat, 27 Jan 2024 02:47:43 +0200 Subject: [PATCH] [MKM] Implement A Killer Among Us (#11704) * [MKM] Implement A Killer Among Us * Address PR comments * Address PR comments --------- Co-authored-by: Matthew Wilson --- .../src/mage/cards/a/AKillerAmongUs.java | 250 ++++++++++++++++++ .../src/mage/sets/MurdersAtKarlovManor.java | 1 + .../cards/single/mkm/AKillerAmongUsTest.java | 119 +++++++++ 3 files changed, 370 insertions(+) create mode 100644 Mage.Sets/src/mage/cards/a/AKillerAmongUs.java create mode 100644 Mage.Tests/src/test/java/org/mage/test/cards/single/mkm/AKillerAmongUsTest.java diff --git a/Mage.Sets/src/mage/cards/a/AKillerAmongUs.java b/Mage.Sets/src/mage/cards/a/AKillerAmongUs.java new file mode 100644 index 00000000000..9b468396bfd --- /dev/null +++ b/Mage.Sets/src/mage/cards/a/AKillerAmongUs.java @@ -0,0 +1,250 @@ +package mage.cards.a; + +import java.util.LinkedHashSet; +import java.util.Set; +import java.util.UUID; + +import mage.MageObject; +import mage.abilities.Ability; +import mage.abilities.common.EntersBattlefieldTriggeredAbility; +import mage.abilities.common.SimpleActivatedAbility; +import mage.abilities.costs.Cost; +import mage.abilities.costs.CostImpl; +import mage.abilities.costs.common.SacrificeSourceCost; +import mage.abilities.effects.OneShotEffect; +import mage.abilities.effects.common.CreateTokenEffect; +import mage.abilities.effects.common.continuous.GainAbilityTargetEffect; +import mage.abilities.keyword.DeathtouchAbility; +import mage.cards.CardImpl; +import mage.cards.CardSetInfo; +import mage.choices.Choice; +import mage.choices.ChoiceImpl; +import mage.constants.CardType; +import mage.constants.Duration; +import mage.constants.Outcome; +import mage.constants.SubType; +import mage.counters.CounterType; +import mage.filter.common.FilterAttackingCreature; +import mage.filter.predicate.permanent.TokenPredicate; +import mage.game.Game; +import mage.game.permanent.Permanent; +import mage.game.permanent.token.GoblinToken; +import mage.game.permanent.token.HumanToken; +import mage.game.permanent.token.MerfolkToken; +import mage.players.Player; +import mage.target.TargetPermanent; + +/** + * A Killer Among Us {4}{G} + * Enchantment + * When A Killer Among Us enters the battlefield, create a 1/1 white Human creature token, a 1/1 blue Merfolk creature token, and a 1/1 red Goblin creature token. Then secretly choose Human, Merfolk, or Goblin. + * Sacrifice A Killer Among Us, Reveal the chosen creature type: If target attacking creature token is the chosen type, put three +1/+1 counters on it and it gains deathtouch until end of turn. + * ┌─────┐ + * ┌─┤ ┌──┴┐ + * │ │ └──┬┘ + * └─┤ ┌─┐ │ + * └─┘ └─┘ + * + * @author DominionSpy + */ +public final class AKillerAmongUs extends CardImpl { + + private static final FilterAttackingCreature filter = new FilterAttackingCreature("attacking creature token"); + + static { + filter.add(TokenPredicate.TRUE); + } + + public AKillerAmongUs(UUID ownerId, CardSetInfo setInfo) { + super(ownerId, setInfo, new CardType[]{CardType.ENCHANTMENT}, "{4}{G}"); + + // When A Killer Among Us enters the battlefield, create a 1/1 white Human creature token, a 1/1 blue Merfolk creature token, and a 1/1 red Goblin creature token. Then secretly choose Human, Merfolk, or Goblin. + Ability ability = new EntersBattlefieldTriggeredAbility(new CreateTokenEffect(new HumanToken())); + ability.addEffect(new CreateTokenEffect(new MerfolkToken()) + .setText(", a 1/1 blue Merfolk creature token")); + ability.addEffect(new CreateTokenEffect(new GoblinToken()) + .setText(", and a 1/1 red Goblin creature token.")); + ability.addEffect(new ChooseHumanMerfolkOrGoblinEffect()); + this.addAbility(ability); + + // Sacrifice A Killer Among Us, Reveal the chosen creature type: If target attacking creature token is the chosen type, put three +1/+1 counters on it and it gains deathtouch until end of turn. + ability = new SimpleActivatedAbility(new AKillerAmongUsEffect(), new SacrificeSourceCost()); + ability.addCost(new AKillerAmongUsCost()); + ability.addTarget(new TargetPermanent(filter)); + this.addAbility(ability); + } + + private AKillerAmongUs(final AKillerAmongUs card) { + super(card); + } + + @Override + public AKillerAmongUs copy() { + return new AKillerAmongUs(this); + } +} + +class ChooseHumanMerfolkOrGoblinEffect extends OneShotEffect { + + public static final String SECRET_CREATURE_TYPE = "_susCreatureType"; + public static final String SECRET_OWNER = "_secOwn"; + + ChooseHumanMerfolkOrGoblinEffect() { + super(Outcome.Neutral); + staticText = "Then secretly choose Human, Merfolk, or Goblin."; + } + + private ChooseHumanMerfolkOrGoblinEffect(final ChooseHumanMerfolkOrGoblinEffect effect) { + super(effect); + } + + @Override + public ChooseHumanMerfolkOrGoblinEffect copy() { + return new ChooseHumanMerfolkOrGoblinEffect(this); + } + + @Override + public boolean apply(Game game, Ability source) { + Player controller = game.getPlayer(source.getControllerId()); + if (controller == null) { + return false; + } + Permanent permanent = game.getPermanentOrLKIBattlefield(source.getSourceId()); + if (permanent == null) { + return false; + } + + Choice choice = new ChoiceImpl(); + Set choices = new LinkedHashSet<>(); + choices.add("Human"); + choices.add("Merfolk"); + choices.add("Goblin"); + choice.setChoices(choices); + choice.setMessage("Choose Human, Merfolk, or Goblin"); + + controller.choose(outcome, choice, game); + game.informPlayers(permanent.getName() + ": " + controller.getLogName() + " has secretly chosen a creature type."); + + SubType chosenType = SubType.fromString(choice.getChoice()); + setSecretCreatureType(chosenType, source, game); + setSecretOwner(source.getControllerId(), source, game); + return true; + } + + public static void setSecretCreatureType(SubType type, Ability source, Game game) { + String uniqueRef = getUniqueReference(source, game); + if (uniqueRef != null) { + game.getState().setValue(uniqueRef + SECRET_CREATURE_TYPE, type); + } + } + + public static SubType getSecretCreatureType(Ability source, Game game) { + String uniqueRef = getUniqueReference(source, game); + if (uniqueRef != null) { + return (SubType) game.getState().getValue(uniqueRef + + ChooseHumanMerfolkOrGoblinEffect.SECRET_CREATURE_TYPE); + } + return null; + } + + public static void setSecretOwner(UUID owner, Ability source, Game game) { + String uniqueRef = getUniqueReference(source, game); + if (uniqueRef != null) { + game.getState().setValue(getUniqueReference(source, game) + SECRET_OWNER, owner); + } + } + + public static UUID getSecretOwner(Ability source, Game game) { + String uniqueRef = getUniqueReference(source, game); + if (uniqueRef != null) { + return (UUID) game.getState().getValue(getUniqueReference(source, game) + + ChooseHumanMerfolkOrGoblinEffect.SECRET_OWNER); + } + return null; + } + + private static String getUniqueReference(Ability source, Game game) { + if (game.getPermanentOrLKIBattlefield(source.getSourceId()) != null) { + return source.getSourceId() + "_" + (game.getPermanentOrLKIBattlefield(source.getSourceId()).getZoneChangeCounter(game)); + } + return null; + } +} + +class AKillerAmongUsEffect extends OneShotEffect { + + AKillerAmongUsEffect() { + super(Outcome.Benefit); + this.staticText = "If target attacking creature token is the chosen type, " + + "put three +1/+1 counters on it and it gains deathtouch until end of turn."; + } + + private AKillerAmongUsEffect(final AKillerAmongUsEffect effect) { + super(effect); + } + + @Override + public AKillerAmongUsEffect copy() { + return new AKillerAmongUsEffect(this); + } + + @Override + public boolean apply(Game game, Ability source) { + Permanent creature = game.getPermanent(source.getFirstTarget()); + if (creature == null) { + return false; + } + SubType creatureType = ChooseHumanMerfolkOrGoblinEffect.getSecretCreatureType(source, game); + if (creatureType != null && creature.getSubtype().contains(creatureType)) { + creature.addCounters(CounterType.P1P1.createInstance(3), source, game); + game.addEffect(new GainAbilityTargetEffect( + DeathtouchAbility.getInstance(), Duration.EndOfTurn + ), source); + } + return true; + } +} + +class AKillerAmongUsCost extends CostImpl { + + AKillerAmongUsCost() { + this.text = "Reveal the chosen creature type"; + } + + private AKillerAmongUsCost(final AKillerAmongUsCost cost) { + super(cost); + } + + @Override + public AKillerAmongUsCost copy() { + return new AKillerAmongUsCost(this); + } + + @Override + public boolean canPay(Ability ability, Ability source, UUID controllerId, Game game) { + return controllerId != null + && controllerId.equals(ChooseHumanMerfolkOrGoblinEffect.getSecretOwner(source, game)) + && ChooseHumanMerfolkOrGoblinEffect.getSecretCreatureType(source, game) != null; + } + + @Override + public boolean pay(Ability ability, Game game, Ability source, UUID controllerId, boolean noMana, Cost costToPay) { + if (controllerId == null || !controllerId.equals(ChooseHumanMerfolkOrGoblinEffect.getSecretOwner(source, game))) { + return false; + } + SubType creatureType = ChooseHumanMerfolkOrGoblinEffect.getSecretCreatureType(source, game); + if (creatureType == null) { + return paid; + } + + Player controller = game.getPlayer(controllerId); + MageObject sourceObject = game.getObject(source); + if (controller != null && sourceObject != null) { + game.informPlayers(sourceObject.getLogName() + ": " + controller.getLogName() + + " reveals the secretly chosen creature type " + creatureType); + } + + paid = true; + return paid; + } +} diff --git a/Mage.Sets/src/mage/sets/MurdersAtKarlovManor.java b/Mage.Sets/src/mage/sets/MurdersAtKarlovManor.java index 53ab1fda85a..cb28861e8fd 100644 --- a/Mage.Sets/src/mage/sets/MurdersAtKarlovManor.java +++ b/Mage.Sets/src/mage/sets/MurdersAtKarlovManor.java @@ -26,6 +26,7 @@ public final class MurdersAtKarlovManor extends ExpansionSet { this.hasBasicLands = true; this.hasBoosters = false; // temporary + cards.add(new SetCardInfo("A Killer Among Us", 167, Rarity.UNCOMMON, mage.cards.a.AKillerAmongUs.class)); cards.add(new SetCardInfo("Absolving Lammasu", 2, Rarity.UNCOMMON, mage.cards.a.AbsolvingLammasu.class)); cards.add(new SetCardInfo("Aftermath Analyst", 148, Rarity.UNCOMMON, mage.cards.a.AftermathAnalyst.class)); cards.add(new SetCardInfo("Agrus Kos, Spirit of Justice", 184, Rarity.MYTHIC, mage.cards.a.AgrusKosSpiritOfJustice.class)); diff --git a/Mage.Tests/src/test/java/org/mage/test/cards/single/mkm/AKillerAmongUsTest.java b/Mage.Tests/src/test/java/org/mage/test/cards/single/mkm/AKillerAmongUsTest.java new file mode 100644 index 00000000000..dac87f43a3f --- /dev/null +++ b/Mage.Tests/src/test/java/org/mage/test/cards/single/mkm/AKillerAmongUsTest.java @@ -0,0 +1,119 @@ +package org.mage.test.cards.single.mkm; + +import mage.abilities.keyword.DeathtouchAbility; +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 AKillerAmongUsTest extends CardTestPlayerBase { + + @Test + public void test_TargetChosenCreatureType() { + addCard(Zone.BATTLEFIELD, playerA, "Forest", 5); + addCard(Zone.HAND, playerA, "A Killer Among Us"); + + // When A Killer Among Us enters the battlefield, create a 1/1 white Human creature token, a 1/1 blue Merfolk creature token, and a 1/1 red Goblin creature token. Then secretly choose Human, Merfolk, or Goblin. + castSpell(1, PhaseStep.PRECOMBAT_MAIN, playerA, "A Killer Among Us"); + setChoice(playerA, "Merfolk"); + waitStackResolved(1, PhaseStep.PRECOMBAT_MAIN); + + checkPermanentCount("human token", 1, PhaseStep.PRECOMBAT_MAIN, playerA, "Human Token", 1); + checkPermanentCount("merfolk token", 1, PhaseStep.PRECOMBAT_MAIN, playerA, "Merfolk Token", 1); + checkPermanentCount("goblin token", 1, PhaseStep.PRECOMBAT_MAIN, playerA, "Goblin Token", 1); + + attack(3, playerA, "Merfolk Token"); + // Sacrifice A Killer Among Us, Reveal the chosen creature type: If target attacking creature token is the chosen type, put three +1/+1 counters on it and it gains deathtouch until end of turn. + activateAbility(3, PhaseStep.DECLARE_BLOCKERS, playerA, "Sacrifice", "Merfolk Token"); + + setStrictChooseMode(true); + setStopAt(3, PhaseStep.POSTCOMBAT_MAIN); + execute(); + + assertPermanentCount(playerA, "A Killer Among Us", 0); + assertCounterCount(playerA, "Merfolk Token", CounterType.P1P1, 3); + assertAbility(playerA, "Merfolk Token", DeathtouchAbility.getInstance(), true); + } + + @Test + public void test_TargetNotChosenCreatureType() { + addCard(Zone.BATTLEFIELD, playerA, "Forest", 5); + addCard(Zone.HAND, playerA, "A Killer Among Us"); + + castSpell(1, PhaseStep.PRECOMBAT_MAIN, playerA, "A Killer Among Us"); + setChoice(playerA, "Merfolk"); + waitStackResolved(1, PhaseStep.PRECOMBAT_MAIN); + + checkPermanentCount("human token", 1, PhaseStep.PRECOMBAT_MAIN, playerA, "Human Token", 1); + checkPermanentCount("merfolk token", 1, PhaseStep.PRECOMBAT_MAIN, playerA, "Merfolk Token", 1); + checkPermanentCount("goblin token", 1, PhaseStep.PRECOMBAT_MAIN, playerA, "Goblin Token", 1); + + attack(3, playerA, "Human Token"); + activateAbility(3, PhaseStep.DECLARE_BLOCKERS, playerA, "Sacrifice", "Human Token"); + + setStrictChooseMode(true); + setStopAt(3, PhaseStep.POSTCOMBAT_MAIN); + execute(); + + assertCounterCount(playerA, "Human Token", CounterType.P1P1, 0); + assertAbility(playerA, "Human Token", DeathtouchAbility.getInstance(), false); + } + + // If the A Killer Among Us ETB trigger is copied, the last chosen creature type + // will be used for the second ability + @Test + public void test_CopyTrigger_TargetLastChosenCreatureType() { + addCard(Zone.BATTLEFIELD, playerA, "Taiga", 5 + 2); + addCard(Zone.BATTLEFIELD, playerA, "Lithoform Engine"); + addCard(Zone.HAND, playerA, "A Killer Among Us"); + addCard(Zone.HAND, playerA, "Tremor"); + + castSpell(1, PhaseStep.PRECOMBAT_MAIN, playerA, "A Killer Among Us"); + waitStackResolved(1, PhaseStep.PRECOMBAT_MAIN, 1); + // Copy A Killer Among Us ETB trigger + activateAbility(1, PhaseStep.PRECOMBAT_MAIN, playerA, "{2}, {T}", "stack ability (When"); + setChoice(playerA, "Human"); + setChoice(playerA, "Merfolk"); + + attack(3, playerA, "Merfolk Token"); + activateAbility(3, PhaseStep.DECLARE_BLOCKERS, playerA, "Sacrifice", "Merfolk Token"); + + // Destroy all the other tokens + castSpell(3, PhaseStep.POSTCOMBAT_MAIN, playerA, "Tremor"); + + setStrictChooseMode(true); + setStopAt(3, PhaseStep.POSTCOMBAT_MAIN); + execute(); + + assertCounterCount(playerA, "Merfolk Token", CounterType.P1P1, 3); + assertAbility(playerA, "Merfolk Token", DeathtouchAbility.getInstance(), true); + } + + @Test + public void test_CopyTrigger_TargetNotLastChosenCreatureType() { + addCard(Zone.BATTLEFIELD, playerA, "Taiga", 5 + 2); + addCard(Zone.BATTLEFIELD, playerA, "Lithoform Engine"); + addCard(Zone.HAND, playerA, "A Killer Among Us"); + addCard(Zone.HAND, playerA, "Tremor"); + + castSpell(1, PhaseStep.PRECOMBAT_MAIN, playerA, "A Killer Among Us"); + waitStackResolved(1, PhaseStep.PRECOMBAT_MAIN, 1); + // Copy A Killer Among Us ETB trigger + activateAbility(1, PhaseStep.PRECOMBAT_MAIN, playerA, "{2}, {T}", "stack ability (When"); + setChoice(playerA, "Human"); + setChoice(playerA, "Merfolk"); + + attack(3, playerA, "Human Token"); + activateAbility(3, PhaseStep.DECLARE_BLOCKERS, playerA, "Sacrifice", "Human Token"); + + // Destroy all the tokens + castSpell(3, PhaseStep.POSTCOMBAT_MAIN, playerA, "Tremor"); + + setStrictChooseMode(true); + setStopAt(3, PhaseStep.POSTCOMBAT_MAIN); + execute(); + + assertPermanentCount(playerA, "Human Token", 0); + } +}