From a7dcd6988f2d83551e9144194e4a41e9bb26a768 Mon Sep 17 00:00:00 2001 From: Evan Kranzler Date: Sat, 31 May 2025 13:42:39 -0400 Subject: [PATCH] [FIC] Implement Locke, Treasure Hunter (#13695) --- .../src/mage/cards/l/LockeTreasureHunter.java | 205 ++++++++++++++++++ .../src/mage/sets/FinalFantasyCommander.java | 2 + .../single/fic/LockeTreasureHunterTest.java | 101 +++++++++ Mage/src/main/java/mage/MageIdentifier.java | 1 + 4 files changed, 309 insertions(+) create mode 100644 Mage.Sets/src/mage/cards/l/LockeTreasureHunter.java create mode 100644 Mage.Tests/src/test/java/org/mage/test/cards/single/fic/LockeTreasureHunterTest.java diff --git a/Mage.Sets/src/mage/cards/l/LockeTreasureHunter.java b/Mage.Sets/src/mage/cards/l/LockeTreasureHunter.java new file mode 100644 index 00000000000..844a2f5e185 --- /dev/null +++ b/Mage.Sets/src/mage/cards/l/LockeTreasureHunter.java @@ -0,0 +1,205 @@ +package mage.cards.l; + +import mage.MageIdentifier; +import mage.MageInt; +import mage.MageObject; +import mage.MageObjectReference; +import mage.abilities.Ability; +import mage.abilities.common.AttacksTriggeredAbility; +import mage.abilities.common.SimpleActivatedAbility; +import mage.abilities.common.SimpleStaticAbility; +import mage.abilities.condition.Condition; +import mage.abilities.costs.mana.GenericManaCost; +import mage.abilities.effects.OneShotEffect; +import mage.abilities.effects.common.combat.CantBeBlockedByCreaturesSourceEffect; +import mage.cards.*; +import mage.constants.*; +import mage.filter.StaticFilters; +import mage.filter.common.FilterCreaturePermanent; +import mage.filter.predicate.ObjectSourcePlayer; +import mage.filter.predicate.ObjectSourcePlayerPredicate; +import mage.game.Game; +import mage.game.events.GameEvent; +import mage.game.permanent.Permanent; +import mage.game.permanent.token.TreasureToken; +import mage.players.Player; +import mage.util.CardUtil; +import mage.watchers.Watcher; + +import java.util.*; + +/** + * @author TheElk801 + */ +public final class LockeTreasureHunter extends CardImpl { + + private static final FilterCreaturePermanent filter = new FilterCreaturePermanent("creatures with greater power"); + + static { + filter.add(LockeTreasureHunterPredicate.instance); + } + + public LockeTreasureHunter(UUID ownerId, CardSetInfo setInfo) { + super(ownerId, setInfo, new CardType[]{CardType.CREATURE}, "{1}{B}{R}"); + + this.supertype.add(SuperType.LEGENDARY); + this.subtype.add(SubType.HUMAN); + this.subtype.add(SubType.ROGUE); + this.power = new MageInt(2); + this.toughness = new MageInt(3); + + // Locke can't be blocked by creatures with greater power. + this.addAbility(new SimpleStaticAbility(new CantBeBlockedByCreaturesSourceEffect(filter, Duration.WhileOnBattlefield))); + + // Mug -- Whenever Locke attacks, each player mills a card. If a land card was milled this way, create a Treasure token. Until end of turn, you may cast a spell from among those cards. + this.addAbility(new AttacksTriggeredAbility(new LockeTreasureHunterEffect()) + .withFlavorWord("Mug") + .setIdentifier(MageIdentifier.LockeTreasureHunterWatcher), new LockeTreasureHunterWatcher()); + } + + private LockeTreasureHunter(final LockeTreasureHunter card) { + super(card); + } + + @Override + public LockeTreasureHunter copy() { + return new LockeTreasureHunter(this); + } + + public static Ability makeTestAbility() { + Ability ability = new SimpleActivatedAbility( + new LockeTreasureHunterEffect(), new GenericManaCost(0) + ).setIdentifier(MageIdentifier.LockeTreasureHunterWatcher); + ability.addWatcher(new LockeTreasureHunterWatcher()); + return ability; + } +} + +enum LockeTreasureHunterPredicate implements ObjectSourcePlayerPredicate { + instance; + + @Override + public boolean apply(ObjectSourcePlayer input, Game game) { + return Optional + .ofNullable(input.getSource().getSourcePermanentIfItStillExists(game)) + .map(MageObject::getPower) + .map(MageInt::getValue) + .map(x -> x < input.getObject().getPower().getValue()) + .orElse(false); + } +} + +class LockeTreasureHunterEffect extends OneShotEffect { + + LockeTreasureHunterEffect() { + super(Outcome.Benefit); + staticText = "each player mills a card. If a land card was milled this way, " + + "create a Treasure token. Until end of turn, you may cast a spell from among those cards"; + } + + private LockeTreasureHunterEffect(final LockeTreasureHunterEffect effect) { + super(effect); + } + + @Override + public LockeTreasureHunterEffect copy() { + return new LockeTreasureHunterEffect(this); + } + + @Override + public boolean apply(Game game, Ability source) { + Cards cards = new CardsImpl(); + for (UUID playerId : game.getState().getPlayersInRange(source.getControllerId(), game)) { + Player player = game.getPlayer(playerId); + if (player != null) { + cards.addAllCards(player.millCards(1, source, game).getCards(game)); + } + } + if (cards.isEmpty()) { + return false; + } + if (cards.count(StaticFilters.FILTER_CARD_LAND, game) > 0) { + new TreasureToken().putOntoBattlefield(1, game, source); + } + LockeTreasureHunterWatcher.saveCards(cards, game, source); + return true; + } +} + +class LockeTreasureHunterWatcher extends Watcher { + + private static class LockeTreasureHunterCondition implements Condition { + private final UUID permissionId; + + LockeTreasureHunterCondition(UUID permissionId) { + this.permissionId = permissionId; + } + + @Override + public boolean apply(Game game, Ability source) { + return game + .getState() + .getWatcher(LockeTreasureHunterWatcher.class) + .checkPermission(permissionId, source, game); + } + } + + // Maps cards to the specific instance that gave them permission to be cast + private final Map morMap = new HashMap<>(); + + // Maps permissions to the players who can use them + private static final Map playerPermissionMap = new HashMap<>(); + + // Tracks permissions which have already been used + private final Set usedSet = new HashSet<>(); + + LockeTreasureHunterWatcher() { + super(WatcherScope.GAME); + } + + @Override + public void watch(GameEvent event, Game game) { + if (event.getType() != GameEvent.EventType.SPELL_CAST + || !event.hasApprovingIdentifier(MageIdentifier.LockeTreasureHunterWatcher)) { + return; + } + Optional.ofNullable(event) + .map(GameEvent::getTargetId) + .map(game::getSpell) + .map(spell -> new MageObjectReference(spell.getMainCard(), game, -1)) + .map(mor -> morMap.getOrDefault(mor, null)) + .ifPresent(usedSet::add); + } + + @Override + public void reset() { + super.reset(); + morMap.clear(); + playerPermissionMap.clear(); + usedSet.clear(); + } + + static void saveCards(Cards cards, Game game, Ability source) { + game.getState() + .getWatcher(LockeTreasureHunterWatcher.class) + .handleSaveCards(cards, game, source); + } + + private void handleSaveCards(Cards cards, Game game, Ability source) { + UUID permissionId = UUID.randomUUID(); + playerPermissionMap.put(permissionId, source.getControllerId()); + Condition condition = new LockeTreasureHunterCondition(permissionId); + for (Card card : cards.getCards(game)) { + morMap.put(new MageObjectReference(card, game), permissionId); + CardUtil.makeCardPlayable( + game, source, card, true, Duration.EndOfTurn, + false, source.getControllerId(), condition + ); + } + } + + private boolean checkPermission(UUID permissionId, Ability source, Game game) { + return !usedSet.contains(permissionId) + && source.isControlledBy(playerPermissionMap.getOrDefault(permissionId, null)); + } +} diff --git a/Mage.Sets/src/mage/sets/FinalFantasyCommander.java b/Mage.Sets/src/mage/sets/FinalFantasyCommander.java index 5ea675ee143..b61cd291ccb 100644 --- a/Mage.Sets/src/mage/sets/FinalFantasyCommander.java +++ b/Mage.Sets/src/mage/sets/FinalFantasyCommander.java @@ -222,6 +222,8 @@ public final class FinalFantasyCommander extends ExpansionSet { cards.add(new SetCardInfo("Lethal Scheme", 277, Rarity.RARE, mage.cards.l.LethalScheme.class)); cards.add(new SetCardInfo("Lightning Greaves", 349, Rarity.UNCOMMON, mage.cards.l.LightningGreaves.class)); cards.add(new SetCardInfo("Lingering Souls", 245, Rarity.UNCOMMON, mage.cards.l.LingeringSouls.class)); + cards.add(new SetCardInfo("Locke, Treasure Hunter", 177, Rarity.RARE, mage.cards.l.LockeTreasureHunter.class, NON_FULL_USE_VARIOUS)); + cards.add(new SetCardInfo("Locke, Treasure Hunter", 87, Rarity.RARE, mage.cards.l.LockeTreasureHunter.class, NON_FULL_USE_VARIOUS)); cards.add(new SetCardInfo("Lord Jyscal Guado", 137, Rarity.RARE, mage.cards.l.LordJyscalGuado.class, NON_FULL_USE_VARIOUS)); cards.add(new SetCardInfo("Lord Jyscal Guado", 23, Rarity.RARE, mage.cards.l.LordJyscalGuado.class, NON_FULL_USE_VARIOUS)); cards.add(new SetCardInfo("Lulu, Stern Guardian", 143, Rarity.RARE, mage.cards.l.LuluSternGuardian.class, NON_FULL_USE_VARIOUS)); diff --git a/Mage.Tests/src/test/java/org/mage/test/cards/single/fic/LockeTreasureHunterTest.java b/Mage.Tests/src/test/java/org/mage/test/cards/single/fic/LockeTreasureHunterTest.java new file mode 100644 index 00000000000..5691cb82d71 --- /dev/null +++ b/Mage.Tests/src/test/java/org/mage/test/cards/single/fic/LockeTreasureHunterTest.java @@ -0,0 +1,101 @@ +package org.mage.test.cards.single.fic; + +import mage.cards.l.LockeTreasureHunter; +import mage.constants.PhaseStep; +import mage.constants.Zone; +import org.junit.Assert; +import org.junit.Test; +import org.mage.test.serverside.base.CardTestPlayerBase; + +import java.util.HashSet; +import java.util.Objects; +import java.util.Set; +import java.util.stream.Collectors; + +/** + * @author TheElk801 + */ +public class LockeTreasureHunterTest extends CardTestPlayerBase { + + private static final String dwarvenGrunt = "Dwarven Grunt"; + private static final String goblinMountaineer = "Goblin Mountaineer"; + private static final String mountainGoat = "Mountain Goat"; + private static final String zodiacGoat = "Zodiac Goat"; + + private void makeTester() { + addCustomCardWithAbility("tester", playerA, LockeTreasureHunter.makeTestAbility()); + } + + private void assertOptions(String... optionsToExpect) { + Set options = playerA + .getPlayable(currentGame, false) + .stream() + .map(Objects::toString) + .collect(Collectors.toSet()); + Set failures = new HashSet<>(); + for (String option : optionsToExpect) { + if (options.stream().noneMatch(s -> s.contains(option))) { + failures.add(option); + } + } + Assert.assertEquals( + "The following cards should be available to cast but aren't: " + + failures.stream().collect(Collectors.joining(", ")), 0, failures.size() + ); + Assert.assertEquals( + "There should be " + (2 + optionsToExpect.length) + " available actions", + 2 + optionsToExpect.length, options.size() + ); + } + + @Test + public void testCastSome() { + skipInitShuffling(); + makeTester(); + addCard(Zone.BATTLEFIELD, playerA, "Mountain", 4); + addCard(Zone.LIBRARY, playerA, dwarvenGrunt); + addCard(Zone.LIBRARY, playerA, goblinMountaineer); + addCard(Zone.LIBRARY, playerB, mountainGoat); + addCard(Zone.LIBRARY, playerB, zodiacGoat); + + activateAbility(1, PhaseStep.PRECOMBAT_MAIN, playerA, "{0}"); + activateAbility(1, PhaseStep.PRECOMBAT_MAIN, playerA, "{0}"); + + castSpell(1, PhaseStep.POSTCOMBAT_MAIN, playerA, dwarvenGrunt); + waitStackResolved(1, PhaseStep.POSTCOMBAT_MAIN); + + setStrictChooseMode(true); + setStopAt(1, PhaseStep.POSTCOMBAT_MAIN); + execute(); + + assertOptions(goblinMountaineer, zodiacGoat); + assertPermanentCount(playerA, dwarvenGrunt, 1); + } + + @Test + public void testCastAll() { + skipInitShuffling(); + makeTester(); + addCard(Zone.BATTLEFIELD, playerA, "Mountain", 4); + addCard(Zone.LIBRARY, playerA, dwarvenGrunt); + addCard(Zone.LIBRARY, playerA, goblinMountaineer); + addCard(Zone.LIBRARY, playerB, mountainGoat); + addCard(Zone.LIBRARY, playerB, zodiacGoat); + + activateAbility(1, PhaseStep.PRECOMBAT_MAIN, playerA, "{0}"); + activateAbility(1, PhaseStep.PRECOMBAT_MAIN, playerA, "{0}"); + + castSpell(1, PhaseStep.POSTCOMBAT_MAIN, playerA, dwarvenGrunt); + waitStackResolved(1, PhaseStep.POSTCOMBAT_MAIN); + castSpell(1, PhaseStep.POSTCOMBAT_MAIN, playerA, zodiacGoat); + waitStackResolved(1, PhaseStep.POSTCOMBAT_MAIN); + + setStrictChooseMode(true); + setStopAt(1, PhaseStep.POSTCOMBAT_MAIN); + execute(); + + assertOptions(); + assertPermanentCount(playerA, dwarvenGrunt, 1); + assertPermanentCount(playerA, zodiacGoat, 1); + } +} diff --git a/Mage/src/main/java/mage/MageIdentifier.java b/Mage/src/main/java/mage/MageIdentifier.java index ecd934256a5..2916b50c3ec 100644 --- a/Mage/src/main/java/mage/MageIdentifier.java +++ b/Mage/src/main/java/mage/MageIdentifier.java @@ -37,6 +37,7 @@ public enum MageIdentifier { LaraCroftTombRaiderWatcher, CoramTheUndertakerWatcher, ThundermanDragonWatcher, + LockeTreasureHunterWatcher, // ----------------------------// // alternate casts //