From 60112c6be5c74dd65e707d5bc97a7e98a68fee44 Mon Sep 17 00:00:00 2001 From: Oleg Agafonov Date: Mon, 30 Dec 2024 15:09:02 +0400 Subject: [PATCH 01/32] test framework: added commands to check declared attackers and blockers creatures (useful for AI tests, see checkAttackers and checkBlockers) --- .../test/AI/basic/AttackAndBlockByAITest.java | 32 ++++++++ .../test/AI/basic/BlockSimulationAITest.java | 10 ++- .../java/org/mage/test/player/TestPlayer.java | 78 ++++++++++++++++++- .../base/impl/CardTestPlayerAPIImpl.java | 30 ++++++- Mage/src/main/java/mage/game/GameImpl.java | 3 +- 5 files changed, 146 insertions(+), 7 deletions(-) diff --git a/Mage.Tests/src/test/java/org/mage/test/AI/basic/AttackAndBlockByAITest.java b/Mage.Tests/src/test/java/org/mage/test/AI/basic/AttackAndBlockByAITest.java index b294eb738b3..a27502ad4f6 100644 --- a/Mage.Tests/src/test/java/org/mage/test/AI/basic/AttackAndBlockByAITest.java +++ b/Mage.Tests/src/test/java/org/mage/test/AI/basic/AttackAndBlockByAITest.java @@ -6,6 +6,9 @@ import org.junit.Ignore; import org.junit.Test; import org.mage.test.serverside.base.CardTestPlayerBaseAI; +import java.util.stream.Collectors; +import java.util.stream.IntStream; + /** * @author JayDi85 */ @@ -21,6 +24,8 @@ public class AttackAndBlockByAITest extends CardTestPlayerBaseAI { // 2 x 2/2 vs 0 - can't lose any attackers addCard(Zone.BATTLEFIELD, playerA, "Balduvian Bears", 2); // 2/2 + checkAttackers("x2 attack", 1, playerA, "Balduvian Bears", "Balduvian Bears"); + setStopAt(1, PhaseStep.END_TURN); setStrictChooseMode(true); execute(); @@ -35,6 +40,8 @@ public class AttackAndBlockByAITest extends CardTestPlayerBaseAI { addCard(Zone.BATTLEFIELD, playerA, "Balduvian Bears", 2); // 2/2 addCard(Zone.BATTLEFIELD, playerB, "Arbor Elf", 1); // 1/1 + checkAttackers("x2 attack", 1, playerA, "Balduvian Bears", "Balduvian Bears"); + setStopAt(1, PhaseStep.END_TURN); setStrictChooseMode(true); execute(); @@ -49,6 +56,8 @@ public class AttackAndBlockByAITest extends CardTestPlayerBaseAI { addCard(Zone.BATTLEFIELD, playerA, "Balduvian Bears", 1); // 2/2 addCard(Zone.BATTLEFIELD, playerB, "Arbor Elf", 2); // 1/1 + checkAttackers("x1 attack", 1, playerA, "Balduvian Bears"); + setStopAt(1, PhaseStep.END_TURN); setStrictChooseMode(true); execute(); @@ -63,6 +72,8 @@ public class AttackAndBlockByAITest extends CardTestPlayerBaseAI { addCard(Zone.BATTLEFIELD, playerA, "Balduvian Bears", 2); // 2/2 addCard(Zone.BATTLEFIELD, playerB, "Arbor Elf", 2); // 1/1 + checkAttackers("x2 attack", 1, playerA, "Balduvian Bears", "Balduvian Bears"); + setStopAt(1, PhaseStep.END_TURN); setStrictChooseMode(true); execute(); @@ -75,6 +86,8 @@ public class AttackAndBlockByAITest extends CardTestPlayerBaseAI { public void test_Attack_1_small_vs_0() { addCard(Zone.BATTLEFIELD, playerA, "Arbor Elf", 1); // 1/1 + checkAttackers("x1 attack", 1, playerA, "Arbor Elf"); + setStopAt(1, PhaseStep.END_TURN); setStrictChooseMode(true); execute(); @@ -88,6 +101,8 @@ public class AttackAndBlockByAITest extends CardTestPlayerBaseAI { addCard(Zone.BATTLEFIELD, playerA, "Arbor Elf", 1); // 1/1 addCard(Zone.BATTLEFIELD, playerB, "Balduvian Bears", 1); // 2/2 + checkAttackers("no attack", 1, playerA, ""); + setStopAt(1, PhaseStep.END_TURN); setStrictChooseMode(true); execute(); @@ -101,6 +116,8 @@ public class AttackAndBlockByAITest extends CardTestPlayerBaseAI { addCard(Zone.BATTLEFIELD, playerA, "Arbor Elf", 2); // 1/1 addCard(Zone.BATTLEFIELD, playerB, "Balduvian Bears", 1); // 2/2 + checkAttackers("no attack", 1, playerA, ""); + setStopAt(1, PhaseStep.END_TURN); setStrictChooseMode(true); execute(); @@ -114,6 +131,11 @@ public class AttackAndBlockByAITest extends CardTestPlayerBaseAI { addCard(Zone.BATTLEFIELD, playerA, "Balduvian Bears", 15); // 2/2 addCard(Zone.BATTLEFIELD, playerB, "Ancient Brontodon", 1); // 9/9 + String needAttackers = IntStream.rangeClosed(1, 15) + .mapToObj(x -> "Balduvian Bears") + .collect(Collectors.joining("^")); + checkAttackers("x15 attack", 1, playerA, needAttackers); + block(1, playerB, "Ancient Brontodon", "Balduvian Bears"); setStopAt(1, PhaseStep.END_TURN); @@ -131,6 +153,10 @@ public class AttackAndBlockByAITest extends CardTestPlayerBaseAI { addCard(Zone.BATTLEFIELD, playerB, "Ancient Brontodon", 1); // 9/9 block(1, playerB, "Ancient Brontodon", "Balduvian Bears"); + String needAttackers = IntStream.rangeClosed(1, 10) + .mapToObj(x -> "Balduvian Bears") + .collect(Collectors.joining("^")); + checkAttackers("x10 attack", 1, playerA, needAttackers); setStopAt(1, PhaseStep.END_TURN); setStrictChooseMode(true); @@ -146,6 +172,7 @@ public class AttackAndBlockByAITest extends CardTestPlayerBaseAI { addCard(Zone.BATTLEFIELD, playerB, "Ancient Brontodon", 1); // 9/9 block(1, playerB, "Ancient Brontodon", "Goblin Brigand"); + checkAttackers("forced x1 attack", 1, playerA, "Goblin Brigand"); setStopAt(1, PhaseStep.END_TURN); setStrictChooseMode(true); @@ -163,6 +190,7 @@ public class AttackAndBlockByAITest extends CardTestPlayerBaseAI { block(1, playerB, "Ancient Brontodon:0", "Goblin Brigand:0"); block(1, playerB, "Ancient Brontodon:1", "Goblin Brigand:1"); + checkAttackers("forced x2 attack", 1, playerA, "Goblin Brigand", "Goblin Brigand"); setStopAt(1, PhaseStep.END_TURN); setStrictChooseMode(true); @@ -183,6 +211,7 @@ public class AttackAndBlockByAITest extends CardTestPlayerBaseAI { addCard(Zone.BATTLEFIELD, playerB, "Trove of Temptation", 1); // 9/9 block(1, playerB, "Ancient Brontodon", "Arbor Elf"); + checkAttackers("forced x1 attack", 1, playerA, "Arbor Elf"); setStopAt(1, PhaseStep.END_TURN); setStrictChooseMode(true); @@ -201,6 +230,7 @@ public class AttackAndBlockByAITest extends CardTestPlayerBaseAI { addCard(Zone.BATTLEFIELD, playerB, "Seeker of Slaanesh", 1); // 3/3 block(1, playerB, "Seeker of Slaanesh", "Arbor Elf"); + checkAttackers("forced x1 attack", 1, playerA, "Arbor Elf"); setStopAt(1, PhaseStep.END_TURN); setStrictChooseMode(true); @@ -217,6 +247,8 @@ public class AttackAndBlockByAITest extends CardTestPlayerBaseAI { addCard(Zone.BATTLEFIELD, playerA, "Balduvian Bears", 1); // 2/2 addCard(Zone.BATTLEFIELD, playerB, "Chainbreaker", 1); // 3/3, but with 2x -1/-1 counters + checkAttackers("x1 attack", 1, playerA, "Balduvian Bears"); + setStopAt(1, PhaseStep.END_TURN); setStrictChooseMode(true); execute(); diff --git a/Mage.Tests/src/test/java/org/mage/test/AI/basic/BlockSimulationAITest.java b/Mage.Tests/src/test/java/org/mage/test/AI/basic/BlockSimulationAITest.java index ef83f53f4ab..624a26a3341 100644 --- a/Mage.Tests/src/test/java/org/mage/test/AI/basic/BlockSimulationAITest.java +++ b/Mage.Tests/src/test/java/org/mage/test/AI/basic/BlockSimulationAITest.java @@ -19,6 +19,7 @@ public class BlockSimulationAITest extends CardTestPlayerBaseWithAIHelps { // ai must block aiPlayStep(1, PhaseStep.DECLARE_BLOCKERS, playerB); + checkBlockers("x1 blocker", 1, playerB, "Balduvian Bears"); setStopAt(1, PhaseStep.END_TURN); setStrictChooseMode(true); @@ -37,8 +38,9 @@ public class BlockSimulationAITest extends CardTestPlayerBaseWithAIHelps { attack(1, playerA, "Arbor Elf"); - // ai must block + // ai must block by optimal blocker aiPlayStep(1, PhaseStep.DECLARE_BLOCKERS, playerB); + checkBlockers("x1 optimal blocker", 1, playerB, "Balduvian Bears"); setStopAt(1, PhaseStep.END_TURN); setStrictChooseMode(true); @@ -58,6 +60,7 @@ public class BlockSimulationAITest extends CardTestPlayerBaseWithAIHelps { // ai must block aiPlayStep(1, PhaseStep.DECLARE_BLOCKERS, playerB); + checkBlockers("x1 blocker", 1, playerB, "Arbor Elf"); setStopAt(1, PhaseStep.END_TURN); setStrictChooseMode(true); @@ -78,6 +81,7 @@ public class BlockSimulationAITest extends CardTestPlayerBaseWithAIHelps { // ai must not block aiPlayStep(1, PhaseStep.DECLARE_BLOCKERS, playerB); + checkBlockers("no blockers", 1, playerB, ""); setStopAt(1, PhaseStep.END_TURN); setStrictChooseMode(true); @@ -100,6 +104,7 @@ public class BlockSimulationAITest extends CardTestPlayerBaseWithAIHelps { // ai must not block aiPlayStep(1, PhaseStep.DECLARE_BLOCKERS, playerB); + checkBlockers("no blockers", 1, playerB, ""); setStopAt(1, PhaseStep.END_TURN); setStrictChooseMode(true); @@ -123,6 +128,7 @@ public class BlockSimulationAITest extends CardTestPlayerBaseWithAIHelps { // ai must block bigger attacker and survive (6/6 must block 5/5) aiPlayStep(1, PhaseStep.DECLARE_BLOCKERS, playerB); + checkBlockers("x1 optimal blocker", 1, playerB, "Colossal Dreadmaw"); setStopAt(1, PhaseStep.END_TURN); setStrictChooseMode(true); @@ -151,6 +157,7 @@ public class BlockSimulationAITest extends CardTestPlayerBaseWithAIHelps { // ai must block bigger attacker and survive (3/3 must block 2/2) aiPlayStep(1, PhaseStep.DECLARE_BLOCKERS, playerB); + checkBlockers("x1 optimal blocker", 1, playerB, "Spectral Bears"); setStopAt(1, PhaseStep.END_TURN); setStrictChooseMode(true); @@ -175,6 +182,7 @@ public class BlockSimulationAITest extends CardTestPlayerBaseWithAIHelps { // ai must use smaller blocker and survive (3/3 must block 2/2) aiPlayStep(1, PhaseStep.DECLARE_BLOCKERS, playerB); + checkBlockers("x1 optimal blocker", 1, playerB, "Spectral Bears"); setStopAt(1, PhaseStep.END_TURN); setStrictChooseMode(true); diff --git a/Mage.Tests/src/test/java/org/mage/test/player/TestPlayer.java b/Mage.Tests/src/test/java/org/mage/test/player/TestPlayer.java index ff6d8181f3d..b2433f4ff43 100644 --- a/Mage.Tests/src/test/java/org/mage/test/player/TestPlayer.java +++ b/Mage.Tests/src/test/java/org/mage/test/player/TestPlayer.java @@ -28,10 +28,7 @@ import mage.filter.common.*; import mage.filter.predicate.Predicates; import mage.filter.predicate.mageobject.NamePredicate; import mage.filter.predicate.permanent.SummoningSicknessPredicate; -import mage.game.Game; -import mage.game.GameImpl; -import mage.game.Graveyard; -import mage.game.Table; +import mage.game.*; import mage.game.combat.CombatGroup; import mage.game.command.CommandObject; import mage.game.draft.Draft; @@ -52,6 +49,7 @@ import mage.target.common.*; import mage.util.CardUtil; import mage.util.MultiAmountMessage; import mage.util.RandomUtil; +import mage.watchers.common.AttackedOrBlockedThisCombatWatcher; import org.apache.log4j.Logger; import org.junit.Assert; @@ -839,6 +837,20 @@ public class TestPlayer implements Player { wasProccessed = true; } + // check attacking: attackers list + if (params[0].equals(CHECK_COMMAND_ATTACKERS) && params.length == 2) { + assertAttackers(action, game, computerPlayer, params[1]); + actions.remove(action); + wasProccessed = true; + } + + // check attacking: attackers list + if (params[0].equals(CHECK_COMMAND_BLOCKERS) && params.length == 2) { + assertBlockers(action, game, computerPlayer, params[1]); + actions.remove(action); + wasProccessed = true; + } + // check playable ability: ability text, must have if (params[0].equals(CHECK_COMMAND_PLAYABLE_ABILITY) && params.length == 3) { assertPlayableAbility(action, game, computerPlayer, params[1], Boolean.parseBoolean(params[2])); @@ -1418,6 +1430,64 @@ public class TestPlayer implements Player { } } + private void assertAttackers(PlayerAction action, Game game, Player player, String attackers) { + AttackedOrBlockedThisCombatWatcher watcher = game.getState().getWatcher(AttackedOrBlockedThisCombatWatcher.class); + Assert.assertNotNull(watcher); + + List actualAttackers = watcher.getAttackedThisTurnCreatures().stream() + .map(mor -> game.getObject(mor.getSourceId()))// no needs in zcc/lki + .filter(Objects::nonNull) + .filter(o -> o instanceof ControllableOrOwnerable) + .map(o -> (ControllableOrOwnerable) o) + .filter(o -> o.getControllerOrOwnerId().equals(player.getId())) + .map(o -> ((MageObject) o).getName()) + .sorted() + .collect(Collectors.toList()); + List needAttackers = Arrays.stream(attackers.split("\\^")) + .filter(s -> !s.equals(TestPlayer.ATTACK_SKIP)) + .sorted() + .collect(Collectors.toList()); + + if (!actualAttackers.equals(needAttackers)) { + printStart(game, action.getActionName()); + System.out.println(String.format("Need attackers: %d", needAttackers.size())); + needAttackers.forEach(s -> System.out.println(" - " + s)); + System.out.println(String.format("Actual attackers: %d", actualAttackers.size())); + actualAttackers.forEach(s -> System.out.println(" - " + s)); + printEnd(); + Assert.fail("Found wrong attackers"); + } + } + + private void assertBlockers(PlayerAction action, Game game, Player player, String blockers) { + AttackedOrBlockedThisCombatWatcher watcher = game.getState().getWatcher(AttackedOrBlockedThisCombatWatcher.class); + Assert.assertNotNull(watcher); + + List actualBlockers = watcher.getBlockedThisTurnCreatures().stream() + .map(mor -> game.getObject(mor.getSourceId()))// no needs in zcc/lki + .filter(Objects::nonNull) + .filter(o -> o instanceof ControllableOrOwnerable) + .map(o -> (ControllableOrOwnerable) o) + .filter(o -> o.getControllerOrOwnerId().equals(player.getId())) + .map(o -> ((MageObject) o).getName()) + .sorted() + .collect(Collectors.toList()); + List needBlockers = Arrays.stream(blockers.split("\\^")) + .filter(s -> !s.equals(TestPlayer.BLOCK_SKIP)) + .sorted() + .collect(Collectors.toList()); + + if (!actualBlockers.equals(needBlockers)) { + printStart(game, action.getActionName()); + System.out.println(String.format("Need blockers: %d", needBlockers.size())); + needBlockers.forEach(s -> System.out.println(" - " + s)); + System.out.println(String.format("Actual blockers: %d", actualBlockers.size())); + actualBlockers.forEach(s -> System.out.println(" - " + s)); + printEnd(); + Assert.fail("Found wrong blockers"); + } + } + private void assertMayAttackDefender(PlayerAction action, Game game, Player controller, String permanentName, Player defender, boolean expectedMayAttack) { Permanent attackingPermanent = findPermanentWithAssert(action, game, controller, permanentName); diff --git a/Mage.Tests/src/test/java/org/mage/test/serverside/base/impl/CardTestPlayerAPIImpl.java b/Mage.Tests/src/test/java/org/mage/test/serverside/base/impl/CardTestPlayerAPIImpl.java index f13f18f145f..c78ea5c0fd7 100644 --- a/Mage.Tests/src/test/java/org/mage/test/serverside/base/impl/CardTestPlayerAPIImpl.java +++ b/Mage.Tests/src/test/java/org/mage/test/serverside/base/impl/CardTestPlayerAPIImpl.java @@ -87,6 +87,8 @@ public abstract class CardTestPlayerAPIImpl extends MageTestPlayerBase implement public static final String CHECK_COMMAND_LIFE = "LIFE"; public static final String CHECK_COMMAND_ABILITY = "ABILITY"; public static final String CHECK_COMMAND_PLAYABLE_ABILITY = "PLAYABLE_ABILITY"; + public static final String CHECK_COMMAND_ATTACKERS = "ATTACKERS"; + public static final String CHECK_COMMAND_BLOCKERS = "BLOCKERS"; public static final String CHECK_COMMAND_MAY_ATTACK_DEFENDER = "MAY_ATTACK_DEFENDER"; public static final String CHECK_COMMAND_PERMANENT_COUNT = "PERMANENT_COUNT"; public static final String CHECK_COMMAND_PERMANENT_TAPPED = "PERMANENT_TAPPED"; @@ -428,7 +430,33 @@ public abstract class CardTestPlayerAPIImpl extends MageTestPlayerBase implement } /** - * Checks whether or not a creature can attack on a given turn a defender (player only for now, could be extended to permanents) + * Make sure in last declared attackers + * + * @param attackers in any order, use empty string or params for no attackers check + */ + public void checkAttackers(String checkName, int turnNum, TestPlayer player, String... attackers) { + String list = String.join("^", attackers); + if (list.isEmpty()) { + list = TestPlayer.ATTACK_SKIP; + } + check(checkName, turnNum, PhaseStep.DECLARE_ATTACKERS, player, CHECK_COMMAND_ATTACKERS, list); + } + + /** + * Make sure in last declared blockers + * + * @param blockers in any order, use empty string or params for no blockers check + */ + public void checkBlockers(String checkName, int turnNum, TestPlayer player, String... blockers) { + String list = String.join("^", blockers); + if (list.isEmpty()) { + list = TestPlayer.BLOCK_SKIP; + } + check(checkName, turnNum, PhaseStep.DECLARE_BLOCKERS, player, CHECK_COMMAND_BLOCKERS, list); + } + + /** + * Checks whether a creature can attack on a given turn a defender (player only for now, could be extended to permanents) * * @param checkName String to show up if the check fails, for display purposes only. * @param turnNum The turn number to check on. diff --git a/Mage/src/main/java/mage/game/GameImpl.java b/Mage/src/main/java/mage/game/GameImpl.java index 0f82f0ae2e7..66ea48734b6 100644 --- a/Mage/src/main/java/mage/game/GameImpl.java +++ b/Mage/src/main/java/mage/game/GameImpl.java @@ -1423,6 +1423,7 @@ public abstract class GameImpl implements Game { newWatchers.add(new CreaturesDiedWatcher()); newWatchers.add(new TemptedByTheRingWatcher()); newWatchers.add(new SpellsCastWatcher()); + newWatchers.add(new AttackedOrBlockedThisCombatWatcher()); // required for tests // runtime check - allows only GAME scope (one watcher per game) newWatchers.forEach(watcher -> { @@ -3635,7 +3636,7 @@ public abstract class GameImpl implements Game { /** * Reset objects stored for Last Known Information. (Happens if all effects - * are applied und stack is empty) + * are applied and stack is empty) */ @Override public void resetLKI() { -- 2.47.2 From 23a414e0740d50718495e5642379fc825983a027 Mon Sep 17 00:00:00 2001 From: Oleg Agafonov Date: Mon, 30 Dec 2024 21:33:54 +0400 Subject: [PATCH 02/32] refactor, combat: improved declare blockers code, added docs, added additional runtime checks for AI, added debug info --- .../main/java/mage/game/combat/Combat.java | 72 ++++++++++++++----- .../java/mage/game/combat/CombatGroup.java | 8 +++ 2 files changed, 63 insertions(+), 17 deletions(-) diff --git a/Mage/src/main/java/mage/game/combat/Combat.java b/Mage/src/main/java/mage/game/combat/Combat.java index 7137f686c7b..b62a906a966 100644 --- a/Mage/src/main/java/mage/game/combat/Combat.java +++ b/Mage/src/main/java/mage/game/combat/Combat.java @@ -663,26 +663,47 @@ public class Combat implements Serializable, Copyable { if (defender == null) { continue; } - boolean choose = true; if (blockController == null) { controller = defender; } else { controller = blockController; } - while (choose) { + + // choosing until good block configuration + while (true) { + // declare normal blockers + // TODO: need reseach - is it possible to concede on bad blocker configuration (e.g. user can't continue) controller.selectBlockers(source, game, defenderId); if (game.isPaused() || game.checkIfGameIsOver() || game.executingRollback()) { return; } - if (!game.getCombat().checkBlockRestrictions(defender, game)) { - if (controller.isHuman()) { // only human player can decide to do the block in another way - continue; - } + + // check multiple restrictions by permanents and effects, reset on invalid blocking configuration, try to auto-fix + // TODO: wtf, some checks contains AI related code inside -- it must be reworked and moved to computer classes?! + + // check 1 of 3 + boolean isValidBlock = game.getCombat().checkBlockRestrictions(defender, game); + if (!isValidBlock) { + makeSureItsNotComputer(controller); + continue; } - choose = !game.getCombat().checkBlockRequirementsAfter(defender, controller, game); - if (!choose) { - choose = !game.getCombat().checkBlockRestrictionsAfter(defender, controller, game); + + // check 2 of 3 + isValidBlock = game.getCombat().checkBlockRequirementsAfter(defender, controller, game); + if (!isValidBlock) { + makeSureItsNotComputer(controller); + continue; } + + // check 3 of 3 + isValidBlock = game.getCombat().checkBlockRestrictionsAfter(defender, controller, game); + if (!isValidBlock) { + makeSureItsNotComputer(controller); + continue; + } + + // all valid, can finish now + break; } game.fireEvent(GameEvent.getEvent(GameEvent.EventType.DECLARED_BLOCKERS, defenderId, defenderId)); @@ -695,6 +716,15 @@ public class Combat implements Serializable, Copyable { TraceUtil.traceCombatIfNeeded(game, game.getCombat()); } + private void makeSureItsNotComputer(Player controller) { + if (controller.isComputer() || !controller.isHuman()) { + // TODO: wtf, AI will freeze forever here in games with attacker/blocker restrictions, + // but it pass in some use cases due random choices. AI must deside blocker configuration + // in one attempt + throw new IllegalStateException("AI can't find good blocker configuration, report it to github"); + } + } + /** * Add info about attacker blocked by blocker to the game log */ @@ -852,10 +882,7 @@ public class Combat implements Serializable, Copyable { * attacking creature fulfills both the restriction and the requirement, so * that's the only option. * - * @param player - * @param controller - * @param game - * @return + * @return false on invalid block configuration e.g. player must choose new blockers */ public boolean checkBlockRequirementsAfter(Player player, Player controller, Game game) { // Get once a list of all opponents in range @@ -1274,10 +1301,7 @@ public class Combat implements Serializable, Copyable { * Checks the canBeBlockedCheckAfter RestrictionEffect Is the block still * valid after all block decisions are done * - * @param player - * @param controller - * @param game - * @return + * @return false on invalid block configuration e.g. player must choose new blockers */ public boolean checkBlockRestrictionsAfter(Player player, Player controller, Game game) { // Restrictions applied to blocking creatures @@ -1906,4 +1930,18 @@ public class Combat implements Serializable, Copyable { return new Combat(this); } + @Override + public String toString() { + List res = new ArrayList<>(); + for (int i = 0; i < this.groups.size(); i++) { + res.add(String.format("group %d with %s", + i + 1, + this.groups.get(i) + )); + } + return String.format("%d groups%s", + this.groups.size(), + this.groups.size() > 0 ? ": " + String.join("; ", res) : "" + ); + } } diff --git a/Mage/src/main/java/mage/game/combat/CombatGroup.java b/Mage/src/main/java/mage/game/combat/CombatGroup.java index 9106b2d4abc..c5b5a9cb677 100644 --- a/Mage/src/main/java/mage/game/combat/CombatGroup.java +++ b/Mage/src/main/java/mage/game/combat/CombatGroup.java @@ -976,4 +976,12 @@ public class CombatGroup implements Serializable, Copyable { private static int getLethalDamage(Permanent blocker, Permanent attacker, Game game) { return blocker.getLethalDamage(attacker.getId(), game); } + + @Override + public String toString() { + return String.format("%d attackers, %d blockers", + this.getAttackers().size(), + this.getBlockers().size() + ); + } } -- 2.47.2 From aef220a19b8adc1f0100e4e712b2ece9e26ef551 Mon Sep 17 00:00:00 2001 From: Oleg Agafonov Date: Tue, 31 Dec 2024 15:55:26 +0400 Subject: [PATCH 03/32] tests: added progress bar with all running games in load tests, added error on non-started local server --- .../java/org/mage/test/load/LoadTest.java | 84 +++++++++++++++---- 1 file changed, 66 insertions(+), 18 deletions(-) diff --git a/Mage.Tests/src/test/java/org/mage/test/load/LoadTest.java b/Mage.Tests/src/test/java/org/mage/test/load/LoadTest.java index e2da4f62218..9319ab03b16 100644 --- a/Mage.Tests/src/test/java/org/mage/test/load/LoadTest.java +++ b/Mage.Tests/src/test/java/org/mage/test/load/LoadTest.java @@ -27,6 +27,7 @@ import java.time.Instant; import java.time.temporal.ChronoUnit; import java.util.*; import java.util.concurrent.*; +import java.util.stream.Collectors; /** * Intended to test Mage server under different load patterns. @@ -214,7 +215,7 @@ public class LoadTest { } } - public void playTwoAIGame(String gameName, long randomSeed, String deckColors, String deckAllowedSets, LoadTestGameResult gameResult) { + public void playTwoAIGame(String gameName, Integer taskNumber, TasksProgress tasksProgress, long randomSeed, String deckColors, String deckAllowedSets, LoadTestGameResult gameResult) { Assert.assertFalse("need deck colors", deckColors.isEmpty()); Assert.assertFalse("need allowed sets", deckAllowedSets.isEmpty()); @@ -249,9 +250,13 @@ public class LoadTest { TableView checkGame = monitor.getTable(tableId).orElse(null); TableState state = (checkGame == null ? null : checkGame.getTableState()); + tasksProgress.update(taskNumber, state == TableState.FINISHED, gameView == null ? 0 : gameView.getTurn()); + String globalProgress = tasksProgress.getInfo(); + if (gameView != null && checkGame != null) { - logger.info(checkGame.getTableName() + ": ---"); - logger.info(String.format("%s: turn %d, step %s, state %s", + logger.info(globalProgress + ", " + checkGame.getTableName() + ": ---"); + logger.info(String.format("%s, %s: turn %d, step %s, state %s", + globalProgress, checkGame.getTableName(), gameView.getTurn(), gameView.getStep().toString(), @@ -278,7 +283,8 @@ public class LoadTest { if (Objects.equals(gameView.getActivePlayerId(), p.getPlayerId())) { activeInfo = " (active)"; } - logger.info(String.format("%s, status: %s - Life=%d; Lib=%d;%s", + logger.info(String.format("%s, %s, status: %s - Life=%d; Lib=%d;%s", + globalProgress, checkGame.getTableName(), p.getName(), p.getLife(), @@ -286,7 +292,7 @@ public class LoadTest { activeInfo )); }); - logger.info(checkGame.getTableName() + ": ---"); + logger.info(globalProgress + ", " + checkGame.getTableName() + ": ---"); } // ping to keep active session @@ -312,7 +318,9 @@ public class LoadTest { LoadTestGameResultsList gameResults = new LoadTestGameResultsList(); long randomSeed = RandomUtil.nextInt(); LoadTestGameResult gameResult = gameResults.createGame(0, "test game", randomSeed); - playTwoAIGame("Single AI game", randomSeed, "WGUBR", TEST_AI_RANDOM_DECK_SETS, gameResult); + TasksProgress tasksProgress = new TasksProgress(); + tasksProgress.update(1, true, 0); + playTwoAIGame("Single AI game", 1, tasksProgress, randomSeed, "WGUBR", TEST_AI_RANDOM_DECK_SETS, gameResult); printGameResults(gameResults); } @@ -347,15 +355,16 @@ public class LoadTest { LoadTestGameResultsList gameResults = new LoadTestGameResultsList(); try { + TasksProgress tasksProgress = new TasksProgress(); for (int i = 0; i < seedsList.size(); i++) { int gameIndex = i; + tasksProgress.update(gameIndex + 1, true, 0); long randomSeed = seedsList.get(i); logger.info("Game " + (i + 1) + " of " + seedsList.size() + ", RANDOM seed: " + randomSeed); - Future gameTask = executerService.submit(() -> { String gameName = "AI game #" + (gameIndex + 1); LoadTestGameResult gameResult = gameResults.createGame(gameIndex + 1, gameName, randomSeed); - playTwoAIGame(gameName, randomSeed, TEST_AI_RANDOM_DECK_COLORS_FOR_AI_GAME, TEST_AI_RANDOM_DECK_SETS, gameResult); + playTwoAIGame(gameName, gameIndex + 1, tasksProgress, randomSeed, TEST_AI_RANDOM_DECK_COLORS_FOR_AI_GAME, TEST_AI_RANDOM_DECK_SETS, gameResult); }); if (!isRunParallel) { @@ -571,6 +580,40 @@ public class LoadTest { return createSimpleGameOptions(gameName, gameTypeView, session, PlayerType.COMPUTER_MAD); } + private static class TasksProgress { + + private String info; + private final Map finishes = new LinkedHashMap<>(); + private final Map turns = new LinkedHashMap<>(); + + synchronized public void update(Integer taskNumber, boolean newFinish, Integer newTurn) { + Boolean oldFinish = this.finishes.getOrDefault(taskNumber, false); + Integer oldTurn = this.turns.getOrDefault(taskNumber, 0); + if (!this.finishes.containsKey(taskNumber) + || !Objects.equals(oldFinish, newFinish) + || !Objects.equals(oldTurn, newTurn)) { + this.finishes.put(taskNumber, newFinish); + this.turns.put(taskNumber, newTurn); + updateInfo(); + } + } + + private void updateInfo() { + // example: progress [=00, +01, +01, =12, =15, =01, +61] + String res = this.finishes.keySet().stream() + .map(taskNumber -> String.format("%s%02d", + this.finishes.getOrDefault(taskNumber, false) ? "=" : "+", + this.turns.getOrDefault(taskNumber, 0) + )) + .collect(Collectors.joining(", ")); + this.info = String.format("progress [%s]", res); + } + + public String getInfo() { + return this.info; + } + } + private class LoadPlayer { String userName; @@ -798,23 +841,23 @@ public class LoadTest { } public int getLife1() { - return this.finalGameView.getPlayers().get(0).getLife(); + return finalGameView == null ? 0 : this.finalGameView.getPlayers().get(0).getLife(); } public int getLife2() { - return this.finalGameView.getPlayers().get(1).getLife(); + return finalGameView == null ? 0 : this.finalGameView.getPlayers().get(1).getLife(); } public int getTurn() { - return this.finalGameView.getTurn(); + return finalGameView == null ? 0 : this.finalGameView.getTurn(); } public int getDurationMs() { - return (int) ((this.timeEnded.getTime() - this.timeStarted.getTime())); + return finalGameView == null ? 0 : ((int) ((this.timeEnded.getTime() - this.timeStarted.getTime()))); } public int getTotalErrorsCount() { - return this.finalGameView.getTotalErrorsCount(); + return finalGameView == null ? 0 : this.finalGameView.getTotalErrorsCount(); } } @@ -886,15 +929,15 @@ public class LoadTest { } private int getAvgTurn() { - return this.values().stream().mapToInt(LoadTestGameResult::getTurn).sum() / this.size(); + return this.size() == 0 ? 0 : this.values().stream().mapToInt(LoadTestGameResult::getTurn).sum() / this.size(); } private int getAvgLife1() { - return this.values().stream().mapToInt(LoadTestGameResult::getLife1).sum() / this.size(); + return this.size() == 0 ? 0 : this.values().stream().mapToInt(LoadTestGameResult::getLife1).sum() / this.size(); } private int getAvgLife2() { - return this.values().stream().mapToInt(LoadTestGameResult::getLife2).sum() / this.size(); + return this.size() == 0 ? 0 : this.values().stream().mapToInt(LoadTestGameResult::getLife2).sum() / this.size(); } private int getTotalDurationMs() { @@ -902,11 +945,12 @@ public class LoadTest { } private int getAvgDurationMs() { - return this.values().stream().mapToInt(LoadTestGameResult::getDurationMs).sum() / this.size(); + return this.size() == 0 ? 0 : this.values().stream().mapToInt(LoadTestGameResult::getDurationMs).sum() / this.size(); } private int getAvgDurationPerTurnMs() { - return getAvgDurationMs() / getAvgTurn(); + int turns = getAvgTurn(); + return turns == 0 ? 0 : getAvgDurationMs() / getAvgTurn(); } } @@ -952,5 +996,9 @@ public class LoadTest { gameResults.printResultHeader(); gameResults.printResultData(); gameResults.printResultTotal(); + + if (gameResults.getAvgTurn() == 0) { + Assert.fail("Games can't start, make sure you are run a localhost server before running current load test"); + } } } -- 2.47.2 From b2279a8e9c0bcce295eb52fa7c49944f759159de Mon Sep 17 00:00:00 2001 From: Oleg Agafonov Date: Tue, 31 Dec 2024 22:07:07 +0400 Subject: [PATCH 04/32] tests: added many use cases for must be blocked, must blocking and menace effects (related to #13182) --- .../combat/AttackBlockRestrictionsTest.java | 452 ++++++++++++++++-- .../main/java/mage/game/combat/Combat.java | 15 +- 2 files changed, 423 insertions(+), 44 deletions(-) diff --git a/Mage.Tests/src/test/java/org/mage/test/combat/AttackBlockRestrictionsTest.java b/Mage.Tests/src/test/java/org/mage/test/combat/AttackBlockRestrictionsTest.java index c6ae191e7d8..9b759bc2784 100644 --- a/Mage.Tests/src/test/java/org/mage/test/combat/AttackBlockRestrictionsTest.java +++ b/Mage.Tests/src/test/java/org/mage/test/combat/AttackBlockRestrictionsTest.java @@ -5,15 +5,17 @@ import mage.constants.Zone; import mage.counters.CounterType; import mage.game.permanent.Permanent; import org.junit.Assert; -import static org.junit.Assert.assertEquals; -import static org.junit.Assert.fail; +import org.junit.Ignore; import org.junit.Test; import org.mage.test.serverside.base.CardTestPlayerBase; +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.fail; + /** * Test restrictions for choosing attackers and blockers. * - * @author noxx + * @author noxx, JayDi85 */ public class AttackBlockRestrictionsTest extends CardTestPlayerBase { @@ -337,10 +339,9 @@ public class AttackBlockRestrictionsTest extends CardTestPlayerBase { /* * Mogg Flunkies cannot attack alone. Cards like Goblin Assault force all goblins to attack each turn. * Mogg Flunkies should not be able to attack. - */ + */ @Test - public void testMustAttackButCannotAttackAlone() - { + public void testMustAttackButCannotAttackAlone() { /* Mogg Flunkies {1}{R} 3/3 Creature — Goblin Mogg Flunkies can't attack or block alone. @@ -434,11 +435,11 @@ public class AttackBlockRestrictionsTest extends CardTestPlayerBase { @Test public void underworldCerberusBlockedByOneTest() { - /* Underworld Cerberus {3}{B}{3} 6/6 - * Underworld Cerberus can't be blocked except by three or more creatures. - * Cards in graveyards can't be the targets of spells or abilities. - * When Underworld Cerberus dies, exile it and each player returns all creature cards from their graveyard to their hand. - */ + /* Underworld Cerberus {3}{B}{3} 6/6 + * Underworld Cerberus can't be blocked except by three or more creatures. + * Cards in graveyards can't be the targets of spells or abilities. + * When Underworld Cerberus dies, exile it and each player returns all creature cards from their graveyard to their hand. + */ addCard(Zone.BATTLEFIELD, playerA, "Underworld Cerberus"); addCard(Zone.BATTLEFIELD, playerB, "Memnite"); // 1/1 @@ -450,18 +451,18 @@ public class AttackBlockRestrictionsTest extends CardTestPlayerBase { try { execute(); fail("Expected exception not thrown"); - } catch(UnsupportedOperationException e) { + } catch (UnsupportedOperationException e) { assertEquals("Underworld Cerberus is blocked by 1 creature(s). It has to be blocked by 3 or more.", e.getMessage()); } } @Test public void underworldCerberusBlockedByTwoTest() { - /* Underworld Cerberus {3}{B}{3} 6/6 - * Underworld Cerberus can't be blocked except by three or more creatures. - * Cards in graveyards can't be the targets of spells or abilities. - * When Underworld Cerberus dies, exile it and each player returns all creature cards from their graveyard to their hand. - */ + /* Underworld Cerberus {3}{B}{3} 6/6 + * Underworld Cerberus can't be blocked except by three or more creatures. + * Cards in graveyards can't be the targets of spells or abilities. + * When Underworld Cerberus dies, exile it and each player returns all creature cards from their graveyard to their hand. + */ addCard(Zone.BATTLEFIELD, playerA, "Underworld Cerberus"); addCard(Zone.BATTLEFIELD, playerB, "Memnite", 2); // 1/1 @@ -474,7 +475,7 @@ public class AttackBlockRestrictionsTest extends CardTestPlayerBase { try { execute(); fail("Expected exception not thrown"); - } catch(UnsupportedOperationException e) { + } catch (UnsupportedOperationException e) { assertEquals("Underworld Cerberus is blocked by 2 creature(s). It has to be blocked by 3 or more.", e.getMessage()); } } @@ -482,11 +483,11 @@ public class AttackBlockRestrictionsTest extends CardTestPlayerBase { @Test public void underworldCerberusBlockedByThreeTest() { - /* Underworld Cerberus {3}{B}{3} 6/6 - * Underworld Cerberus can't be blocked except by three or more creatures. - * Cards in graveyards can't be the targets of spells or abilities. - * When Underworld Cerberus dies, exile it and each player returns all creature cards from their graveyard to their hand. - */ + /* Underworld Cerberus {3}{B}{3} 6/6 + * Underworld Cerberus can't be blocked except by three or more creatures. + * Cards in graveyards can't be the targets of spells or abilities. + * When Underworld Cerberus dies, exile it and each player returns all creature cards from their graveyard to their hand. + */ addCard(Zone.BATTLEFIELD, playerA, "Underworld Cerberus"); addCard(Zone.BATTLEFIELD, playerB, "Memnite", 3); // 1/1 @@ -511,17 +512,17 @@ public class AttackBlockRestrictionsTest extends CardTestPlayerBase { @Test public void underworldCerberusBlockedByTenTest() { - /* Underworld Cerberus {3}{B}{3} 6/6 - * Underworld Cerberus can't be blocked except by three or more creatures. - * Cards in graveyards can't be the targets of spells or abilities. - * When Underworld Cerberus dies, exile it and each player returns all creature cards from their graveyard to their hand. - */ + /* Underworld Cerberus {3}{B}{3} 6/6 + * Underworld Cerberus can't be blocked except by three or more creatures. + * Cards in graveyards can't be the targets of spells or abilities. + * When Underworld Cerberus dies, exile it and each player returns all creature cards from their graveyard to their hand. + */ addCard(Zone.BATTLEFIELD, playerA, "Underworld Cerberus"); addCard(Zone.BATTLEFIELD, playerB, "Memnite", 10); // 1/1 // Blocked by 10 creatures - this is acceptable as it's >3 attack(3, playerA, "Underworld Cerberus"); - for(int i = 0; i < 10; i++) { + for (int i = 0; i < 10; i++) { block(3, playerB, "Memnite:" + i, "Underworld Cerberus"); } @@ -541,24 +542,24 @@ public class AttackBlockRestrictionsTest extends CardTestPlayerBase { assertLife(playerA, 20); assertLife(playerB, 20); } - + @Test public void irresistiblePreyMustBeBlockedTest() { addCard(Zone.BATTLEFIELD, playerA, "Llanowar Elves"); addCard(Zone.BATTLEFIELD, playerA, "Alpha Myr"); addCard(Zone.BATTLEFIELD, playerA, "Forest"); addCard(Zone.HAND, playerA, "Irresistible Prey"); - + addCard(Zone.BATTLEFIELD, playerB, "Bronze Sable"); - + castSpell(1, PhaseStep.PRECOMBAT_MAIN, playerA, "Irresistible Prey", "Llanowar Elves"); // must be blocked - + attack(1, playerA, "Llanowar Elves"); attack(1, playerA, "Alpha Myr"); - + // attempt to block the creature that doesn't have "must be blocked" block(1, playerB, "Bronze Sable", "Alpha Myr"); - + setStopAt(1, PhaseStep.POSTCOMBAT_MAIN); execute(); @@ -568,4 +569,383 @@ public class AttackBlockRestrictionsTest extends CardTestPlayerBase { assertTapped("Alpha Myr", true); assertLife(playerB, 18); } -} + + @Test + public void test_MustBeBlocked_nothing() { + // Fear of Being Hunted must be blocked if able. + addCard(Zone.BATTLEFIELD, playerA, "Fear of Being Hunted"); // 4/2 + + attack(1, playerA, "Fear of Being Hunted"); + checkAttackers("x1 attacker", 1, playerA, "Fear of Being Hunted"); + checkBlockers("no blocker", 1, playerB, ""); + + setStrictChooseMode(true); + setStopAt(1, PhaseStep.END_TURN); + execute(); + + assertLife(playerB, 20 - 4); + } + + @Test + public void test_MustBeBlocked_1_blocker() { + // Fear of Being Hunted must be blocked if able. + addCard(Zone.BATTLEFIELD, playerA, "Fear of Being Hunted"); // 4/2 + // + addCard(Zone.BATTLEFIELD, playerB, "Alpha Myr", 1); // 2/1 + + attack(1, playerA, "Fear of Being Hunted"); + checkAttackers("x1 attacker", 1, playerA, "Fear of Being Hunted"); + checkBlockers("forced x1 blocker", 1, playerB, "Alpha Myr"); + + setStrictChooseMode(true); + setStopAt(1, PhaseStep.END_TURN); + execute(); + + assertLife(playerB, 20); + assertGraveyardCount(playerA, "Fear of Being Hunted", 1); + } + + @Test + public void test_MustBeBlocked_many_blockers_good() { + // Fear of Being Hunted must be blocked if able. + addCard(Zone.BATTLEFIELD, playerA, "Fear of Being Hunted"); // 4/2 + // + addCard(Zone.BATTLEFIELD, playerB, "Spectral Bears", 10); // 3/3 + + // TODO: human logic can't be tested (until isHuman replaced by ~isComputer), so current use case will + // take first available blocker + attack(1, playerA, "Fear of Being Hunted"); + checkAttackers("x1 attacker", 1, playerA, "Fear of Being Hunted"); + checkBlockers("x1 optimal blocker", 1, playerB, "Spectral Bears"); + + setStrictChooseMode(true); + setStopAt(1, PhaseStep.END_TURN); + execute(); + + assertLife(playerB, 20); + assertGraveyardCount(playerA, "Fear of Being Hunted", 1); + } + + @Test + public void test_MustBeBlocked_many_blockers_bad() { + // Fear of Being Hunted must be blocked if able. + addCard(Zone.BATTLEFIELD, playerA, "Fear of Being Hunted"); // 4/2 + // + addCard(Zone.BATTLEFIELD, playerB, "Memnite", 10); // 1/1 + + // TODO: human logic can't be tested (until isHuman replaced by ~isComputer), so current use case will + // take first available blocker + attack(1, playerA, "Fear of Being Hunted"); + checkAttackers("x1 attacker", 1, playerA, "Fear of Being Hunted"); + checkBlockers("x1 optimal blocker", 1, playerB, "Memnite"); + + setStrictChooseMode(true); + setStopAt(1, PhaseStep.END_TURN); + execute(); + + assertLife(playerB, 20); + assertPermanentCount(playerA, "Fear of Being Hunted", 1); + } + + @Test + @Ignore + // TODO: enable and duplicate for AI -- after implement choose blocker logic and isHuman replace by ~isComputer + public void test_MustBeBlocked_many_blockers_optimal() { + // Fear of Being Hunted must be blocked if able. + addCard(Zone.BATTLEFIELD, playerA, "Fear of Being Hunted"); // 4/2 + // + addCard(Zone.BATTLEFIELD, playerB, "Memnite", 1); // 1/1 + addCard(Zone.BATTLEFIELD, playerB, "Grizzly Bears", 1); // 2/2 + addCard(Zone.BATTLEFIELD, playerB, "Spectral Bears", 1); // 3/3 + addCard(Zone.BATTLEFIELD, playerB, "Deadbridge Goliath", 1); // 5/5 + + attack(1, playerA, "Fear of Being Hunted"); + checkAttackers("x1 attacker", 1, playerA, "Fear of Being Hunted"); + checkBlockers("x1 optimal blocker", 1, playerB, "Deadbridge Goliath"); + + setStrictChooseMode(true); + setStopAt(1, PhaseStep.END_TURN); + execute(); + + assertLife(playerB, 20); + assertGraveyardCount(playerA, "Fear of Being Hunted", 1); + } + + @Test + public void test_MustBlocking_zero_blockers() { + // All creatures able to block target creature this turn do so. + addCard(Zone.HAND, playerA, "Bloodscent"); // {3}{G} + addCard(Zone.BATTLEFIELD, playerA, "Forest", 4); // {3}{G} + // + // Menace + // Each creature you control with menace can't be blocked except by three or more creatures. + addCard(Zone.BATTLEFIELD, playerA, "Sonorous Howlbonder"); // 2/2 + + // prepare + castSpell(1, PhaseStep.PRECOMBAT_MAIN, playerA, "Bloodscent", "Sonorous Howlbonder"); + + attack(1, playerA, "Sonorous Howlbonder"); + checkAttackers("x1 attacker", 1, playerA, "Sonorous Howlbonder"); + checkBlockers("no blocker", 1, playerB, ""); + + setStrictChooseMode(true); + setStopAt(1, PhaseStep.END_TURN); + execute(); + + assertLife(playerB, 20 - 2); + assertPermanentCount(playerA, "Sonorous Howlbonder", 1); + } + + @Test + public void test_MustBlocking_full_blockers() { + // All creatures able to block target creature this turn do so. + addCard(Zone.HAND, playerA, "Bloodscent"); // {3}{G} + addCard(Zone.BATTLEFIELD, playerA, "Forest", 4); // {3}{G} + // + // Menace + // Each creature you control with menace can't be blocked except by three or more creatures. + addCard(Zone.BATTLEFIELD, playerA, "Sonorous Howlbonder"); // 2/2 + // + addCard(Zone.BATTLEFIELD, playerB, "Memnite", 3); // 1/1 + + // prepare + castSpell(1, PhaseStep.PRECOMBAT_MAIN, playerA, "Bloodscent", "Sonorous Howlbonder"); + + attack(1, playerA, "Sonorous Howlbonder"); + setChoiceAmount(playerA, 1); // assign damage to 1 of 3 blocking memnites + checkAttackers("x1 attacker", 1, playerA, "Sonorous Howlbonder"); + checkBlockers("x3 blockers", 1, playerB, "Memnite", "Memnite", "Memnite"); + + setStrictChooseMode(true); + setStopAt(1, PhaseStep.END_TURN); + execute(); + + assertLife(playerB, 20); + assertGraveyardCount(playerA, "Sonorous Howlbonder", 1); + } + + @Test + public void test_MustBlocking_many_blockers() { + // possible bug: AI's blockers auto-fix assign too many blockers (e.g. x10 instead x3 by required effect) + + // All creatures able to block target creature this turn do so. + addCard(Zone.HAND, playerA, "Bloodscent"); // {3}{G} + addCard(Zone.BATTLEFIELD, playerA, "Forest", 4); // {3}{G} + // + // Menace + // Each creature you control with menace can't be blocked except by three or more creatures. + addCard(Zone.BATTLEFIELD, playerA, "Sonorous Howlbonder"); // 2/2 + // + addCard(Zone.BATTLEFIELD, playerB, "Memnite", 5); // 1/1 + + // prepare + castSpell(1, PhaseStep.PRECOMBAT_MAIN, playerA, "Bloodscent", "Sonorous Howlbonder"); + + attack(1, playerA, "Sonorous Howlbonder"); + setChoiceAmount(playerA, 1); // assign damage to 1 of 3 blocking memnites + checkAttackers("x1 attacker", 1, playerA, "Sonorous Howlbonder"); + checkBlockers("all blockers", 1, playerB, "Memnite", "Memnite", "Memnite", "Memnite", "Memnite"); + + setStrictChooseMode(true); + setStopAt(1, PhaseStep.END_TURN); + execute(); + + assertLife(playerB, 20); + assertGraveyardCount(playerA, "Sonorous Howlbonder", 1); + } + + @Test + @Ignore + // TODO: need exception fix - java.lang.UnsupportedOperationException: Sonorous Howlbonder is blocked by 1 creature(s). It has to be blocked by 3 or more. + // It's auto-fix in block configuration, so exception must be fixed cause AI works with it + public void test_MustBlocking_low_blockers() { + // possible bug: exception on wrong block configuration + // if effect require x3 blockers, but opponent has only 1 then it must use 1 blocker anyway + + // All creatures able to block target creature this turn do so. + addCard(Zone.HAND, playerA, "Bloodscent"); // {3}{G} + addCard(Zone.BATTLEFIELD, playerA, "Forest", 4); // {3}{G} + // + // Menace + // Each creature you control with menace can't be blocked except by three or more creatures. + addCard(Zone.BATTLEFIELD, playerA, "Sonorous Howlbonder"); // 2/2 + // + addCard(Zone.BATTLEFIELD, playerB, "Memnite", 1); // 1/1 + + // prepare + castSpell(1, PhaseStep.PRECOMBAT_MAIN, playerA, "Bloodscent", "Sonorous Howlbonder"); + + attack(1, playerA, "Sonorous Howlbonder"); + setChoiceAmount(playerA, 1); // assign damage to 1 of 3 blocking memnites + checkAttackers("x1 attacker", 1, playerA, "Sonorous Howlbonder"); + checkBlockers("one possible blocker", 1, playerB, "Memnite"); + + setStrictChooseMode(true); + setStopAt(1, PhaseStep.END_TURN); + execute(); + + assertLife(playerB, 20); + assertGraveyardCount(playerA, "Sonorous Howlbonder", 1); + } + + @Test + public void test_MustBeBlockedWithMenace_0_blockers() { + // At the beginning of combat on your turn, you may pay {2}{R/G}. If you do, double target creature’s + // power until end of turn. That creature must be blocked this combat if able. + addCard(Zone.BATTLEFIELD, playerA, "Neyith of the Dire Hunt"); // 3/3 + addCard(Zone.BATTLEFIELD, playerA, "Forest", 3); + // + // Menace + addCard(Zone.BATTLEFIELD, playerA, "Alley Strangler", 1); // 2/3 + + addTarget(playerA, "Alley Strangler"); // boost target + setChoice(playerA, true); // boost target + attack(1, playerA, "Alley Strangler"); + checkAttackers("x1 attacker", 1, playerA, "Alley Strangler"); + checkBlockers("no blocker", 1, playerB, ""); + + setStrictChooseMode(true); + setStopAt(1, PhaseStep.END_TURN); + execute(); + + assertLife(playerB, 20 - 4); + assertGraveyardCount(playerA, "Alley Strangler", 0); + } + + @Test + @Ignore // TODO: need improve of block configuration auto-fix (block by x2 instead x1) + public void test_MustBeBlockedWithMenace_all_blockers() { + // At the beginning of combat on your turn, you may pay {2}{R/G}. If you do, double target creature’s + // power until end of turn. That creature must be blocked this combat if able. + addCard(Zone.BATTLEFIELD, playerA, "Neyith of the Dire Hunt"); // 3/3 + addCard(Zone.BATTLEFIELD, playerA, "Forest", 3); + // + // Menace + addCard(Zone.BATTLEFIELD, playerA, "Alley Strangler", 1); // 2/3 + // + addCard(Zone.BATTLEFIELD, playerB, "Memnite", 2); // 1/1 + + // If the target creature has menace, two creatures must block it if able. + // (2020-06-23) + + addTarget(playerA, "Alley Strangler"); // boost target + setChoice(playerA, true); // boost target + attack(1, playerA, "Alley Strangler"); + checkAttackers("x1 attacker", 1, playerA, "Alley Strangler"); + checkBlockers("x2 blockers", 1, playerB, "Memnite", "Memnite"); + + setStrictChooseMode(true); + setStopAt(1, PhaseStep.END_TURN); + execute(); + + assertLife(playerB, 20); + assertGraveyardCount(playerA, "Alley Strangler", 0); + } + + @Test + @Ignore // TODO: need improve of block configuration auto-fix (block by x2 instead x1) + public void test_MustBeBlockedWithMenace_many_blockers() { + // At the beginning of combat on your turn, you may pay {2}{R/G}. If you do, double target creature’s + // power until end of turn. That creature must be blocked this combat if able. + addCard(Zone.BATTLEFIELD, playerA, "Neyith of the Dire Hunt"); // 3/3 + addCard(Zone.BATTLEFIELD, playerA, "Forest", 3); + // + // Menace + addCard(Zone.BATTLEFIELD, playerA, "Alley Strangler", 1); // 2/3 + // + addCard(Zone.BATTLEFIELD, playerB, "Memnite", 10); // 1/1 + + // If the target creature has menace, two creatures must block it if able. + // (2020-06-23) + + addTarget(playerA, "Alley Strangler"); // boost target + setChoice(playerA, true); // boost target + attack(1, playerA, "Alley Strangler"); + checkAttackers("x1 attacker", 1, playerA, "Alley Strangler"); + checkBlockers("x2 blockers", 1, playerB, "Memnite", "Memnite"); + + setStrictChooseMode(true); + setStopAt(1, PhaseStep.END_TURN); + execute(); + + assertLife(playerB, 20); + assertGraveyardCount(playerA, "Alley Strangler", 0); + } + + @Test + @Ignore + // TODO: need auto-fix cause AI use it (it must ignore bad blocker configuration and allow to pass without blockers at all) + public void test_MustBeBlockedWithMenace_low_blockers_auto() { + // At the beginning of combat on your turn, you may pay {2}{R/G}. If you do, double target creature’s + // power until end of turn. That creature must be blocked this combat if able. + addCard(Zone.BATTLEFIELD, playerA, "Neyith of the Dire Hunt"); // 3/3 + addCard(Zone.BATTLEFIELD, playerA, "Forest", 3); + // + // Menace + addCard(Zone.BATTLEFIELD, playerA, "Alley Strangler", 1); // 2/3 + // + addCard(Zone.BATTLEFIELD, playerB, "Memnite", 1); // 1/1 + + // If the target creature has menace, two creatures must block it if able. + // (2020-06-23) + // + // If a creature is required to block a creature with menace, another creature must also block that creature + // if able. If none can, the creature that’s required to block can block another creature or not block at all. + // (2020-04-17) + + // auto-fix block config inside + + addTarget(playerA, "Alley Strangler"); // boost target + setChoice(playerA, true); // boost target + attack(1, playerA, "Alley Strangler"); + checkAttackers("x1 attacker", 1, playerA, "Alley Strangler"); + checkBlockers("no blockers", 1, playerB, ""); + + setStrictChooseMode(true); + setStopAt(1, PhaseStep.END_TURN); + execute(); + + assertLife(playerB, 20 - 4); + assertGraveyardCount(playerA, "Alley Strangler", 0); + } + + @Test + @Ignore + // TODO: need exception fix java.lang.UnsupportedOperationException: Alley Strangler is blocked by 1 creature(s). It has to be blocked by 2 or more. + // It's ok to have such exception in unit tests from manual setup + // If it's impossible to auto-fix, then keep that error and ignore the test + public void test_MustBeBlockedWithMenace_low_blockers_manual() { + // At the beginning of combat on your turn, you may pay {2}{R/G}. If you do, double target creature’s + // power until end of turn. That creature must be blocked this combat if able. + addCard(Zone.BATTLEFIELD, playerA, "Neyith of the Dire Hunt"); // 3/3 + addCard(Zone.BATTLEFIELD, playerA, "Forest", 3); + // + // Menace + addCard(Zone.BATTLEFIELD, playerA, "Alley Strangler", 1); // 2/3 + // + addCard(Zone.BATTLEFIELD, playerB, "Memnite", 1); // 1/1 + + // If the target creature has menace, two creatures must block it if able. + // (2020-06-23) + // + // If a creature is required to block a creature with menace, another creature must also block that creature + // if able. If none can, the creature that’s required to block can block another creature or not block at all. + // (2020-04-17) + + // define blocker manual + + addTarget(playerA, "Alley Strangler"); // boost target + setChoice(playerA, true); // boost target + attack(1, playerA, "Alley Strangler"); + block(1, playerB, "Memnite", "Alley Strangler"); + checkAttackers("x1 attacker", 1, playerA, "Alley Strangler"); + checkBlockers("no blockers", 1, playerB, ""); + + setStrictChooseMode(true); + setStopAt(1, PhaseStep.END_TURN); + execute(); + + assertLife(playerB, 20 - 4); + assertGraveyardCount(playerA, "Alley Strangler", 0); + } +} \ No newline at end of file diff --git a/Mage/src/main/java/mage/game/combat/Combat.java b/Mage/src/main/java/mage/game/combat/Combat.java index b62a906a966..21a534f1786 100644 --- a/Mage/src/main/java/mage/game/combat/Combat.java +++ b/Mage/src/main/java/mage/game/combat/Combat.java @@ -893,7 +893,7 @@ public class Combat implements Serializable, Copyable { Map minNumberOfBlockersMap = new HashMap<>(); Map minPossibleBlockersMap = new HashMap<>(); - // check mustBlock requirements of creatures from opponents of attacking player + // FIND attackers and potential blockers for "must be blocked" effects for (Permanent creature : game.getBattlefield().getActivePermanents(StaticFilters.FILTER_PERMANENT_CREATURES_CONTROLLED, player.getId(), game)) { // creature is controlled by an opponent of the attacker if (opponents.contains(creature.getControllerId())) { @@ -1012,7 +1012,7 @@ public class Combat implements Serializable, Copyable { if (toBeBlockedCreature != null) { CombatGroup toBeBlockedGroup = findGroup(toBeBlockedCreature); if (toBeBlockedGroup != null && toBeBlockedGroup.getDefendingPlayerId().equals(creature.getControllerId())) { - minNumberOfBlockersMap.put(toBeBlockedCreature, effect.getMinNumberOfBlockers()); + minNumberOfBlockersMap.put(toBeBlockedCreature, effect.getMinNumberOfBlockers()); // TODO: fail on multiple effects 1 + 2 min blockers? Permanent toBeBlockedCreaturePermanent = game.getPermanent(toBeBlockedCreature); if (toBeBlockedCreaturePermanent != null) { minPossibleBlockersMap.put(toBeBlockedCreature, toBeBlockedCreaturePermanent.getMinBlockedBy()); @@ -1096,12 +1096,10 @@ public class Combat implements Serializable, Copyable { } } - } - } - // check if for attacking creatures with mustBeBlockedByAtLeastX requirements are fulfilled + // APPLY potential blockers to attackers with "must be blocked" effects for (UUID toBeBlockedCreatureId : mustBeBlockedByAtLeastX.keySet()) { for (CombatGroup combatGroup : game.getCombat().getGroups()) { if (combatGroup.getAttackers().contains(toBeBlockedCreatureId)) { @@ -1124,6 +1122,8 @@ public class Combat implements Serializable, Copyable { if (!requirementFulfilled) { // creature is not blocked but has possible blockers if (controller.isHuman()) { + // HUMAN logic - send warning about wrong blocker config and repeat declare + // TODO: replace isHuman by !isComputer for working unit tests Permanent toBeBlockedCreature = game.getPermanent(toBeBlockedCreatureId); if (toBeBlockedCreature != null) { // check if all possible blocker block other creatures they are forced to block @@ -1142,9 +1142,8 @@ public class Combat implements Serializable, Copyable { } } } - } else { - // take the first potential blocker from the set to block for the AI + // AI logic - auto-fix wrong blocker config (take the first potential blocker) for (UUID possibleBlockerId : mustBeBlockedByAtLeastX.get(toBeBlockedCreatureId)) { String blockRequiredMessage = isCreatureDoingARequiredBlock( possibleBlockerId, toBeBlockedCreatureId, mustBeBlockedByAtLeastX, game); @@ -1167,8 +1166,8 @@ public class Combat implements Serializable, Copyable { } } } - } + // check if creatures are forced to block but do not block at all or block creatures they are not forced to block StringBuilder sb = new StringBuilder(); for (Map.Entry> entry : creatureMustBlockAttackers.entrySet()) { -- 2.47.2 From bd1f6a4ca7daf6a8670777ca114399c42deb2757 Mon Sep 17 00:00:00 2001 From: Oleg Agafonov Date: Tue, 31 Dec 2024 22:17:56 +0400 Subject: [PATCH 05/32] tests: enabled test after #13182 --- .../java/org/mage/test/combat/AttackBlockRestrictionsTest.java | 2 -- 1 file changed, 2 deletions(-) diff --git a/Mage.Tests/src/test/java/org/mage/test/combat/AttackBlockRestrictionsTest.java b/Mage.Tests/src/test/java/org/mage/test/combat/AttackBlockRestrictionsTest.java index 9b759bc2784..e60aa31d41c 100644 --- a/Mage.Tests/src/test/java/org/mage/test/combat/AttackBlockRestrictionsTest.java +++ b/Mage.Tests/src/test/java/org/mage/test/combat/AttackBlockRestrictionsTest.java @@ -873,8 +873,6 @@ public class AttackBlockRestrictionsTest extends CardTestPlayerBase { } @Test - @Ignore - // TODO: need auto-fix cause AI use it (it must ignore bad blocker configuration and allow to pass without blockers at all) public void test_MustBeBlockedWithMenace_low_blockers_auto() { // At the beginning of combat on your turn, you may pay {2}{R/G}. If you do, double target creature’s // power until end of turn. That creature must be blocked this combat if able. -- 2.47.2 From 7a1d22d45949051b740f7773b46f06daefb06d70 Mon Sep 17 00:00:00 2001 From: Oleg Agafonov Date: Tue, 31 Dec 2024 22:33:10 +0400 Subject: [PATCH 06/32] merge fix --- Mage/src/main/java/mage/game/combat/Combat.java | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Mage/src/main/java/mage/game/combat/Combat.java b/Mage/src/main/java/mage/game/combat/Combat.java index 21a534f1786..38c300969ca 100644 --- a/Mage/src/main/java/mage/game/combat/Combat.java +++ b/Mage/src/main/java/mage/game/combat/Combat.java @@ -721,7 +721,7 @@ public class Combat implements Serializable, Copyable { // TODO: wtf, AI will freeze forever here in games with attacker/blocker restrictions, // but it pass in some use cases due random choices. AI must deside blocker configuration // in one attempt - throw new IllegalStateException("AI can't find good blocker configuration, report it to github"); + //throw new IllegalStateException("AI can't find good blocker configuration, report it to github"); } } -- 2.47.2 From 7e0c9bb5c59fe2c7a3fa292627e05f8877ab1027 Mon Sep 17 00:00:00 2001 From: Oleg Agafonov Date: Thu, 2 Jan 2025 02:11:23 +0400 Subject: [PATCH 07/32] images: fixed symbols download from gatherer website, removed custom cacert key storage (close #13159, close #13157, related to #9266) --- Mage.Client/release/cacerts | Bin 104792 -> 0 bytes .../src/main/java/mage/client/MageFrame.java | 50 +++++++++++++----- .../client/remote/XmageURLConnection.java | 6 +++ .../java/mage/client/util/DownloaderTest.java | 17 ++++++ 4 files changed, 60 insertions(+), 13 deletions(-) delete mode 100644 Mage.Client/release/cacerts diff --git a/Mage.Client/release/cacerts b/Mage.Client/release/cacerts deleted file mode 100644 index f50115666131d74b50ce9a71cb94362b2042b2ea..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 104792 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%0D?B6o_B51?-{fO10d-Ia#+qAi5Ps6 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°_ zc{_u5N(ejx5h^uxW)q)PYFsrr(?k|Xzw)!{2a_vRce2%e!Dh~RlndwSFU|=Q^2ME_ zGw$xa*V(un3r=?`v^Bp^te}~Adi48dlvmJm-LsEK(W+snUVEd2k)T5NaCj2~?Xv<@ z9mZ2;!(l-S5igX{lLs!4p=l3rWTt9k&LtqjU@iCb-o#jzL*+t+HkC{G(2tKCnLpyV z>Rc2A@&C2$=l>w7w;8R;4N<-uog9xmvU=oMh`WS|@rNB;l;|4gwGHJdcaY)FH;0hT z=buTD3yRX%)!9r1ntsghK{?q?hFx&Oid{_;_DrG=S~x!;Yth-9zoSv@Qt(0#iu!!4 zb6{=r>q1dnK?!o*Q~DmL z(&2HAsxHM8?JQ@4+w2D{K}J4M=fQpJAQ5G#R|f#?}3 z(^Q<`4ZTMvBCeajp!!k8E>e0i{;0_T^?rocDQ#xI?z+1nSiB57DYuV#>>52pUU&avgA)=dHGO{yzdZ@1b?;->0^IoO zK9r}&QFsN3QUmE{`l60xHTdiAc3HM~Btv5;T~u+3Kkgh@U>WlqXTPnr7wV)TYHCwg zIWjWo|3QR`vuhRdKn`zs9wq8!npW&*S4u^}4*KKq>eR~7Z~fy-_z~C+lEu|4pEN^< zW14Wv$CryV4OHI~)qZICFjGS+8_oMH>}6(8)6kwvj!V3a`UTn&xj5SbeOSOKb4Mp< zbD&$x&A`#*cfWB<;wRw3JOcbiX8>@uF98=#v=KQ%5rVP;eTb9(%mnfDk97v}Px#%3 zL;`eM0Q7~&gi=#wxza|5!$egvuy!VuHLx-v6#;U?ELVych?v;mfh%e%V#=z@YAUMN zS*7cNEJ&EwGvC0DC-_rb_`P7_&rd+d#8Fd~X9QnD9Q+9xC9ggr7|<_Bo31|N+ znY-k>Is<2ncw#GhUu`2Fza5)i?yU9P5O#rH-{07xz?g-N;w{mZvrJYPe{s z<@lS)ds}5XRe3hYUmb9*vOnzYJ<3?;7$D)eZChXJQq+s3cq+DZfubiD z7(9lwj@g7rGDi||>mwewh7IXtM!hL4+>5dZg>63yBl`iLX2FEOmO_Uje)~y}6#Hun zrG`V;Uh`s8*?NH@g9gM<)c4c;8(uK7X^6U3v7;NE>$H)tp7CiigRNp9 zQLqDe2ZkR6%>By*=Kh7SU9rxi;(jeaSLMXK-j%XB3adZl##!{K|n_87istf?e* zX$PkH7hpwrIn)#^$O#O?y>t#8Iet2a>hC0Xlzw6rAXXdz1?0;hbFQ32eNCya)J%11 zhlpDK!X&($|9By}3Cz3w=>{^ADsx=VYDdIG{B_p#i_kY; zyx@tk0y@lWzfx9Uo){0%xXljYdIWPm{~B6OQBd?IHE_jP#@1+)YXwk3QW~< zrOQO1CbKkz0yzv;Mr*I!n|Xu~obU4V-h1s+$?qu6+rwfj{mg5By9z;+t)HzgEFHUykPno4eOIm0}=*35Y6Jb5%$pJ z5JCRpMB|_Bm@fW?YJ|1&P(6WNl-R}wb{r^ zT+^pjW_eaoCQh1lOmffu`?EyvCrR&Jr9>P#H7b(>M3;$_vRc+Ci-ml zm#T>Wm&f}BO`7a7ZSD= z+x#yXNea$#{$YMHUy>8(w3!?Ig=?OiuXyXjruUBV4>4t9*XMtuUa)KZ{1%#QB?D*c zu4?7QW=84^Bsxq0TE^w3cCnj*?Vt5G?^{Z3=O_L^cLLbc3hZjHF7fAg@fR@q_y_zB zKUchFL}YSR#%odrkVrLg1bVKVe$A-81f}a_ERX~FdGaM@i2zAm6US?^2Q)H~DkG34 zF#)(7q?eoE4fML?Z~#6vu+@P8RvoyNl?|BV4a|$b=12c;c*uXkxI(=NLcJxY*o6u0 zovd#X3qQ=&^luSE<&>vr{vZi2WiLKKc{eVJzhM#GMS4P32#3G^TD!=kQ*Ry0%hQXI zaJ06@?;TzMPSBC*YxoXT3jg`u8Y#g&1zRpjTJGEJjf>{?r#uOAgPn=7iiK^W zuh6{;B?%8E^f?LisWj96h!IuOqd>J4uXl7|gv)m2A7^g`bukM(3O2Ou(rbBS)kK& z{{fi)cfkC|uFU^03+vQ25{b=>G&2BgnjM^oR1Z zyN&n>dQqV&Wp@O-`!Yx|ZRmL4-}dI)w{qn+8t2r{Ptnze@O#6o>AUy%kV+(E(SlGp zIcCOT?h_-5yxrb=sqv~G1nMOWRnAzeeaF;u1hvHROqTZ^JP(slKc~ukX0H-B5#)^3 zYL*UT&O03?^pIFuzE33f-lF;_gnhU2hNpK4?_xd6q1fI#{XR@hC`b*N&~MqMC05IKay^e$3oS{)W0ILsQi}Qjq*J%iV*~&2r7z-M8P41wH;XU#@H5OP;O$Tix=w>W>wV(%@BKt5%$TA7 zrIWyXe84tjCX}KNb4sLdmc1M%?GD{Y(}C{$k(P#yr*^E)+pz)64FUH(is`FKz2)jX z)G*#*uQcpi#xYqtXY>(OY_~&Z2lqP)6g#~_N^wE95vz`RUKGz&nb{wWErB|>6!<0j z^t&*mTMtcvVKCb^7yNgJJ~!B@_xQx2LVe!i#OEZPGat_>COeKI)#MAH7A4Td1$Isw zJn5+G3JeUnXsa0M_s~L&yX^A@i>vAE-B@lAkyeZ>qpiJw3W94Tn;WjeO=U!>knvwAQ4XL)DS#3QmneY-fA6b$ z%zg2)7cQ1vnz5a~0Wz2$X7D>^Tt_UJ$Uo(10PAPL2jN}@$7tYO+x7EYq}oV9B$v2= z`|~F*y@Kn&`67e?YHe4x|5w$T|G*>slM&C}n?ZM+%Fh|f)+kiWibisa>B@S!f$=e3 z3lf2}2l82-Jg8b_U!=_fLL+T9lKjhKSRn|1gpVHy1DmFM?>UIij+%DfDtD#9iS|q< ziSyp5mdu8q7%lZc=7BZwmDagak9+W{af5aY;9NgS(hN4Y)uv#TX_9YVdT29e-{gkY zbON~-Jf1I#_JFm(1#e^^t5IT>#&Rc_?LZ1%KVn#~f;Xt?MWM8;w?LnQp0%rPrVLkX z`e*WQHXpGFqAa`5ddsnlaXYo>(pj6fH;#so?hn7J7G|A59{zsdKT;R@K(LZJscACRpx-OQdPBy3H1!#|+* zThHX*p71v`uiYz^F^<0XwtJMvG%jCZWA>=D>yeAnTOLK%5F;2Lf9IKnp4?{GCC5> zo2)RMHZ&a_uoaHnW~^Lslg>zcFw3CXEJU%+NE;v~;I8_VI`ONYWIosT%~5xKoT;KE z)Lz>nQCx0jsfige0s0zFl|EiY*nOs@HVr9j12Vi3nBqr#cX|D+@>WZ+v6erDwbXE1 z&&FDZpBvY>QE7mh2`RH@J}*y8cvV(V!rua|F*YDIi8U$K6#Q6Cgv2O*AKH<~t`~{O z*Rebj7iTkdVdePO+NTaK2G&4KW@7w%Bv5Ad)5u|f+!mM%1`YdnkpS4L&2Cure^vhU z*9e-Q@~2AIj|R?BfW7_;+rCmfWoN%AjDicKU_I1}!~fia{F8;xlO&&gMtJx*CqtqU zU9U8VebSK6{ZaKYsB#UNh`tTy{Og*-YWOa??YaU=>>vgnqZBseyBu=NPITjQa*X7f zC~ao=J_pNkGf2%X9q}KpAHCc|z3(ySOW4!DSxtLNzmi`5x(253bg#3b>@9uf@q;b= zOgnoj9{~$#-`nJ$z8AxzC(|X(6pD%^W9SN`*(jeZX7ZaeZ_3zvVR9GOV^eL7etebH z%reUS|7`w)|00opS|;CYukit15jy{(uHX*|E312|H@{zYc)Ns6=!o^hi3h_>KZx8( zHRH@%y@dU^>BU3L#%9r_YPn7yg|eOVy$h%OGLd~NoOi0BeujR|&eR((AvwJ9ib3N= zq;1cZQ@fM8V@_Ua*cHJ)-CmT#`OJ*v$fM?LnByCb6F~zOz#~vQ4I0}TM6j8#FndWR z-D188wI2*yS}M7^^wp2<)WoU6yE0kcx1ADRaD0`2inP7Nw-vfNq5Z&{`Cd6?$sSM* z)Lp)xXYMO@mXf5MZG}c%#)Afmz}a}O7>Xwh{MBf z)u&CZNz1k@zq!|ZPR3TxMhAzrGbkm0wCedp@F*!716k((;1KhNe76$rpDY9PEg?C|5XHUd5o~soaWt tHtOc}&FkispZ{R6@xS-X7snRLy?w2b*xDs@?Q~77s;1+&t4ynR0RV@mE6)G` diff --git a/Mage.Client/src/main/java/mage/client/MageFrame.java b/Mage.Client/src/main/java/mage/client/MageFrame.java index 07ce7d54e7d..cfcf25b24f2 100644 --- a/Mage.Client/src/main/java/mage/client/MageFrame.java +++ b/Mage.Client/src/main/java/mage/client/MageFrame.java @@ -26,6 +26,7 @@ import mage.client.plugins.adapters.MageActionCallback; import mage.client.plugins.impl.Plugins; import mage.client.preference.MagePreferences; import mage.client.remote.CallbackClientImpl; +import mage.client.remote.XmageURLConnection; import mage.client.table.TablesPane; import mage.client.table.TablesPanel; import mage.client.tournament.TournamentPane; @@ -54,6 +55,7 @@ import net.java.truevfs.access.TArchiveDetector; import net.java.truevfs.access.TConfig; import net.java.truevfs.kernel.spec.FsAccessOption; import org.apache.log4j.Logger; +import org.junit.Assert; import org.mage.card.arcane.ManaSymbols; import org.mage.card.arcane.SvgUtils; import org.mage.plugins.card.images.DownloadPicturesService; @@ -196,19 +198,6 @@ public class MageFrame extends javax.swing.JFrame implements MageClient { } public MageFrame() throws MageException { - File cacertsFile = new File(System.getProperty("user.dir") + "/release/cacerts").getAbsoluteFile(); - if (!cacertsFile.exists()) { // When running from the jar file the contents of the /release folder will have been expanded into the home folder as part of packaging - cacertsFile = new File(System.getProperty("user.dir") + "/cacerts").getAbsoluteFile(); - } - if (cacertsFile.exists()) { - LOGGER.info("Custom (or bundled) Java certificate file (cacerts) file found"); - String cacertsPath = cacertsFile.getPath(); - System.setProperty("javax.net.ssl.trustStore", cacertsPath); - System.setProperty("javax.net.ssl.trustStorePassword", "changeit"); - } else { - LOGGER.info("custom Java certificate file not found at: " + cacertsFile.getAbsolutePath()); - } - setWindowTitle(); // mac os only: enable full screen support in java 8 (java 11+ try to use it all the time) @@ -403,6 +392,41 @@ public class MageFrame extends javax.swing.JFrame implements MageClient { }); } + /** + * Init certificates store for https work (if java version is outdated) + * Debug with -Djavax.net.debug=SSL,trustmanager + */ + @Deprecated // TODO: replaced by enableAIAcaIssuers, delete that code after few releases (2025-01-01) + private void initSSLCertificates() { + // from dev build (runtime) + boolean cacertsUsed = false; + File cacertsFile = new File(System.getProperty("user.dir") + "/release/cacerts").getAbsoluteFile(); + if (cacertsFile.exists()) { + cacertsUsed = true; + LOGGER.info("SSL certificates: used runtime cacerts bundle"); + } + + // from release build (jar) + // When running from the jar file the contents of the /release folder will have been expanded into the home folder as part of packaging + if (!cacertsUsed) { + cacertsFile = new File(System.getProperty("user.dir") + "/cacerts").getAbsoluteFile(); + if (cacertsFile.exists()) { + cacertsUsed = true; + LOGGER.info("SSL certificates: used release cacerts bundle"); + } + } + + if (cacertsUsed && cacertsFile.exists()) { + String cacertsPath = cacertsFile.getPath(); + System.setProperty("javax.net.ssl.trustStoreType", "PKCS12"); // cacerts file format from java 9+ instead "jks" from java 8 + System.setProperty("javax.net.ssl.trustStore", cacertsPath); + System.setProperty("javax.net.ssl.trustStorePassword", "changeit"); + } else { + LOGGER.info("SSL certificates: used default cacerts bundle from " + System.getProperty("java.version")); + } + System.setProperty("com.sun.security.enableAIAcaIssuers", "true"); + } + private void bootstrapSetsAndFormats() { LOGGER.info("Loading sets and formats..."); ConstructedFormats.ensureLists(); diff --git a/Mage.Client/src/main/java/mage/client/remote/XmageURLConnection.java b/Mage.Client/src/main/java/mage/client/remote/XmageURLConnection.java index 2c9e7443be8..fb4c0aeb44a 100644 --- a/Mage.Client/src/main/java/mage/client/remote/XmageURLConnection.java +++ b/Mage.Client/src/main/java/mage/client/remote/XmageURLConnection.java @@ -48,6 +48,12 @@ public class XmageURLConnection { private static final AtomicLong debugLastRequestTimeMs = new AtomicLong(0); private static final ReentrantLock debugLogsWriterlock = new ReentrantLock(); + static { + // add Authority Information Access (AIA) Extension support for certificates from Windows servers like gatherer website + // fix download errors like sun.security.provider.certpath.SunCertPathBuilderException: unable to find valid certification path to requested target + System.setProperty("com.sun.security.enableAIAcaIssuers", "true"); + } + final String url; Proxy proxy = null; HttpURLConnection connection = null; diff --git a/Mage.Client/src/test/java/mage/client/util/DownloaderTest.java b/Mage.Client/src/test/java/mage/client/util/DownloaderTest.java index 68034c47597..a26df5a73ec 100644 --- a/Mage.Client/src/test/java/mage/client/util/DownloaderTest.java +++ b/Mage.Client/src/test/java/mage/client/util/DownloaderTest.java @@ -60,4 +60,21 @@ public class DownloaderTest { Assert.assertNotNull(stream); Assert.assertTrue("must have image data", image.getWidth() > 0); } + + @Test + public void test_DownloadFromWindowsServers() throws IOException { + // symbols download from gatherer website + // error example: sun.security.provider.certpath.SunCertPathBuilderException: unable to find valid certification path to requested target + InputStream stream = XmageURLConnection.downloadBinary("https://gatherer.wizards.com/Handlers/Image.ashx?type=symbol&set=BIG&size=small&rarity=C"); + Assert.assertNotNull(stream); + BufferedImage image = null; + try { + image = ImageIO.read(stream); + } catch (IOException e) { + Assert.fail("Can't download image file due error: " + e); + } + Assert.assertNotNull(stream); + Assert.assertNotNull(image); + Assert.assertTrue("must have image data", image.getWidth() > 0); + } } -- 2.47.2 From 8941bb1ec03204cb94d3374c63ef1611f22f8d53 Mon Sep 17 00:00:00 2001 From: jam1garner Date: Thu, 2 Jan 2025 20:03:06 -0500 Subject: [PATCH 08/32] [PIP] Add Curie, Emergent Intelligence (#13175) --- .../cards/c/CurieEmergentIntelligence.java | 161 ++++++++++++++++++ Mage.Sets/src/mage/sets/Fallout.java | 1 + 2 files changed, 162 insertions(+) create mode 100644 Mage.Sets/src/mage/cards/c/CurieEmergentIntelligence.java diff --git a/Mage.Sets/src/mage/cards/c/CurieEmergentIntelligence.java b/Mage.Sets/src/mage/cards/c/CurieEmergentIntelligence.java new file mode 100644 index 00000000000..b61a5940a87 --- /dev/null +++ b/Mage.Sets/src/mage/cards/c/CurieEmergentIntelligence.java @@ -0,0 +1,161 @@ +package mage.cards.c; + +import java.util.UUID; + +import mage.abilities.Ability; +import mage.abilities.common.DealsCombatDamageToAPlayerTriggeredAbility; +import mage.abilities.common.SimpleActivatedAbility; +import mage.abilities.costs.Cost; +import mage.abilities.costs.common.ExileTargetCost; +import mage.abilities.costs.mana.ManaCostsImpl; +import mage.abilities.decorator.ConditionalOneShotEffect; +import mage.abilities.dynamicvalue.DynamicValue; +import mage.abilities.effects.Effect; +import mage.abilities.effects.common.DrawCardSourceControllerEffect; +import mage.abilities.effects.OneShotEffect; +import mage.cards.CardImpl; +import mage.cards.CardSetInfo; +import mage.constants.CardType; +import mage.constants.Duration; +import mage.constants.SubType; +import mage.constants.SuperType; +import mage.constants.Outcome; +import mage.filter.common.FilterControlledPermanent; +import mage.filter.predicate.mageobject.AnotherPredicate; +import mage.filter.predicate.permanent.TokenPredicate; +import mage.filter.predicate.Predicates; +import mage.game.Game; +import mage.game.permanent.Permanent; +import mage.game.permanent.PermanentCard; +import mage.MageInt; +import mage.MageObject; +import mage.target.common.TargetControlledPermanent; +import mage.util.functions.CopyApplier; + +/** + * + * @author jam1garner + */ +public final class CurieEmergentIntelligence extends CardImpl { + + private static final FilterControlledPermanent filter = + new FilterControlledPermanent("another nontoken artifact creature you control"); + + static { + filter.add(AnotherPredicate.instance); + filter.add(Predicates.and( + CardType.ARTIFACT.getPredicate(), + CardType.CREATURE.getPredicate() + )); + filter.add(TokenPredicate.FALSE); + } + + public CurieEmergentIntelligence(UUID ownerId, CardSetInfo setInfo) { + super(ownerId, setInfo, new CardType[]{CardType.ARTIFACT, CardType.CREATURE}, "{1}{U}"); + + this.supertype.add(SuperType.LEGENDARY); + this.subtype.add(SubType.ROBOT); + this.power = new MageInt(1); + this.toughness = new MageInt(3); + + // Whenever Curie, Emergent Intelligence deals combat damage to a player, draw cards equal to its base power. + this.addAbility(new DealsCombatDamageToAPlayerTriggeredAbility( + new DrawCardSourceControllerEffect(CurieEmergentIntelligenceValue.NON_NEGATIVE).setText("draw cards equal to its base power"), false + )); + + // {1}{U}, Exile another nontoken artifact creature you control: Curie becomes a copy of the exiled creature, except it has + // "Whenever this creature deals combat damage to a player, draw cards equal to its base power." + Ability ability = new SimpleActivatedAbility(new CurieEmergentIntelligenceCopyEffect(), new ManaCostsImpl<>("{1}{U}")); + ability.addCost(new ExileTargetCost(new TargetControlledPermanent(filter))); + this.addAbility(ability); + } + + private CurieEmergentIntelligence(final CurieEmergentIntelligence card) { + super(card); + } + + @Override + public CurieEmergentIntelligence copy() { + return new CurieEmergentIntelligence(this); + } +} + +enum CurieEmergentIntelligenceValue implements DynamicValue { + NON_NEGATIVE; + + @Override + public int calculate(Game game, Ability sourceAbility, Effect effect) { + Permanent sourcePermanent = sourceAbility.getSourcePermanentOrLKI(game); + if (sourcePermanent == null) { + return 0; + } + + // Minimum of 0 needed to account for Spinal Parasite + return Math.max(0, sourcePermanent.getPower().getModifiedBaseValue()); + } + + @Override + public CurieEmergentIntelligenceValue copy() { + return this; + } + + @Override + public String toString() { + return "X"; + } + + @Override + public String getMessage() { + return "{this}'s power"; + } +} + +class CurieEmergentIntelligenceCopyEffect extends OneShotEffect { + + private static final CopyApplier applier = new CopyApplier() { + @Override + public boolean apply(Game game, MageObject blueprint, Ability source, UUID targetObjectId) { + blueprint.getAbilities().add(new DealsCombatDamageToAPlayerTriggeredAbility( + new DrawCardSourceControllerEffect(CurieEmergentIntelligenceValue.NON_NEGATIVE).setText("draw cards equal to its base power"), false + )); + return true; + } + }; + + CurieEmergentIntelligenceCopyEffect() { + super(Outcome.Benefit); + this.setText("{this} becomes a copy of the exiled creature, except it has \"Whenever this creature deals combat damage to a player, draw cards equal to its base power.\""); + } + + private CurieEmergentIntelligenceCopyEffect(final CurieEmergentIntelligenceCopyEffect effect) { + super(effect); + } + + @Override + public CurieEmergentIntelligenceCopyEffect copy() { + return new CurieEmergentIntelligenceCopyEffect(this); + } + + @Override + public boolean apply(Game game, Ability source) { + for (Cost c : source.getCosts()) { + if (c.isPaid() && c instanceof ExileTargetCost) { + for (Permanent exiled : ((ExileTargetCost) c).getPermanents()) { + if (exiled != null) { + game.copyPermanent( + Duration.WhileOnBattlefield, + exiled, + source.getSourceId(), source, applier + ); + + return true; + } else { + return false; + } + } + } + } + + return false; + } +} diff --git a/Mage.Sets/src/mage/sets/Fallout.java b/Mage.Sets/src/mage/sets/Fallout.java index e3b4309e94e..6364801575f 100644 --- a/Mage.Sets/src/mage/sets/Fallout.java +++ b/Mage.Sets/src/mage/sets/Fallout.java @@ -103,6 +103,7 @@ public final class Fallout extends ExpansionSet { cards.add(new SetCardInfo("Crucible of Worlds", 357, Rarity.MYTHIC, mage.cards.c.CrucibleOfWorlds.class)); cards.add(new SetCardInfo("Crush Contraband", 158, Rarity.UNCOMMON, mage.cards.c.CrushContraband.class)); cards.add(new SetCardInfo("Cultivate", 196, Rarity.UNCOMMON, mage.cards.c.Cultivate.class)); + cards.add(new SetCardInfo("Curie, Emergent Intelligence", 30, Rarity.RARE, mage.cards.c.CurieEmergentIntelligence.class)); cards.add(new SetCardInfo("Darkwater Catacombs", 260, Rarity.RARE, mage.cards.d.DarkwaterCatacombs.class)); cards.add(new SetCardInfo("Deadly Dispute", 184, Rarity.COMMON, mage.cards.d.DeadlyDispute.class)); cards.add(new SetCardInfo("Desdemona, Freedom's Edge", 101, Rarity.RARE, mage.cards.d.DesdemonaFreedomsEdge.class, NON_FULL_USE_VARIOUS)); -- 2.47.2 From 7f7a6d6b83235f3adb4051ad3f07b9749610bd05 Mon Sep 17 00:00:00 2001 From: Shashakar Date: Thu, 2 Jan 2025 18:03:19 -0700 Subject: [PATCH 09/32] Fixed interaction between Bello and Ashaya (#13196) Co-authored-by: Dexton Armstrong --- .../mage/cards/b/BelloBardOfTheBrambles.java | 7 +- .../blb/BelloBardOfTheBramblesTest.java | 156 ++++++++++++++++++ 2 files changed, 157 insertions(+), 6 deletions(-) create mode 100644 Mage.Tests/src/test/java/org/mage/test/cards/single/blb/BelloBardOfTheBramblesTest.java diff --git a/Mage.Sets/src/mage/cards/b/BelloBardOfTheBrambles.java b/Mage.Sets/src/mage/cards/b/BelloBardOfTheBrambles.java index a8c4d0dff67..913f3190d0b 100644 --- a/Mage.Sets/src/mage/cards/b/BelloBardOfTheBrambles.java +++ b/Mage.Sets/src/mage/cards/b/BelloBardOfTheBrambles.java @@ -78,12 +78,7 @@ class BelloBardOfTheBramblesEffect extends ContinuousEffectImpl { "\"Whenever this creature deals combat damage to a player, draw a card.\""; this.dependendToTypes.add(DependencyType.EnchantmentAddingRemoving); // Enchanted Evening - this.dependendToTypes.add(DependencyType.AuraAddingRemoving); // Cloudform - this.dependendToTypes.add(DependencyType.BecomeForest); // Song of the Dryads - this.dependendToTypes.add(DependencyType.BecomeMountain); - this.dependendToTypes.add(DependencyType.BecomePlains); - this.dependendToTypes.add(DependencyType.BecomeSwamp); - this.dependendToTypes.add(DependencyType.BecomeIsland); + this.dependendToTypes.add(DependencyType.ArtifactAddingRemoving); // March of the Machines this.dependencyTypes.add(DependencyType.BecomeCreature); // Conspiracy } diff --git a/Mage.Tests/src/test/java/org/mage/test/cards/single/blb/BelloBardOfTheBramblesTest.java b/Mage.Tests/src/test/java/org/mage/test/cards/single/blb/BelloBardOfTheBramblesTest.java new file mode 100644 index 00000000000..1e3c2b49bfd --- /dev/null +++ b/Mage.Tests/src/test/java/org/mage/test/cards/single/blb/BelloBardOfTheBramblesTest.java @@ -0,0 +1,156 @@ +package org.mage.test.cards.single.blb; + +import mage.abilities.common.DealsCombatDamageToAPlayerTriggeredAbility; +import mage.abilities.effects.common.DrawCardSourceControllerEffect; +import mage.abilities.keyword.HasteAbility; +import mage.abilities.keyword.IndestructibleAbility; +import mage.abilities.mana.GreenManaAbility; +import mage.constants.CardType; +import mage.constants.PhaseStep; +import mage.constants.SubType; +import mage.constants.Zone; +import org.junit.Test; +import org.mage.test.serverside.base.CardTestPlayerBase; + +public class BelloBardOfTheBramblesTest extends CardTestPlayerBase { + + private static final String bello = "Bello, Bard of the Brambles"; + private static final String ashaya = "Ashaya, Soul of the Wild"; // Has type addition for lands + private static final String cityOnFire = "City on Fire"; // Is a 4+ cmc non-creature, non-aura enchantment + private static final String thranDynamo = "Thran Dynamo"; // Is a 4+ cmc non-creature, non-equipment artifact + private static final String aggravatedAssault = "Aggravated Assault"; // Is a 3 cmc non-creature, non-aura enchantment + private static final String abandonedSarcophagus = "Abandoned Sarcophagus"; // Is a 3 cmc non-creature, non-equipment artifact + private static final String bearUmbra = "Bear Umbra"; // Is a 4 cmc non-creature, aura enchantment + private static final String tangleweave = "Tangleweave Armor"; // Is a 4 cmc non-creature, equipment artifact + private static final String forest = "Forest"; + private static final String mountain = "Mountain"; + private static final String bear = "Grizzly Bears"; + + // During your turn, each non-Equipment artifact and non-Aura enchantment you control with mana value 4 or greater is a 4/4 Elemental creature in addition to its other types and has indestructible, haste, and "Whenever this creature deals combat damage to a player, draw a card." + // City on Fire should become a 4/4 Elemental creature with indestructible, haste, and "Whenever this creature deals combat damage to a player, draw a card." + // Thran Dynamo should become a 4/4 Elemental creature with indestructible, haste, and "Whenever this creature deals combat damage to a player, draw a card." + @Test + public void testBello() { + initBelloTest(); + + castSpell(1, PhaseStep.PRECOMBAT_MAIN, playerA, bello); + setStopAt(1, PhaseStep.END_TURN); + execute(); + + ensurePermanentHasBelloEffects(cityOnFire); + ensurePermanentHasBelloEffects(thranDynamo); + } + + // Ensures that overlapping land and creature type addition effects are handled properly + // This was an issue encountered between Ashaya and Bello + // While both were on the field, creatures weren't Forests, and artifacts and enchantements that met Bello's criteria weren't 4/4 Elementals + @Test + public void testBelloTypeAddition(){ + initBelloTest(); + addCard(Zone.BATTLEFIELD, playerA, ashaya); + + castSpell(1, PhaseStep.PRECOMBAT_MAIN, playerA, bello); + setStopAt(1, PhaseStep.END_TURN); + execute(); + + // Assert that Ashaya is not affected by Bello + assertType(ashaya, CardType.CREATURE, SubType.ELEMENTAL); + assertType(ashaya, CardType.LAND, SubType.FOREST); + assertAbility(playerA, ashaya, new GreenManaAbility(), true); + ensurePermanentDoesNotHaveBelloEffects(ashaya); + + // Assert that City on Fire is affected by Bello and Ashaya + assertType(cityOnFire, CardType.LAND, SubType.FOREST); + ensurePermanentHasBelloEffects(cityOnFire); + } + + // Ensures that Bello does not affect Equipment artifacts + // Tangleweave Armor should not be affected by Bello + @Test + public void testBelloEquipment(){ + initBelloTest(); + addCard(Zone.BATTLEFIELD, playerA, tangleweave); + + castSpell(1, PhaseStep.PRECOMBAT_MAIN, playerA, bello); + setStopAt(1, PhaseStep.END_TURN); + + execute(); + + // Assert that Equipment is not affected by Bello + ensurePermanentDoesNotHaveBelloEffects(tangleweave); + } + + // Ensures that Bello does not affect Aura enchantments + // Aura should not be affected by Bello + @Test + public void testBelloAura(){ + initBelloTest(); + addCard(Zone.BATTLEFIELD, playerA, bear); + addCard(Zone.HAND, playerA, bearUmbra); + + castSpell(1, PhaseStep.PRECOMBAT_MAIN, playerA, bearUmbra, bear); + castSpell(1, PhaseStep.POSTCOMBAT_MAIN, playerA, bello); + + setStopAt(1, PhaseStep.END_TURN); + execute(); + + // Assert that Aura is not affected by Bello + assertAttachedTo(playerA, bearUmbra, bear, true); + ensurePermanentDoesNotHaveBelloEffects(bearUmbra); + } + + // Ensures that Bello does not affect less-than-3 cmc non-creature, non-aura enchantments + // Aggravated Assault should not be affected by Bello + @Test + public void testBelloLessThanFourCmcEnchantment(){ + initBelloTest(); + addCard(Zone.BATTLEFIELD, playerA, aggravatedAssault); + + castSpell(1, PhaseStep.PRECOMBAT_MAIN, playerA, bello); + setStopAt(1, PhaseStep.END_TURN); + execute(); + + // Assert that Aggravated Assault is not affected by Bello + ensurePermanentDoesNotHaveBelloEffects(aggravatedAssault); + } + + // Ensures that Bello does not affect less-than-3 cmc non-creature, non-equipment artifacts + // Abandoned Sarcophagus should not be affected by Bello + @Test + public void testBelloLessThanFourCmcArtifact(){ + initBelloTest(); + addCard(Zone.BATTLEFIELD, playerA, abandonedSarcophagus); + + castSpell(1, PhaseStep.PRECOMBAT_MAIN, playerA, bello); + setStopAt(1, PhaseStep.END_TURN); + execute(); + // Assert that Abandoned Sarcophagus is not affected by Bello + ensurePermanentDoesNotHaveBelloEffects(abandonedSarcophagus); + } + + private void initBelloTest(){ + setStrictChooseMode(true); + addCard(Zone.BATTLEFIELD, playerA, forest, 10); + addCard(Zone.BATTLEFIELD, playerA, mountain, 10); + addCard(Zone.BATTLEFIELD, playerA, cityOnFire); + addCard(Zone.BATTLEFIELD, playerA, thranDynamo); + + addCard(Zone.HAND, playerA, bello); + } + + private void ensurePermanentHasBelloEffects(String permanentName){ + assertType(permanentName, CardType.CREATURE, SubType.ELEMENTAL); + assertPowerToughness(playerA, permanentName, 4, 4); + assertAbility(playerA, permanentName, IndestructibleAbility.getInstance(), true); + assertAbility(playerA, permanentName, HasteAbility.getInstance(), true); + assertAbility(playerA, permanentName, new DealsCombatDamageToAPlayerTriggeredAbility(new DrawCardSourceControllerEffect(1), false), true); + } + + private void ensurePermanentDoesNotHaveBelloEffects(String permanentName){ + assertAbility(playerA, permanentName, IndestructibleAbility.getInstance(), false); + assertAbility(playerA, permanentName, HasteAbility.getInstance(), false); + assertAbility(playerA, permanentName, new DealsCombatDamageToAPlayerTriggeredAbility(new DrawCardSourceControllerEffect(1), false), false); + } + + +} -- 2.47.2 From 2a7d527c64172af9d06439683544ada4e70a04e8 Mon Sep 17 00:00:00 2001 From: tiera3 <87589219+tiera3@users.noreply.github.com> Date: Fri, 3 Jan 2025 11:03:27 +1000 Subject: [PATCH 10/32] Fix name [DA1] More of That Strange Oil... (#13183) --- Mage.Sets/src/mage/sets/UnknownEvent.java | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Mage.Sets/src/mage/sets/UnknownEvent.java b/Mage.Sets/src/mage/sets/UnknownEvent.java index da1fd1f58c7..776949d124a 100644 --- a/Mage.Sets/src/mage/sets/UnknownEvent.java +++ b/Mage.Sets/src/mage/sets/UnknownEvent.java @@ -21,6 +21,6 @@ public final class UnknownEvent extends ExpansionSet { this.hasBasicLands = false; this.hasBoosters = false; - cards.add(new SetCardInfo("More of That Strange Oil", "CU13", Rarity.COMMON, mage.cards.m.MoreOfThatStrangeOil.class)); + cards.add(new SetCardInfo("More of That Strange Oil...", "CU13", Rarity.COMMON, mage.cards.m.MoreOfThatStrangeOil.class)); } } -- 2.47.2 From 48117b96204a6fb73007bf43ac1c904430136689 Mon Sep 17 00:00:00 2001 From: Cameron Merkel <44722506+Cguy7777@users.noreply.github.com> Date: Thu, 2 Jan 2025 19:03:35 -0600 Subject: [PATCH 11/32] [DSC] Implement Phenomenon Investigators (#13184) --- .../mage/cards/p/PhenomenonInvestigators.java | 120 ++++++++++++++++++ .../sets/DuskmournHouseOfHorrorCommander.java | 1 + .../token/HorrorEnchantmentCreatureToken.java | 30 +++++ 3 files changed, 151 insertions(+) create mode 100644 Mage.Sets/src/mage/cards/p/PhenomenonInvestigators.java create mode 100644 Mage/src/main/java/mage/game/permanent/token/HorrorEnchantmentCreatureToken.java diff --git a/Mage.Sets/src/mage/cards/p/PhenomenonInvestigators.java b/Mage.Sets/src/mage/cards/p/PhenomenonInvestigators.java new file mode 100644 index 00000000000..366fe7c8fa9 --- /dev/null +++ b/Mage.Sets/src/mage/cards/p/PhenomenonInvestigators.java @@ -0,0 +1,120 @@ +package mage.cards.p; + +import java.util.UUID; + +import mage.MageInt; +import mage.abilities.Ability; +import mage.abilities.common.AsEntersBattlefieldAbility; +import mage.abilities.common.DiesCreatureTriggeredAbility; +import mage.abilities.condition.common.ModeChoiceSourceCondition; +import mage.abilities.costs.Cost; +import mage.abilities.costs.CostImpl; +import mage.abilities.decorator.ConditionalTriggeredAbility; +import mage.abilities.effects.common.ChooseModeEffect; +import mage.abilities.effects.common.CreateTokenEffect; +import mage.abilities.effects.common.DoIfCostPaid; +import mage.abilities.effects.common.DrawCardSourceControllerEffect; +import mage.abilities.triggers.BeginningOfEndStepTriggeredAbility; +import mage.constants.*; +import mage.cards.CardImpl; +import mage.cards.CardSetInfo; +import mage.filter.FilterPermanent; +import mage.filter.StaticFilters; +import mage.filter.common.FilterNonlandPermanent; +import mage.game.Game; +import mage.game.permanent.Permanent; +import mage.game.permanent.token.HorrorEnchantmentCreatureToken; +import mage.players.Player; +import mage.target.TargetPermanent; + +/** + * @author Cguy7777 + */ +public final class PhenomenonInvestigators extends CardImpl { + + public PhenomenonInvestigators(UUID ownerId, CardSetInfo setInfo) { + super(ownerId, setInfo, new CardType[]{CardType.CREATURE}, "{2}{U}{B}"); + + this.subtype.add(SubType.HUMAN); + this.subtype.add(SubType.DETECTIVE); + this.power = new MageInt(3); + this.toughness = new MageInt(4); + + // As Phenomenon Investigators enters, choose Believe or Doubt. + this.addAbility(new AsEntersBattlefieldAbility( + new ChooseModeEffect("Believe or Doubt?", "Believe", "Doubt"))); + + // * Believe -- Whenever a nontoken creature you control dies, create a 2/2 black Horror enchantment creature token. + this.addAbility(new ConditionalTriggeredAbility( + new DiesCreatureTriggeredAbility( + new CreateTokenEffect(new HorrorEnchantmentCreatureToken()), + false, + StaticFilters.FILTER_CONTROLLED_CREATURE_NON_TOKEN), + new ModeChoiceSourceCondition("Believe"), + "&bull Believe — Whenever a nontoken creature you control dies, " + + "create a 2/2 black Horror enchantment creature token.")); + + // * Doubt -- At the beginning of your end step, you may return a nonland permanent you own to your hand. If you do, draw a card. + this.addAbility(new ConditionalTriggeredAbility( + new BeginningOfEndStepTriggeredAbility( + new DoIfCostPaid( + new DrawCardSourceControllerEffect(1), + new PhenomenonInvestigatorsReturnCost())), + new ModeChoiceSourceCondition("Doubt"), + "&bull Doubt — At the beginning of your end step, you may return a nonland permanent " + + "you own to your hand. If you do, draw a card.")); + } + + private PhenomenonInvestigators(final PhenomenonInvestigators card) { + super(card); + } + + @Override + public PhenomenonInvestigators copy() { + return new PhenomenonInvestigators(this); + } +} + +class PhenomenonInvestigatorsReturnCost extends CostImpl { + + private static final FilterPermanent filter = new FilterNonlandPermanent("a nonland permanent you own"); + + static { + filter.add(TargetController.YOU.getOwnerPredicate()); + } + + PhenomenonInvestigatorsReturnCost() { + this.addTarget(new TargetPermanent(filter).withNotTarget(true)); + text = "return a nonland permanent you own to your hand"; + } + + private PhenomenonInvestigatorsReturnCost(final PhenomenonInvestigatorsReturnCost cost) { + super(cost); + } + + @Override + public boolean pay(Ability ability, Game game, Ability source, UUID controllerId, boolean noMana, Cost costToPay) { + Player controller = game.getPlayer(controllerId); + if (controller != null) { + if (this.getTargets().choose(Outcome.ReturnToHand, controllerId, source.getSourceId(), source, game)) { + Permanent permanentToReturn = game.getPermanent(this.getTargets().getFirstTarget()); + if (permanentToReturn == null) { + return false; + } + controller.moveCards(permanentToReturn, Zone.HAND, ability, game); + paid = true; + } + } + return paid; + } + + @Override + public boolean canPay(Ability ability, Ability source, UUID controllerId, Game game) { + return this.getTargets().canChoose(controllerId, source, game); + } + + @Override + public PhenomenonInvestigatorsReturnCost copy() { + return new PhenomenonInvestigatorsReturnCost(this); + } +} diff --git a/Mage.Sets/src/mage/sets/DuskmournHouseOfHorrorCommander.java b/Mage.Sets/src/mage/sets/DuskmournHouseOfHorrorCommander.java index e03862fc9ad..5ad516da099 100644 --- a/Mage.Sets/src/mage/sets/DuskmournHouseOfHorrorCommander.java +++ b/Mage.Sets/src/mage/sets/DuskmournHouseOfHorrorCommander.java @@ -194,6 +194,7 @@ public final class DuskmournHouseOfHorrorCommander extends ExpansionSet { cards.add(new SetCardInfo("Oversimplify", 228, Rarity.RARE, mage.cards.o.Oversimplify.class)); cards.add(new SetCardInfo("Overwhelming Stampede", 192, Rarity.RARE, mage.cards.o.OverwhelmingStampede.class)); cards.add(new SetCardInfo("Persistent Constrictor", 22, Rarity.RARE, mage.cards.p.PersistentConstrictor.class)); + cards.add(new SetCardInfo("Phenomenon Investigators", 38, Rarity.RARE, mage.cards.p.PhenomenonInvestigators.class)); cards.add(new SetCardInfo("Ponder", 73, Rarity.COMMON, mage.cards.p.Ponder.class)); cards.add(new SetCardInfo("Portent", 74, Rarity.COMMON, mage.cards.p.Portent.class)); cards.add(new SetCardInfo("Primordial Mist", 123, Rarity.RARE, mage.cards.p.PrimordialMist.class)); diff --git a/Mage/src/main/java/mage/game/permanent/token/HorrorEnchantmentCreatureToken.java b/Mage/src/main/java/mage/game/permanent/token/HorrorEnchantmentCreatureToken.java new file mode 100644 index 00000000000..eb3af5f787c --- /dev/null +++ b/Mage/src/main/java/mage/game/permanent/token/HorrorEnchantmentCreatureToken.java @@ -0,0 +1,30 @@ +package mage.game.permanent.token; + +import mage.MageInt; +import mage.constants.CardType; +import mage.constants.SubType; + +/** + * @author Cguy7777 + */ +public class HorrorEnchantmentCreatureToken extends TokenImpl { + + public HorrorEnchantmentCreatureToken() { + super("Horror Token", "2/2 black Horror enchantment creature token"); + cardType.add(CardType.ENCHANTMENT); + cardType.add(CardType.CREATURE); + color.setBlack(true); + subtype.add(SubType.HORROR); + power = new MageInt(2); + toughness = new MageInt(2); + } + + private HorrorEnchantmentCreatureToken(final HorrorEnchantmentCreatureToken token) { + super(token); + } + + @Override + public HorrorEnchantmentCreatureToken copy() { + return new HorrorEnchantmentCreatureToken(this); + } +} -- 2.47.2 From fd4b82696b01db71822f06443decab0ec1870f8b Mon Sep 17 00:00:00 2001 From: jackd149 <67557084+jackd149@users.noreply.github.com> Date: Fri, 3 Jan 2025 04:03:51 +0300 Subject: [PATCH 12/32] [DSK] Implement Kaito, Bane of Nightmares (#13187) --- .../sources/ScryfallImageSupportTokens.java | 3 + .../mage/cards/k/KaitoBaneOfNightmares.java | 132 ++++++++++++++++++ .../src/mage/sets/DuskmournHouseOfHorror.java | 4 + .../emblems/KaitoBaneOfNightmaresEmblem.java | 31 ++++ .../common/PlayerLostLifeWatcher.java | 13 ++ Mage/src/main/resources/tokens-database.txt | 1 + 6 files changed, 184 insertions(+) create mode 100644 Mage.Sets/src/mage/cards/k/KaitoBaneOfNightmares.java create mode 100644 Mage/src/main/java/mage/game/command/emblems/KaitoBaneOfNightmaresEmblem.java diff --git a/Mage.Client/src/main/java/org/mage/plugins/card/dl/sources/ScryfallImageSupportTokens.java b/Mage.Client/src/main/java/org/mage/plugins/card/dl/sources/ScryfallImageSupportTokens.java index 0b091bd16fb..573b54094e2 100644 --- a/Mage.Client/src/main/java/org/mage/plugins/card/dl/sources/ScryfallImageSupportTokens.java +++ b/Mage.Client/src/main/java/org/mage/plugins/card/dl/sources/ScryfallImageSupportTokens.java @@ -2457,6 +2457,9 @@ public class ScryfallImageSupportTokens { // BLC put("BLC/Raccoon", "https://api.scryfall.com/cards/tblc/29/en?format=image"); + // DSK + put("DSK/Emblem Kaito", "https://api.scryfall.com/cards/tdsk/17/en?format=image"); + // FDN put("FDN/Beast/1", "https://api.scryfall.com/cards/tfdn/32/en?format=image"); put("FDN/Beast/2", "https://api.scryfall.com/cards/tfdn/33/en?format=image"); diff --git a/Mage.Sets/src/mage/cards/k/KaitoBaneOfNightmares.java b/Mage.Sets/src/mage/cards/k/KaitoBaneOfNightmares.java new file mode 100644 index 00000000000..f947283d7f0 --- /dev/null +++ b/Mage.Sets/src/mage/cards/k/KaitoBaneOfNightmares.java @@ -0,0 +1,132 @@ +package mage.cards.k; + +import mage.abilities.Ability; +import mage.abilities.LoyaltyAbility; +import mage.abilities.common.SimpleStaticAbility; +import mage.abilities.condition.Condition; +import mage.abilities.condition.common.MyTurnCondition; +import mage.abilities.decorator.ConditionalContinuousEffect; +import mage.abilities.dynamicvalue.DynamicValue; +import mage.abilities.effects.Effect; +import mage.abilities.effects.common.DrawCardSourceControllerEffect; +import mage.abilities.effects.common.GetEmblemEffect; +import mage.abilities.effects.common.TapTargetEffect; +import mage.abilities.effects.common.continuous.BecomesCreatureSourceEffect; +import mage.abilities.effects.common.counter.AddCountersTargetEffect; +import mage.abilities.effects.keyword.SurveilEffect; +import mage.abilities.hint.common.MyTurnHint; +import mage.abilities.keyword.HexproofAbility; +import mage.abilities.keyword.NinjutsuAbility; +import mage.cards.CardImpl; +import mage.cards.CardSetInfo; +import mage.constants.*; +import mage.counters.CounterType; +import mage.game.Game; +import mage.game.command.emblems.KaitoBaneOfNightmaresEmblem; +import mage.game.permanent.Permanent; +import mage.game.permanent.token.custom.CreatureToken; +import mage.target.common.TargetCreaturePermanent; +import mage.watchers.common.PlayerLostLifeWatcher; + +import java.util.UUID; + +/** + * + * @author jackd149 + */ +public final class KaitoBaneOfNightmares extends CardImpl { + + public KaitoBaneOfNightmares(UUID ownerId, CardSetInfo setInfo) { + super(ownerId, setInfo, new CardType[]{CardType.PLANESWALKER}, "{2}{U}{B}"); + + this.supertype.add(SuperType.LEGENDARY); + this.subtype.add(SubType.KAITO); + this.setStartingLoyalty(4); + + // Ninjutsu {1}{U}{B} + this.addAbility(new NinjutsuAbility("{1}{U}{B}")); + + // During your turn, as long as Kaito has one or more loyalty counters on him, he's a 3/4 Ninja creature and has hexproof. + this.addAbility(new SimpleStaticAbility(new ConditionalContinuousEffect( + new BecomesCreatureSourceEffect( + new CreatureToken(3, 4, "3/4 Ninja creature") + .withSubType(SubType.NINJA) + .withAbility(HexproofAbility.getInstance()), null, Duration.WhileOnBattlefield + ), KaitoBaneOfNightmaresCondition.instance, "During your turn, as long as {this} has one or more loyalty counters on him, " + + "he's a 3/4 Ninja creature and has hexproof." + )).addHint(MyTurnHint.instance)); + + // +1: You get an emblem with "Ninjas you control get +1/+1." + this.addAbility(new LoyaltyAbility(new GetEmblemEffect(new KaitoBaneOfNightmaresEmblem()), 1)); + + // 0: Surveil 2. Then draw a card for each opponent who lost life this turn. + Ability ability = new LoyaltyAbility(new SurveilEffect(2), 0); + ability.addEffect(new DrawCardSourceControllerEffect(KaitoBaneOfNightmaresCount.instance)); + this.addAbility(ability, new PlayerLostLifeWatcher()); + + // -2: Tap target creature. Put two stun counters on it. + Ability minusTwoAbility = new LoyaltyAbility(new TapTargetEffect(), -2); + minusTwoAbility.addEffect(new AddCountersTargetEffect(CounterType.STUN.createInstance(2)) + .setText("Put two stun counters on it")); + minusTwoAbility.addTarget(new TargetCreaturePermanent()); + this.addAbility(minusTwoAbility); + } + + private KaitoBaneOfNightmares(final KaitoBaneOfNightmares card) { + super(card); + } + + @Override + public KaitoBaneOfNightmares copy() { + return new KaitoBaneOfNightmares(this); + } +} + +enum KaitoBaneOfNightmaresCondition implements Condition { + instance; + + @Override + public boolean apply(Game game, Ability source) { + if (!MyTurnCondition.instance.apply(game, source)){ + return false; + } + + Permanent permanent = game.getPermanent(source.getSourceId()); + + if (permanent == null) { + return false; + } + + int loyaltyCount = permanent.getCounters(game).getCount(CounterType.LOYALTY); + return loyaltyCount > 0; + + } +} + +enum KaitoBaneOfNightmaresCount implements DynamicValue { + instance; + + @Override + public int calculate(Game game, Ability sourceAbility, Effect effect) { + PlayerLostLifeWatcher watcher = game.getState().getWatcher(PlayerLostLifeWatcher.class); + if (watcher != null) { + return watcher.getNumberOfOpponentsWhoLostLife(sourceAbility.getControllerId(), game); + } + return 0; + } + + @Override + public KaitoBaneOfNightmaresCount copy() { + return instance; + } + + @Override + public String toString() { + return "1"; + } + + @Override + public String getMessage() { + return "opponent who lost life this turn."; + } +} diff --git a/Mage.Sets/src/mage/sets/DuskmournHouseOfHorror.java b/Mage.Sets/src/mage/sets/DuskmournHouseOfHorror.java index 28e8282694e..08420c6897e 100644 --- a/Mage.Sets/src/mage/sets/DuskmournHouseOfHorror.java +++ b/Mage.Sets/src/mage/sets/DuskmournHouseOfHorror.java @@ -131,6 +131,10 @@ public final class DuskmournHouseOfHorror extends ExpansionSet { cards.add(new SetCardInfo("Irreverent Gremlin", 142, Rarity.UNCOMMON, mage.cards.i.IrreverentGremlin.class)); cards.add(new SetCardInfo("Island", 273, Rarity.LAND, mage.cards.basiclands.Island.class, FULL_ART_BFZ_VARIOUS)); cards.add(new SetCardInfo("Jump Scare", 17, Rarity.COMMON, mage.cards.j.JumpScare.class)); + cards.add(new SetCardInfo("Kaito, Bane of Nightmares", 220, Rarity.MYTHIC, mage.cards.k.KaitoBaneOfNightmares.class, NON_FULL_USE_VARIOUS)); + cards.add(new SetCardInfo("Kaito, Bane of Nightmares", 328, Rarity.MYTHIC, mage.cards.k.KaitoBaneOfNightmares.class, NON_FULL_USE_VARIOUS)); + cards.add(new SetCardInfo("Kaito, Bane of Nightmares", 354, Rarity.MYTHIC, mage.cards.k.KaitoBaneOfNightmares.class, NON_FULL_USE_VARIOUS)); + cards.add(new SetCardInfo("Kaito, Bane of Nightmares", 409, Rarity.MYTHIC, mage.cards.k.KaitoBaneOfNightmares.class, NON_FULL_USE_VARIOUS)); cards.add(new SetCardInfo("Killer's Mask", 104, Rarity.UNCOMMON, mage.cards.k.KillersMask.class)); cards.add(new SetCardInfo("Kona, Rescue Beastie", 187, Rarity.RARE, mage.cards.k.KonaRescueBeastie.class)); cards.add(new SetCardInfo("Lakeside Shack", 262, Rarity.COMMON, mage.cards.l.LakesideShack.class)); diff --git a/Mage/src/main/java/mage/game/command/emblems/KaitoBaneOfNightmaresEmblem.java b/Mage/src/main/java/mage/game/command/emblems/KaitoBaneOfNightmaresEmblem.java new file mode 100644 index 00000000000..ac6fa47d0c4 --- /dev/null +++ b/Mage/src/main/java/mage/game/command/emblems/KaitoBaneOfNightmaresEmblem.java @@ -0,0 +1,31 @@ +package mage.game.command.emblems; + +import mage.abilities.common.SimpleStaticAbility; +import mage.abilities.effects.common.continuous.BoostControlledEffect; +import mage.constants.Duration; +import mage.constants.SubType; +import mage.constants.Zone; +import mage.filter.common.FilterCreaturePermanent; +import mage.game.command.Emblem; + +/** + * @author jackd149 + */ +public final class KaitoBaneOfNightmaresEmblem extends Emblem { + + public KaitoBaneOfNightmaresEmblem() { + super("Emblem Kaito"); + FilterCreaturePermanent filter = new FilterCreaturePermanent(SubType.NINJA, "Ninjas you control"); + this.getAbilities().add(new SimpleStaticAbility(Zone.COMMAND, new BoostControlledEffect(1, 1, Duration.EndOfGame, filter, false))); + } + + private KaitoBaneOfNightmaresEmblem(final KaitoBaneOfNightmaresEmblem card) { + super(card); + } + + @Override + public KaitoBaneOfNightmaresEmblem copy() { + return new KaitoBaneOfNightmaresEmblem(this); + } + +} diff --git a/Mage/src/main/java/mage/watchers/common/PlayerLostLifeWatcher.java b/Mage/src/main/java/mage/watchers/common/PlayerLostLifeWatcher.java index 89c03aeda2a..ad1551295d7 100644 --- a/Mage/src/main/java/mage/watchers/common/PlayerLostLifeWatcher.java +++ b/Mage/src/main/java/mage/watchers/common/PlayerLostLifeWatcher.java @@ -59,6 +59,19 @@ public class PlayerLostLifeWatcher extends Watcher { return amount; } + public int getNumberOfOpponentsWhoLostLife(UUID playerId, Game game) { + int numPlayersLostLife = 0; + for (UUID opponentId : this.amountOfLifeLostThisTurn.keySet()) { + Player opponent = game.getPlayer(opponentId); + if (opponent != null && opponent.hasOpponent(playerId, game)) { + if (this.amountOfLifeLostThisTurn.getOrDefault(opponentId, 0) > 0) { + numPlayersLostLife++; + } + } + } + return numPlayersLostLife; + } + public int getLifeLostLastTurn(UUID playerId) { return amountOfLifeLostLastTurn.getOrDefault(playerId, 0); } diff --git a/Mage/src/main/resources/tokens-database.txt b/Mage/src/main/resources/tokens-database.txt index 643a4eefd12..a842d1c5600 100644 --- a/Mage/src/main/resources/tokens-database.txt +++ b/Mage/src/main/resources/tokens-database.txt @@ -136,6 +136,7 @@ |Generate|EMBLEM:M3C|Emblem Vivien|||VivienReidEmblem| |Generate|EMBLEM:ACR|Emblem Capitoline Triad|||TheCapitolineTriadEmblem| |Generate|EMBLEM:BLB|Emblem Ral|||RalCracklingWitEmblem| +|Generate|EMBLEM:DSK|Emblem Kaito|||KaitoBaneOfNightmaresEmblem| |Generate|EMBLEM:FDN|Emblem Kaito|||KaitoCunningInfiltratorEmblem| |Generate|EMBLEM:FDN|Emblem Vivien|||VivienReidEmblem| -- 2.47.2 From b8e1266e39a4147b19da5cbe82038a22eb129306 Mon Sep 17 00:00:00 2001 From: jackd149 <67557084+jackd149@users.noreply.github.com> Date: Fri, 3 Jan 2025 04:04:13 +0300 Subject: [PATCH 13/32] [DSK] Implement Cryptid Inspector (#13189) --- .../src/mage/cards/c/CryptidInspector.java | 61 +++++++++++++++++++ .../src/mage/sets/DuskmournHouseOfHorror.java | 1 + 2 files changed, 62 insertions(+) create mode 100644 Mage.Sets/src/mage/cards/c/CryptidInspector.java diff --git a/Mage.Sets/src/mage/cards/c/CryptidInspector.java b/Mage.Sets/src/mage/cards/c/CryptidInspector.java new file mode 100644 index 00000000000..2481532b2ff --- /dev/null +++ b/Mage.Sets/src/mage/cards/c/CryptidInspector.java @@ -0,0 +1,61 @@ +package mage.cards.c; + +import java.util.UUID; + +import mage.MageInt; +import mage.abilities.common.EntersBattlefieldControlledTriggeredAbility; +import mage.abilities.common.TurnedFaceUpAllTriggeredAbility; +import mage.abilities.effects.common.counter.AddCountersSourceEffect; +import mage.abilities.keyword.VigilanceAbility; +import mage.abilities.meta.OrTriggeredAbility; +import mage.cards.CardImpl; +import mage.cards.CardSetInfo; +import mage.constants.CardType; +import mage.constants.SubType; +import mage.constants.Zone; +import mage.counters.CounterType; +import mage.filter.FilterPermanent; +import mage.filter.common.FilterControlledPermanent; +import mage.filter.predicate.card.FaceDownPredicate; + +/** + * @author jackd149 + */ +public final class CryptidInspector extends CardImpl { + private static final FilterPermanent filter1 = new FilterPermanent("a face-down permanent"); + private static final FilterPermanent filter2 = new FilterControlledPermanent("Cryptid Inspector or another permanent you control"); + + static { + filter1.add(FaceDownPredicate.instance); + } + + public CryptidInspector(UUID ownerId, CardSetInfo setInfo) { + super(ownerId, setInfo, new CardType[]{CardType.CREATURE}, "{2}{G}"); + this.power = new MageInt(2); + this.toughness = new MageInt(3); + this.subtype.add(SubType.ELF); + this.subtype.add(SubType.WARRIOR); + this.addAbility(VigilanceAbility.getInstance()); + + // Whenever a face-down permanent you control enters and whenever Cryptid Inspector or another permanent you control is turned face up, + // put a +1/+1 counter on Cryptid Inspector. + this.addAbility(new OrTriggeredAbility( + Zone.BATTLEFIELD, + new AddCountersSourceEffect(CounterType.P1P1.createInstance()), + false, + "Whenever a face-down permanent you control enters and " + + "whenever Cryptid Inspector or another permanent you control is turned face up, ", + new EntersBattlefieldControlledTriggeredAbility(null, filter1), + new TurnedFaceUpAllTriggeredAbility(null, filter2) + )); + } + + private CryptidInspector(final CryptidInspector card){ + super(card); + } + + @Override + public CryptidInspector copy() { + return new CryptidInspector(this); + } +} \ No newline at end of file diff --git a/Mage.Sets/src/mage/sets/DuskmournHouseOfHorror.java b/Mage.Sets/src/mage/sets/DuskmournHouseOfHorror.java index 08420c6897e..eb2e87f70c3 100644 --- a/Mage.Sets/src/mage/sets/DuskmournHouseOfHorror.java +++ b/Mage.Sets/src/mage/sets/DuskmournHouseOfHorror.java @@ -52,6 +52,7 @@ public final class DuskmournHouseOfHorror extends ExpansionSet { cards.add(new SetCardInfo("Conductive Machete", 244, Rarity.UNCOMMON, mage.cards.c.ConductiveMachete.class)); cards.add(new SetCardInfo("Coordinated Clobbering", 173, Rarity.UNCOMMON, mage.cards.c.CoordinatedClobbering.class)); cards.add(new SetCardInfo("Cracked Skull", 88, Rarity.COMMON, mage.cards.c.CrackedSkull.class)); + cards.add(new SetCardInfo("Cryptid Inspector", 174, Rarity.COMMON, mage.cards.c.CryptidInspector.class)); cards.add(new SetCardInfo("Cult Healer", 2, Rarity.COMMON, mage.cards.c.CultHealer.class)); cards.add(new SetCardInfo("Cursed Recording", 131, Rarity.RARE, mage.cards.c.CursedRecording.class)); cards.add(new SetCardInfo("Cursed Windbreaker", 47, Rarity.UNCOMMON, mage.cards.c.CursedWindbreaker.class)); -- 2.47.2 From e149661723400e9da03cebbf58b838d62181c97d Mon Sep 17 00:00:00 2001 From: tiera3 <87589219+tiera3@users.noreply.github.com> Date: Fri, 20 Dec 2024 21:34:47 +1000 Subject: [PATCH 14/32] [CON] booster collation for Conflux (closes #13169) --- Mage.Sets/src/mage/sets/Conflux.java | 64 +++++++++++++++++++++++++++- 1 file changed, 63 insertions(+), 1 deletion(-) diff --git a/Mage.Sets/src/mage/sets/Conflux.java b/Mage.Sets/src/mage/sets/Conflux.java index 82a42d710f9..6b18fe618ef 100644 --- a/Mage.Sets/src/mage/sets/Conflux.java +++ b/Mage.Sets/src/mage/sets/Conflux.java @@ -1,10 +1,16 @@ - package mage.sets; import mage.cards.ExpansionSet; +import mage.collation.BoosterCollator; +import mage.collation.BoosterStructure; +import mage.collation.CardRun; +import mage.collation.RarityConfiguration; import mage.constants.Rarity; import mage.constants.SetType; +import java.util.ArrayList; +import java.util.List; + /** * * @author BetaSteward_at_googlemail.com @@ -175,4 +181,60 @@ public final class Conflux extends ExpansionSet { cards.add(new SetCardInfo("Zombie Outlander", 133, Rarity.COMMON, mage.cards.z.ZombieOutlander.class)); } + @Override + public BoosterCollator createCollator() { + return new ConfluxCollator(); + } +} + +// Booster collation info from https://vm1.substation33.com/tiera/t/lethe/cfx.html +// Using USA collation +class ConfluxCollator implements BoosterCollator { + private final CardRun commonA = new CardRun(true, "8", "97", "28", "63", "50", "130", "134", "42", "4", "72", "36", "78", "138", "133", "84", "7", "63", "56", "105", "32", "4", "137", "81", "72", "27", "135", "8", "54", "132", "97", "61", "28", "3", "56", "94", "60", "36", "54", "137", "16", "133", "78", "7", "37", "134", "67", "27", "40", "130", "94", "3", "50", "61", "32", "84", "135", "42", "60", "105", "37", "16", "132", "40", "81", "67", "138"); + private final CardRun commonB = new CardRun(true, "90", "47", "39", "70", "51", "2", "119", "29", "106", "9", "90", "57", "86", "76", "21", "128", "68", "51", "109", "6", "39", "85", "52", "21", "106", "68", "119", "86", "19", "29", "57", "131", "70", "96", "9", "144", "128", "22", "6", "69", "47", "122", "76", "85", "19", "52", "109", "22", "96", "2", "144", "131", "69", "122"); + private final CardRun uncommonA = new CardRun(false, "141", "5", "23", "41", "103", "24", "62", "82", "107", "83", "65", "66", "112", "114", "139", "13", "14", "143", "91", "129", "38", "55"); + private final CardRun uncommonB = new CardRun(false, "1", "43", "104", "25", "45", "46", "111", "15", "89", "123", "34", "124", "125", "126", "93", "145", "73", "74"); + private final CardRun rare = new CardRun(false, "58", "58", "59", "59", "99", "99", "100", "100", "79", "79", "80", "80", "142", "142", "44", "44", "136", "136", "108", "108", "64", "64", "110", "110", "30", "30", "48", "48", "113", "113", "116", "116", "10", "10", "11", "11", "31", "31", "118", "118", "87", "87", "49", "49", "140", "140", "88", "88", "71", "71", "17", "17", "53", "53", "33", "33", "18", "18", "92", "92", "127", "127", "35", "35", "75", "75", "20", "20", "77", "77", "98", "101", "102", "26", "115", "117", "12", "120", "121", "95"); + private final CardRun land = new CardRun(false, "ALA_230", "ALA_231", "ALA_232", "ALA_233", "ALA_234", "ALA_235", "ALA_236", "ALA_237", "ALA_238", "ALA_239", "ALA_240", "ALA_241", "ALA_242", "ALA_243", "ALA_244", "ALA_245", "ALA_246", "ALA_247", "ALA_248", "ALA_249"); + + private final BoosterStructure AAAAAABBBB = new BoosterStructure( + commonA, commonA, commonA, commonA, commonA, commonA, + commonB, commonB, commonB, commonB + ); + private final BoosterStructure AAAAABBBBB = new BoosterStructure( + commonA, commonA, commonA, commonA, commonA, + commonB, commonB, commonB, commonB, commonB + ); + private final BoosterStructure AAB = new BoosterStructure(uncommonA, uncommonA, uncommonB); + private final BoosterStructure ABB = new BoosterStructure(uncommonA, uncommonB, uncommonB); + private final BoosterStructure R1 = new BoosterStructure(rare); + private final BoosterStructure L1 = new BoosterStructure(land); + + // In order for equal numbers of each common to exist, the average booster must contain: + // 5.5 A commons (11 / 2) + // 4.5 B commons ( 9 / 2) + private final RarityConfiguration commonRuns = new RarityConfiguration( + AAAAAABBBB, + AAAAABBBBB + ); + // In order for equal numbers of each uncommon to exist, the average booster must contain: + // 1.65 A uncommons (33 / 20) + // 1.35 B uncommons (27 / 20) + // These numbers are the same for all sets with 60 uncommons in asymmetrical A/B print runs + private final RarityConfiguration uncommonRuns = new RarityConfiguration( + AAB, AAB, AAB, AAB, AAB, AAB, AAB, AAB, AAB, AAB, AAB, AAB, AAB, + ABB, ABB, ABB, ABB, ABB, ABB, ABB + ); + private final RarityConfiguration rareRuns = new RarityConfiguration(R1); + private final RarityConfiguration landRuns = new RarityConfiguration(L1); + + @Override + public List makeBooster() { + List booster = new ArrayList<>(); + booster.addAll(commonRuns.getNext().makeRun()); + booster.addAll(uncommonRuns.getNext().makeRun()); + booster.addAll(rareRuns.getNext().makeRun()); + booster.addAll(landRuns.getNext().makeRun()); + return booster; + } } -- 2.47.2 From cb936d826bbc7597eaff580bee6bdb7fe1bff52a Mon Sep 17 00:00:00 2001 From: tiera3 <87589219+tiera3@users.noreply.github.com> Date: Mon, 16 Dec 2024 11:31:05 +1000 Subject: [PATCH 15/32] [ARB] booster collation for Alara Reborn (closes #13146) --- Mage.Sets/src/mage/sets/AlaraReborn.java | 65 +++++++++++++++++++++++- 1 file changed, 64 insertions(+), 1 deletion(-) diff --git a/Mage.Sets/src/mage/sets/AlaraReborn.java b/Mage.Sets/src/mage/sets/AlaraReborn.java index c4fcf0254f4..2bae33b9a98 100644 --- a/Mage.Sets/src/mage/sets/AlaraReborn.java +++ b/Mage.Sets/src/mage/sets/AlaraReborn.java @@ -1,10 +1,16 @@ - package mage.sets; import mage.cards.ExpansionSet; +import mage.collation.BoosterCollator; +import mage.collation.BoosterStructure; +import mage.collation.CardRun; +import mage.collation.RarityConfiguration; import mage.constants.Rarity; import mage.constants.SetType; +import java.util.ArrayList; +import java.util.List; + /** * * @author BetaSteward_at_googlemail.com @@ -176,4 +182,61 @@ public final class AlaraReborn extends ExpansionSet { cards.add(new SetCardInfo("Zealous Persecution", 85, Rarity.UNCOMMON, mage.cards.z.ZealousPersecution.class)); } + @Override + public BoosterCollator createCollator() { + return new AlaraRebornCollator(); + } +} + +// Booster collation info from https://vm1.substation33.com/tiera/t/lethe/arb.html +// Using USA collation +class AlaraRebornCollator implements BoosterCollator { + private final CardRun commonA = new CardRun(true, "51", "74", "122", "45", "52", "14", "134", "29", "75", "138", "43", "13", "59", "80", "135", "27", "7", "143", "46", "72", "96", "22", "134", "4", "80", "144", "17", "46", "96", "19", "7", "138", "105", "132", "95", "75", "51", "22", "4", "122", "56", "45", "143", "74", "29", "13", "48", "139", "56", "72", "17", "5", "144", "27", "43", "14", "139", "105", "52", "48", "132", "19", "59", "5", "135", "95"); + private final CardRun commonB = new CardRun(true, "78", "131", "9", "55", "40", "107", "69", "18", "112", "61", "10", "40", "125", "79", "20", "107", "3", "55", "35", "116", "32", "79", "63", "112", "3", "66", "88", "142", "41", "32", "63", "141", "84", "54", "116", "66", "35", "20", "131", "38", "9", "54", "141", "78", "41", "84", "18", "142", "69", "61", "125", "10", "38", "88"); + private final CardRun uncommonA = new CardRun(false, "1", "11", "23", "25", "34", "39", "62", "65", "68", "85", "89", "93", "99", "100", "101", "111", "120", "133", "136", "137", "140", "145"); + private final CardRun uncommonB = new CardRun(false, "15", "16", "21", "26", "33", "44", "50", "57", "64", "76", "77", "83", "87", "94", "102", "108", "115", "127"); + private final CardRun rare = new CardRun(false, "2", "2", "6", "6", "8", "8", "12", "12", "24", "24", "28", "28", "30", "30", "31", "31", "36", "36", "42", "42", "47", "47", "49", "49", "58", "58", "60", "60", "67", "67", "70", "70", "71", "71", "73", "73", "81", "81", "82", "82", "86", "86", "90", "90", "92", "92", "97", "97", "98", "98", "103", "103", "104", "104", "106", "106", "114", "114", "118", "118", "119", "119", "121", "121", "123", "123", "126", "126", "129", "129", "37", "53", "91", "109", "110", "113", "117", "124", "128", "130"); + private final CardRun land = new CardRun(false, "ALA_230", "ALA_231", "ALA_232", "ALA_233", "ALA_234", "ALA_235", "ALA_236", "ALA_237", "ALA_238", "ALA_239", "ALA_240", "ALA_241", "ALA_242", "ALA_243", "ALA_244", "ALA_245", "ALA_246", "ALA_247", "ALA_248", "ALA_249"); + + private final BoosterStructure AAAAAABBBB = new BoosterStructure( + commonA, commonA, commonA, commonA, commonA, commonA, + commonB, commonB, commonB, commonB + ); + private final BoosterStructure AAAAABBBBB = new BoosterStructure( + commonA, commonA, commonA, commonA, commonA, + commonB, commonB, commonB, commonB, commonB + ); + private final BoosterStructure AAB = new BoosterStructure(uncommonA, uncommonA, uncommonB); + private final BoosterStructure ABB = new BoosterStructure(uncommonA, uncommonB, uncommonB); + private final BoosterStructure R1 = new BoosterStructure(rare); + private final BoosterStructure L1 = new BoosterStructure(land); + + // In order for equal numbers of each common to exist, the average booster must contain: + // 5.5 A commons (11 / 2) + // 4.5 B commons ( 9 / 2) + private final RarityConfiguration commonRuns = new RarityConfiguration( + AAAAAABBBB, + AAAAABBBBB + ); + // In order for equal numbers of each uncommon to exist, the average booster must contain: + // 1.65 A uncommons (33 / 20) + // 1.35 B uncommons (27 / 20) + // These numbers are the same for all sets with 60 uncommons in asymmetrical A/B print runs + private final RarityConfiguration uncommonRuns = new RarityConfiguration( + AAB, AAB, AAB, AAB, AAB, AAB, AAB, AAB, AAB, AAB, AAB, AAB, AAB, + ABB, ABB, ABB, ABB, ABB, ABB, ABB + ); + private final RarityConfiguration rareRuns = new RarityConfiguration(R1); + private final RarityConfiguration landRuns = new RarityConfiguration(L1); + + @Override + public List makeBooster() { + List booster = new ArrayList<>(); + booster.addAll(commonRuns.getNext().makeRun()); + booster.addAll(uncommonRuns.getNext().makeRun()); + booster.addAll(rareRuns.getNext().makeRun()); + booster.addAll(landRuns.getNext().makeRun()); + return booster; + } + } -- 2.47.2 From fa781bb9668cb982e682795c0c3b6d69a01f59b5 Mon Sep 17 00:00:00 2001 From: xenohedron Date: Thu, 2 Jan 2025 23:49:20 -0500 Subject: [PATCH 16/32] remove misleading comments from non-draftable sets (related to #13160) --- Mage.Sets/src/mage/sets/FoundationsJumpstart.java | 2 +- Mage.Sets/src/mage/sets/MarchOfTheMachineTheAftermath.java | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/Mage.Sets/src/mage/sets/FoundationsJumpstart.java b/Mage.Sets/src/mage/sets/FoundationsJumpstart.java index 4a2096c2fdf..297584d4418 100644 --- a/Mage.Sets/src/mage/sets/FoundationsJumpstart.java +++ b/Mage.Sets/src/mage/sets/FoundationsJumpstart.java @@ -19,7 +19,7 @@ public final class FoundationsJumpstart extends ExpansionSet { super("Foundations Jumpstart", "J25", ExpansionSet.buildDate(2024, 11, 15), SetType.EXPANSION); this.blockName = "Foundations"; // for sorting in GUI this.hasBasicLands = true; - this.hasBoosters = false; // temporary + this.hasBoosters = false; cards.add(new SetCardInfo("Abandon Reason", 513, Rarity.UNCOMMON, mage.cards.a.AbandonReason.class)); cards.add(new SetCardInfo("Academy Journeymage", 281, Rarity.COMMON, mage.cards.a.AcademyJourneymage.class)); diff --git a/Mage.Sets/src/mage/sets/MarchOfTheMachineTheAftermath.java b/Mage.Sets/src/mage/sets/MarchOfTheMachineTheAftermath.java index 465b9049d62..053f1aae252 100644 --- a/Mage.Sets/src/mage/sets/MarchOfTheMachineTheAftermath.java +++ b/Mage.Sets/src/mage/sets/MarchOfTheMachineTheAftermath.java @@ -19,7 +19,7 @@ public final class MarchOfTheMachineTheAftermath extends ExpansionSet { super("March of the Machine: The Aftermath", "MAT", ExpansionSet.buildDate(2023, 5, 12), SetType.SUPPLEMENTAL_STANDARD_LEGAL); this.blockName = "March of the Machine"; this.hasBasicLands = false; - this.hasBoosters = false; // temporary + this.hasBoosters = false; cards.add(new SetCardInfo("Animist's Might", 120, Rarity.UNCOMMON, mage.cards.a.AnimistsMight.class, NON_FULL_USE_VARIOUS)); cards.add(new SetCardInfo("Animist's Might", 20, Rarity.UNCOMMON, mage.cards.a.AnimistsMight.class, NON_FULL_USE_VARIOUS)); -- 2.47.2 From 9164509b51566e416477f4a74439a62c4aa7adf6 Mon Sep 17 00:00:00 2001 From: xenohedron Date: Thu, 2 Jan 2025 23:52:54 -0500 Subject: [PATCH 17/32] fix #13188 (Mangara's Tome - replacement effect is single use) --- Mage.Sets/src/mage/cards/m/MangarasTome.java | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/Mage.Sets/src/mage/cards/m/MangarasTome.java b/Mage.Sets/src/mage/cards/m/MangarasTome.java index 2e5e294b122..8c6cedb7cff 100644 --- a/Mage.Sets/src/mage/cards/m/MangarasTome.java +++ b/Mage.Sets/src/mage/cards/m/MangarasTome.java @@ -16,7 +16,6 @@ import mage.constants.Zone; import mage.filter.FilterCard; import mage.game.Game; import mage.game.events.GameEvent; -import mage.game.events.GameEvent.EventType; import mage.game.permanent.Permanent; import mage.players.Player; import mage.target.common.TargetCardInLibrary; @@ -112,6 +111,7 @@ class MangarasTomeReplacementEffect extends ReplacementEffectImpl { controller.moveCards(card, Zone.HAND, source, game); } } + used = true; // one time use return true; } @@ -122,6 +122,6 @@ class MangarasTomeReplacementEffect extends ReplacementEffectImpl { @Override public boolean applies(GameEvent event, Ability source, Game game) { - return source.isControlledBy(event.getPlayerId()); + return !used && source.isControlledBy(event.getPlayerId()); } } -- 2.47.2 From 07427c1df62c3378fbb18e7fcb6610a24aad2281 Mon Sep 17 00:00:00 2001 From: xenohedron Date: Fri, 3 Jan 2025 00:02:22 -0500 Subject: [PATCH 18/32] fix bad error handling (related to #13132) --- .../src/main/java/org/mage/card/arcane/TextboxRuleParser.java | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Mage.Client/src/main/java/org/mage/card/arcane/TextboxRuleParser.java b/Mage.Client/src/main/java/org/mage/card/arcane/TextboxRuleParser.java index 12d1a127c1a..d13c7ddfc78 100644 --- a/Mage.Client/src/main/java/org/mage/card/arcane/TextboxRuleParser.java +++ b/Mage.Client/src/main/java/org/mage/card/arcane/TextboxRuleParser.java @@ -143,7 +143,7 @@ public final class TextboxRuleParser { index += 5; ++outputIndex; } else { - LOGGER.error("Bad &...; sequence `" + rule.substring(index + 1, index + 10) + "` in rule."); + LOGGER.error("Bad &...; sequence `" + rule.substring(index, Math.max(rule.length(), index + 10)) + "` in rule."); build.append('&'); ++index; ++outputIndex; -- 2.47.2 From ab1b3f52979c158988448b71754983a7f631b5d2 Mon Sep 17 00:00:00 2001 From: Oleg Agafonov Date: Fri, 3 Jan 2025 12:45:21 +0400 Subject: [PATCH 19/32] fix bad error handling (related to #13132) --- .../src/main/java/org/mage/card/arcane/TextboxRuleParser.java | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Mage.Client/src/main/java/org/mage/card/arcane/TextboxRuleParser.java b/Mage.Client/src/main/java/org/mage/card/arcane/TextboxRuleParser.java index d13c7ddfc78..f549c6c9c2a 100644 --- a/Mage.Client/src/main/java/org/mage/card/arcane/TextboxRuleParser.java +++ b/Mage.Client/src/main/java/org/mage/card/arcane/TextboxRuleParser.java @@ -143,7 +143,7 @@ public final class TextboxRuleParser { index += 5; ++outputIndex; } else { - LOGGER.error("Bad &...; sequence `" + rule.substring(index, Math.max(rule.length(), index + 10)) + "` in rule."); + LOGGER.error("Bad &...; sequence `" + rule.substring(index, Math.min(rule.length(), index + 10)) + "` in rule."); build.append('&'); ++index; ++outputIndex; -- 2.47.2 From 281086bbeb968842d88d54edb151a621d4ab3832 Mon Sep 17 00:00:00 2001 From: Grath <1895280+Grath@users.noreply.github.com> Date: Fri, 3 Jan 2025 12:14:44 -0500 Subject: [PATCH 20/32] [PIP] Implement Cait, Cage Brawler Also add alt arts of Curie, Emergent Intelligence and Yes Man, Personal Securitron, other recently added PIP cards. --- .../src/mage/cards/c/CaitCageBrawler.java | 123 ++++++++++++++++++ Mage.Sets/src/mage/sets/Fallout.java | 14 +- 2 files changed, 135 insertions(+), 2 deletions(-) create mode 100644 Mage.Sets/src/mage/cards/c/CaitCageBrawler.java diff --git a/Mage.Sets/src/mage/cards/c/CaitCageBrawler.java b/Mage.Sets/src/mage/cards/c/CaitCageBrawler.java new file mode 100644 index 00000000000..b92d83818f9 --- /dev/null +++ b/Mage.Sets/src/mage/cards/c/CaitCageBrawler.java @@ -0,0 +1,123 @@ +package mage.cards.c; + +import java.util.UUID; +import mage.MageInt; +import mage.MageObject; +import mage.abilities.Ability; +import mage.abilities.common.AttacksTriggeredAbility; +import mage.abilities.common.SimpleStaticAbility; +import mage.abilities.condition.common.MyTurnCondition; +import mage.abilities.decorator.ConditionalContinuousEffect; +import mage.abilities.effects.OneShotEffect; +import mage.abilities.effects.common.continuous.GainAbilitySourceEffect; +import mage.abilities.effects.common.counter.AddCountersSourceEffect; +import mage.abilities.keyword.IndestructibleAbility; +import mage.cards.*; +import mage.constants.*; +import mage.counters.CounterType; +import mage.filter.FilterCard; +import mage.game.Game; +import mage.players.Player; +import mage.target.TargetCard; + +/** + * + * @author Grath + */ +public final class CaitCageBrawler extends CardImpl { + + public CaitCageBrawler(UUID ownerId, CardSetInfo setInfo) { + super(ownerId, setInfo, new CardType[]{CardType.CREATURE}, "{R}{G}"); + + this.supertype.add(SuperType.LEGENDARY); + this.subtype.add(SubType.HUMAN); + this.subtype.add(SubType.WARRIOR); + this.power = new MageInt(1); + this.toughness = new MageInt(1); + + // During your turn, Cait, Cage Brawler has indestructible. + this.addAbility(new SimpleStaticAbility(new ConditionalContinuousEffect( + new GainAbilitySourceEffect(IndestructibleAbility.getInstance()), + MyTurnCondition.instance, "during your turn, {this} has indestructible" + ))); + + // Whenever Cait attacks, you and defending player each draw a card, then discard a card. Put two +1/+1 counters on Cait if you discarded the card with the highest mana value among those cards or tied for highest. + this.addAbility(new AttacksTriggeredAbility(new CaitCageBrawlerEffect(), false, null, SetTargetPointer.PLAYER)); + } + + private CaitCageBrawler(final CaitCageBrawler card) { + super(card); + } + + @Override + public CaitCageBrawler copy() { + return new CaitCageBrawler(this); + } +} + +class CaitCageBrawlerEffect extends OneShotEffect { + + public CaitCageBrawlerEffect() { + super(Outcome.Benefit); + this.staticText = "you and defending player each draw a card, then discard a card. Put two +1/+1 counters on " + + "{this} if you discarded the card with the highest mana value among those cards or tied for highest."; + } + + protected CaitCageBrawlerEffect(final CaitCageBrawlerEffect effect) { + super(effect); + } + + @Override + public CaitCageBrawlerEffect copy() { + return new CaitCageBrawlerEffect(this); + } + + @Override + public boolean apply(Game game, Ability source) { + Player controller = game.getPlayer(source.getControllerId()); + MageObject sourceObject = source.getSourceObject(game); + if (controller == null + || sourceObject == null) { + return false; + } + + controller.drawCards(1, source, game); + + Player opponent = game.getPlayer(getTargetPointer().getFirst(game, source)); + if (opponent != null) { + opponent.drawCards(1, source, game); + } + int mvController = Integer.MIN_VALUE; + Card cardController = null; + int mvOpponent = Integer.MIN_VALUE; + Card cardOpponent = null; + + TargetCard controllerTarget = new TargetCard(Zone.HAND, new FilterCard()); + if (controller.choose(Outcome.Discard, controller.getHand(), controllerTarget, source, game)) { + Card card = controller.getHand().get(controllerTarget.getFirstTarget(), game); + if (card != null) { + cardController = card; + mvController = card.getManaValue(); + } + } + TargetCard opponentTarget = new TargetCard(Zone.HAND, new FilterCard()); + if (opponent != null && opponent.choose(Outcome.Discard, opponent.getHand(), opponentTarget, source, game)) { + Card card = opponent.getHand().get(opponentTarget.getFirstTarget(), game); + if (card != null) { + cardOpponent = card; + mvOpponent = card.getManaValue(); + } + } + + if (cardOpponent != null) { + opponent.discard(cardOpponent, false, source, game); + } + if (cardController != null) { + controller.discard(cardController, false, source, game); + if (mvController > mvOpponent) { + new AddCountersSourceEffect(CounterType.P1P1.createInstance(2)).apply(game, source); + } + } + return true; + } +} \ No newline at end of file diff --git a/Mage.Sets/src/mage/sets/Fallout.java b/Mage.Sets/src/mage/sets/Fallout.java index 6364801575f..e9b9fd78221 100644 --- a/Mage.Sets/src/mage/sets/Fallout.java +++ b/Mage.Sets/src/mage/sets/Fallout.java @@ -76,6 +76,10 @@ public final class Fallout extends ExpansionSet { cards.add(new SetCardInfo("Caesar, Legion's Emperor", 529, Rarity.MYTHIC, mage.cards.c.CaesarLegionsEmperor.class, NON_FULL_USE_VARIOUS)); cards.add(new SetCardInfo("Caesar, Legion's Emperor", 867, Rarity.MYTHIC, mage.cards.c.CaesarLegionsEmperor.class, NON_FULL_USE_VARIOUS)); cards.add(new SetCardInfo("Caesar, Legion's Emperor", 1064, Rarity.MYTHIC, mage.cards.c.CaesarLegionsEmperor.class, NON_FULL_USE_VARIOUS)); + cards.add(new SetCardInfo("Cait, Cage Brawler", 96, Rarity.RARE, mage.cards.c.CaitCageBrawler.class, NON_FULL_USE_VARIOUS)); + cards.add(new SetCardInfo("Cait, Cage Brawler", 409, Rarity.RARE, mage.cards.c.CaitCageBrawler.class, NON_FULL_USE_VARIOUS)); + cards.add(new SetCardInfo("Cait, Cage Brawler", 624, Rarity.RARE, mage.cards.c.CaitCageBrawler.class, NON_FULL_USE_VARIOUS)); + cards.add(new SetCardInfo("Cait, Cage Brawler", 937, Rarity.RARE, mage.cards.c.CaitCageBrawler.class, NON_FULL_USE_VARIOUS)); cards.add(new SetCardInfo("Canopy Vista", 255, Rarity.RARE, mage.cards.c.CanopyVista.class)); cards.add(new SetCardInfo("Canyon Slough", 256, Rarity.RARE, mage.cards.c.CanyonSlough.class)); cards.add(new SetCardInfo("Captain of the Watch", 157, Rarity.RARE, mage.cards.c.CaptainOfTheWatch.class)); @@ -103,7 +107,10 @@ public final class Fallout extends ExpansionSet { cards.add(new SetCardInfo("Crucible of Worlds", 357, Rarity.MYTHIC, mage.cards.c.CrucibleOfWorlds.class)); cards.add(new SetCardInfo("Crush Contraband", 158, Rarity.UNCOMMON, mage.cards.c.CrushContraband.class)); cards.add(new SetCardInfo("Cultivate", 196, Rarity.UNCOMMON, mage.cards.c.Cultivate.class)); - cards.add(new SetCardInfo("Curie, Emergent Intelligence", 30, Rarity.RARE, mage.cards.c.CurieEmergentIntelligence.class)); + cards.add(new SetCardInfo("Curie, Emergent Intelligence", 30, Rarity.RARE, mage.cards.c.CurieEmergentIntelligence.class, NON_FULL_USE_VARIOUS)); + cards.add(new SetCardInfo("Curie, Emergent Intelligence", 374, Rarity.RARE, mage.cards.c.CurieEmergentIntelligence.class, NON_FULL_USE_VARIOUS)); + cards.add(new SetCardInfo("Curie, Emergent Intelligence", 558, Rarity.RARE, mage.cards.c.CurieEmergentIntelligence.class, NON_FULL_USE_VARIOUS)); + cards.add(new SetCardInfo("Curie, Emergent Intelligence", 902, Rarity.RARE, mage.cards.c.CurieEmergentIntelligence.class, NON_FULL_USE_VARIOUS)); cards.add(new SetCardInfo("Darkwater Catacombs", 260, Rarity.RARE, mage.cards.d.DarkwaterCatacombs.class)); cards.add(new SetCardInfo("Deadly Dispute", 184, Rarity.COMMON, mage.cards.d.DeadlyDispute.class)); cards.add(new SetCardInfo("Desdemona, Freedom's Edge", 101, Rarity.RARE, mage.cards.d.DesdemonaFreedomsEdge.class, NON_FULL_USE_VARIOUS)); @@ -431,7 +438,10 @@ public final class Fallout extends ExpansionSet { cards.add(new SetCardInfo("Windbrisk Heights", 315, Rarity.RARE, mage.cards.w.WindbriskHeights.class)); cards.add(new SetCardInfo("Winding Constrictor", 223, Rarity.UNCOMMON, mage.cards.w.WindingConstrictor.class)); cards.add(new SetCardInfo("Woodland Cemetery", 316, Rarity.RARE, mage.cards.w.WoodlandCemetery.class)); - cards.add(new SetCardInfo("Yes Man, Personal Securitron", 29, Rarity.RARE, mage.cards.y.YesManPersonalSecuritron.class)); + cards.add(new SetCardInfo("Yes Man, Personal Securitron", 29, Rarity.RARE, mage.cards.y.YesManPersonalSecuritron.class, NON_FULL_USE_VARIOUS)); + cards.add(new SetCardInfo("Yes Man, Personal Securitron", 373, Rarity.RARE, mage.cards.y.YesManPersonalSecuritron.class, NON_FULL_USE_VARIOUS)); + cards.add(new SetCardInfo("Yes Man, Personal Securitron", 557, Rarity.RARE, mage.cards.y.YesManPersonalSecuritron.class, NON_FULL_USE_VARIOUS)); + cards.add(new SetCardInfo("Yes Man, Personal Securitron", 901, Rarity.RARE, mage.cards.y.YesManPersonalSecuritron.class, NON_FULL_USE_VARIOUS)); cards.add(new SetCardInfo("Young Deathclaws", 125, Rarity.UNCOMMON, mage.cards.y.YoungDeathclaws.class)); cards.removeIf(setCardInfo -> IkoriaLairOfBehemoths.mutateNames.contains(setCardInfo.getName())); // remove when mutate is implemented -- 2.47.2 From fbd5cca14a7bd5f52d7d0cb363b872572a60ce49 Mon Sep 17 00:00:00 2001 From: Oleg Agafonov Date: Sat, 4 Jan 2025 12:45:38 +0400 Subject: [PATCH 21/32] GUI: fixed rare error while draging/moving card on first play (close #13201) --- .../mage/client/plugins/adapters/MageActionCallback.java | 9 +++++++-- 1 file changed, 7 insertions(+), 2 deletions(-) diff --git a/Mage.Client/src/main/java/mage/client/plugins/adapters/MageActionCallback.java b/Mage.Client/src/main/java/mage/client/plugins/adapters/MageActionCallback.java index f4103cd8554..102de5751ac 100644 --- a/Mage.Client/src/main/java/mage/client/plugins/adapters/MageActionCallback.java +++ b/Mage.Client/src/main/java/mage/client/plugins/adapters/MageActionCallback.java @@ -98,8 +98,8 @@ public class MageActionCallback implements ActionCallback { private MageCard prevCardPanel; private boolean startedDragging; private boolean isDragging; // TODO: remove drag hand code to the hand panels - private Point initialCardPos; - private Point initialMousePos; + private Point initialCardPos = null; + private Point initialMousePos = null; private final Set draggingCards = new HashSet<>(); public MageActionCallback() { @@ -351,6 +351,11 @@ public class MageActionCallback implements ActionCallback { return; } + if (this.initialMousePos == null || this.initialCardPos == null) { + // only allow really mouse pressed, e.g. ignore draft/game update on active card draging/pressing + return; + } + Point mouse = new Point(e.getX(), e.getY()); SwingUtilities.convertPointToScreen(mouse, data.getComponent()); if (!isDragging -- 2.47.2 From 75d241d5413f747bd28b9c5573aa34575a855abb Mon Sep 17 00:00:00 2001 From: Oleg Agafonov Date: Mon, 6 Jan 2025 03:33:06 +0400 Subject: [PATCH 22/32] GUI, game: improved priority pass on non-empty mana pool (no more confirm dialogs on active "don't lose unspent mana" and other effects, close #11717) --- .../src/mage/player/human/HumanPlayer.java | 4 +- Mage/src/main/java/mage/players/ManaPool.java | 37 +++++++++++++++---- 2 files changed, 32 insertions(+), 9 deletions(-) diff --git a/Mage.Server.Plugins/Mage.Player.Human/src/mage/player/human/HumanPlayer.java b/Mage.Server.Plugins/Mage.Player.Human/src/mage/player/human/HumanPlayer.java index de9f95c640b..81547206754 100644 --- a/Mage.Server.Plugins/Mage.Player.Human/src/mage/player/human/HumanPlayer.java +++ b/Mage.Server.Plugins/Mage.Player.Human/src/mage/player/human/HumanPlayer.java @@ -2928,7 +2928,7 @@ public class HumanPlayer extends PlayerImpl { protected boolean passWithManaPoolCheck(Game game) { if (userData.confirmEmptyManaPool() - && game.getStack().isEmpty() && getManaPool().count() > 0) { + && game.getStack().isEmpty() && getManaPool().count() > 0 && getManaPool().canLostManaOnEmpty()) { String activePlayerText; if (game.isActivePlayer(playerId)) { activePlayerText = "Your turn"; @@ -2940,7 +2940,7 @@ public class HumanPlayer extends PlayerImpl { priorityPlayerText = " / priority " + game.getPlayer(game.getPriorityPlayerId()).getName(); } // TODO: chooseUse and other dialogs must be under controlling player - if (!chooseUse(Outcome.Detriment, GameLog.getPlayerConfirmColoredText("You still have mana in your mana pool. Pass regardless?") + if (!chooseUse(Outcome.Detriment, GameLog.getPlayerConfirmColoredText("You still have mana in your mana pool and it will be lose. Pass anyway?") + GameLog.getSmallSecondLineText(activePlayerText + " / " + game.getTurnStepType().toString() + priorityPlayerText), null, game)) { sendPlayerAction(PlayerAction.PASS_PRIORITY_CANCEL_ALL_ACTIONS, game, null); return false; diff --git a/Mage/src/main/java/mage/players/ManaPool.java b/Mage/src/main/java/mage/players/ManaPool.java index dd8114a604b..82bca7edcef 100644 --- a/Mage/src/main/java/mage/players/ManaPool.java +++ b/Mage/src/main/java/mage/players/ManaPool.java @@ -38,9 +38,10 @@ public class ManaPool implements Serializable { private boolean forcedToPay; // for Word of Command private final List poolBookmark = new ArrayList<>(); // mana pool bookmark for rollback purposes - private final Set doNotEmptyManaTypes = new HashSet<>(); - private boolean manaBecomesBlack = false; - private boolean manaBecomesColorless = false; + // empty mana pool effects + private final Set doNotEmptyManaTypes = new HashSet<>(); // keep some colors + private boolean manaBecomesBlack = false; // replace all pool by black + private boolean manaBecomesColorless = false; // replace all pool by colorless private static final class ConditionalManaInfo { private final ManaType manaType; @@ -147,10 +148,10 @@ public class ManaPool implements Serializable { if (ability.getSourceId().equals(mana.getSourceId()) || !(mana.getSourceObject() instanceof Spell) || ((Spell) mana.getSourceObject()) - .getAbilities(game) - .stream() - .flatMap(a -> a.getAllEffects().stream()) - .anyMatch(ManaEffect.class::isInstance)) { + .getAbilities(game) + .stream() + .flatMap(a -> a.getAllEffects().stream()) + .anyMatch(ManaEffect.class::isInstance)) { continue; // if any of the above cases, not an alt mana payment ability, thus excluded by filter } } @@ -253,6 +254,28 @@ public class ManaPool implements Serializable { manaItems.clear(); } + public boolean canLostManaOnEmpty() { + for (ManaPoolItem item : manaItems) { + for (ManaType manaType : ManaType.values()) { + if (item.get(manaType) == 0) { + continue; + } + if (doNotEmptyManaTypes.contains(manaType)) { + continue; + } + if (manaBecomesBlack) { + continue; + } + if (manaBecomesColorless) { + continue; + } + // found real mana to empty + return true; + } + } + return false; + } + public int emptyPool(Game game) { int total = 0; Iterator it = manaItems.iterator(); -- 2.47.2 From c076f4925f7cc2bf7d752c2f8870e1745733f81f Mon Sep 17 00:00:00 2001 From: Oleg Agafonov Date: Tue, 7 Jan 2025 12:26:30 +0400 Subject: [PATCH 23/32] Turn under control reworked: - game: added support for human games (cards like Emrakul, the Promised End, #12878); - game: added support of 720.1. to reset control in the turn beginning instead cleanup step (related to #12115); - game: added game logs for priorities in cleanup step; - game: fixed game freezes and wrong skip settings usages (related to #12878); - gui: added playable and choose-able marks for controlling player's cards and permanents, including switched hands; - gui: added controlling player name in all choice dialogs; - info: control of computer players is it not yet supported; --- .../main/java/mage/client/game/GamePanel.java | 32 ++++++++++++ .../src/mage/player/human/HumanPlayer.java | 50 ++++++++----------- .../java/mage/server/game/GameController.java | 8 +-- .../mage/server/game/GameSessionPlayer.java | 13 ++--- .../mage/test/load/LoadCallbackClient.java | 23 ++++----- ...OfTurnStepPostDelayedTriggeredAbility.java | 42 ---------------- Mage/src/main/java/mage/game/Game.java | 6 +++ Mage/src/main/java/mage/game/GameImpl.java | 46 +++++++++++------ .../java/mage/game/turn/BeginningPhase.java | 2 - .../main/java/mage/game/turn/CleanupStep.java | 26 +++++++--- .../main/java/mage/game/turn/EndPhase.java | 11 ++-- Mage/src/main/java/mage/game/turn/Step.java | 6 +++ Mage/src/main/java/mage/game/turn/Turn.java | 33 ++++++++++-- Mage/src/main/java/mage/players/Player.java | 2 +- .../main/java/mage/players/PlayerImpl.java | 8 +-- Mage/src/main/java/mage/util/CardUtil.java | 5 +- Mage/src/main/java/mage/util/GameLog.java | 4 -- 17 files changed, 177 insertions(+), 140 deletions(-) delete mode 100644 Mage/src/main/java/mage/abilities/common/delayed/AtTheEndOfTurnStepPostDelayedTriggeredAbility.java diff --git a/Mage.Client/src/main/java/mage/client/game/GamePanel.java b/Mage.Client/src/main/java/mage/client/game/GamePanel.java index 7ee3f1ebccb..56d4e666592 100644 --- a/Mage.Client/src/main/java/mage/client/game/GamePanel.java +++ b/Mage.Client/src/main/java/mage/client/game/GamePanel.java @@ -30,8 +30,10 @@ import mage.constants.*; import mage.game.events.PlayerQueryEvent; import mage.players.PlayableObjectStats; import mage.players.PlayableObjectsList; +import mage.util.CardUtil; import mage.util.DebugUtil; import mage.util.MultiAmountMessage; +import mage.util.StreamUtils; import mage.view.*; import org.apache.log4j.Logger; import org.mage.plugins.card.utils.impl.ImageManagerImpl; @@ -53,6 +55,7 @@ import java.util.*; import java.util.concurrent.CancellationException; import java.util.concurrent.ExecutionException; import java.util.concurrent.TimeUnit; +import java.util.stream.Collectors; import static mage.client.dialog.PreferencesDialog.*; import static mage.constants.PlayerAction.*; @@ -1810,6 +1813,7 @@ public final class GamePanel extends javax.swing.JPanel { // hand if (needZone == Zone.HAND || needZone == Zone.ALL) { + // my hand for (CardView card : lastGameData.game.getMyHand().values()) { if (needSelectable.contains(card.getId())) { card.setChoosable(true); @@ -1821,6 +1825,34 @@ public final class GamePanel extends javax.swing.JPanel { card.setPlayableStats(needPlayable.getStats(card.getId())); } } + + // opponent hands (switching by GUI's button with my hand) + List list = lastGameData.game.getOpponentHands().values().stream().flatMap(s -> s.values().stream()).collect(Collectors.toList()); + for (SimpleCardView card : list) { + if (needSelectable.contains(card.getId())) { + card.setChoosable(true); + } + if (needChosen.contains(card.getId())) { + card.setSelected(true); + } + if (needPlayable.containsObject(card.getId())) { + card.setPlayableStats(needPlayable.getStats(card.getId())); + } + } + + // watched hands (switching by GUI's button with my hand) + list = lastGameData.game.getWatchedHands().values().stream().flatMap(s -> s.values().stream()).collect(Collectors.toList()); + for (SimpleCardView card : list) { + if (needSelectable.contains(card.getId())) { + card.setChoosable(true); + } + if (needChosen.contains(card.getId())) { + card.setSelected(true); + } + if (needPlayable.containsObject(card.getId())) { + card.setPlayableStats(needPlayable.getStats(card.getId())); + } + } } // stack diff --git a/Mage.Server.Plugins/Mage.Player.Human/src/mage/player/human/HumanPlayer.java b/Mage.Server.Plugins/Mage.Player.Human/src/mage/player/human/HumanPlayer.java index 81547206754..f55dcf7b402 100644 --- a/Mage.Server.Plugins/Mage.Player.Human/src/mage/player/human/HumanPlayer.java +++ b/Mage.Server.Plugins/Mage.Player.Human/src/mage/player/human/HumanPlayer.java @@ -37,6 +37,7 @@ import mage.game.tournament.Tournament; import mage.players.Player; import mage.players.PlayerImpl; import mage.players.PlayerList; +import mage.players.net.UserData; import mage.target.Target; import mage.target.TargetAmount; import mage.target.TargetCard; @@ -93,7 +94,7 @@ public class HumanPlayer extends PlayerImpl { // * - GAME thread: open response for income command and wait (go to sleep by response.wait) // * - CALL thread: on closed response - waiting open status of player's response object (if it's too long then cancel the answer) // * - CALL thread: on opened response - save answer to player's response object and notify GAME thread about it by response.notifyAll - // * - GAME thread: on nofify from response - check new answer value and process it (if it bad then repeat and wait the next one); + // * - GAME thread: on notify from response - check new answer value and process it (if it bad then repeat and wait the next one); private transient Boolean responseOpenedForAnswer = false; // GAME thread waiting new answer private transient long responseLastWaitingThreadId = 0; private final transient PlayerResponse response = new PlayerResponse(); @@ -1159,25 +1160,27 @@ public class HumanPlayer extends PlayerImpl { // TODO: change pass and other states like passedUntilStackResolved for controlling player, not for "this" // TODO: check and change all "this" to controling player calls, many bugs with hand, mana, skips - https://github.com/magefree/mage/issues/2088 // TODO: use controlling player in all choose dialogs (and canRespond too, what's with take control of player AI?!) + UserData controllingUserData = this.userData; if (canRespond()) { - HumanPlayer controllingPlayer = this; - if (isGameUnderControl()) { // TODO: must be ! to get real controlling player + if (!isGameUnderControl()) { Player player = game.getPlayer(getTurnControlledBy()); if (player instanceof HumanPlayer) { - controllingPlayer = (HumanPlayer) player; + controllingUserData = player.getUserData(); + } else { + // TODO: add computer opponent here?! } } // TODO: check that all skips and stops used from real controlling player // like holdingPriority (is it a bug here?) if (getJustActivatedType() != null && !holdingPriority) { - if (controllingPlayer.getUserData().isPassPriorityCast() + if (controllingUserData.isPassPriorityCast() && getJustActivatedType() == AbilityType.SPELL) { setJustActivatedType(null); pass(game); return false; } - if (controllingPlayer.getUserData().isPassPriorityActivation() + if (controllingUserData.isPassPriorityActivation() && getJustActivatedType().isNonManaActivatedAbility()) { setJustActivatedType(null); pass(game); @@ -1252,7 +1255,7 @@ public class HumanPlayer extends PlayerImpl { // it's main step if (!skippedAtLeastOnce || (!playerId.equals(game.getActivePlayerId()) - && !controllingPlayer.getUserData().getUserSkipPrioritySteps().isStopOnAllMainPhases())) { + && !controllingUserData.getUserSkipPrioritySteps().isStopOnAllMainPhases())) { skippedAtLeastOnce = true; if (passWithManaPoolCheck(game)) { return false; @@ -1274,8 +1277,7 @@ public class HumanPlayer extends PlayerImpl { // it's end of turn step if (!skippedAtLeastOnce || (playerId.equals(game.getActivePlayerId()) - && !controllingPlayer - .getUserData() + && !controllingUserData .getUserSkipPrioritySteps() .isStopOnAllEndPhases())) { skippedAtLeastOnce = true; @@ -1295,7 +1297,7 @@ public class HumanPlayer extends PlayerImpl { } if (!dontCheckPassStep - && checkPassStep(game, controllingPlayer)) { + && checkPassStep(game, controllingUserData)) { if (passWithManaPoolCheck(game)) { return false; } @@ -1308,8 +1310,7 @@ public class HumanPlayer extends PlayerImpl { if (passedUntilStackResolved) { if (haveNewObjectsOnStack && (playerId.equals(game.getActivePlayerId()) - && controllingPlayer - .getUserData() + && controllingUserData .getUserSkipPrioritySteps() .isStopOnStackNewObjects())) { // new objects on stack -- disable "pass until stack resolved" @@ -1433,17 +1434,17 @@ public class HumanPlayer extends PlayerImpl { return response.getUUID(); } - private boolean checkPassStep(Game game, HumanPlayer controllingPlayer) { + private boolean checkPassStep(Game game, UserData controllingUserData) { try { if (playerId.equals(game.getActivePlayerId())) { - return !controllingPlayer.getUserData().getUserSkipPrioritySteps().getYourTurn().isPhaseStepSet(game.getTurnStepType()); + return !controllingUserData.getUserSkipPrioritySteps().getYourTurn().isPhaseStepSet(game.getTurnStepType()); } else { - return !controllingPlayer.getUserData().getUserSkipPrioritySteps().getOpponentTurn().isPhaseStepSet(game.getTurnStepType()); + return !controllingUserData.getUserSkipPrioritySteps().getOpponentTurn().isPhaseStepSet(game.getTurnStepType()); } } catch (NullPointerException ex) { - if (controllingPlayer.getUserData() != null) { - if (controllingPlayer.getUserData().getUserSkipPrioritySteps() != null) { + if (controllingUserData != null) { + if (controllingUserData.getUserSkipPrioritySteps() != null) { if (game.getStep() != null) { if (game.getTurnStepType() == null) { logger.error("game.getTurnStepType() == null"); @@ -2929,19 +2930,8 @@ public class HumanPlayer extends PlayerImpl { protected boolean passWithManaPoolCheck(Game game) { if (userData.confirmEmptyManaPool() && game.getStack().isEmpty() && getManaPool().count() > 0 && getManaPool().canLostManaOnEmpty()) { - String activePlayerText; - if (game.isActivePlayer(playerId)) { - activePlayerText = "Your turn"; - } else { - activePlayerText = game.getPlayer(game.getActivePlayerId()).getName() + "'s turn"; - } - String priorityPlayerText = ""; - if (!isGameUnderControl()) { - priorityPlayerText = " / priority " + game.getPlayer(game.getPriorityPlayerId()).getName(); - } - // TODO: chooseUse and other dialogs must be under controlling player - if (!chooseUse(Outcome.Detriment, GameLog.getPlayerConfirmColoredText("You still have mana in your mana pool and it will be lose. Pass anyway?") - + GameLog.getSmallSecondLineText(activePlayerText + " / " + game.getTurnStepType().toString() + priorityPlayerText), null, game)) { + String message = GameLog.getPlayerConfirmColoredText("You still have mana in your mana pool and it will be lose. Pass anyway?"); + if (!chooseUse(Outcome.Detriment, message, null, game)) { sendPlayerAction(PlayerAction.PASS_PRIORITY_CANCEL_ALL_ACTIONS, game, null); return false; } diff --git a/Mage.Server/src/main/java/mage/server/game/GameController.java b/Mage.Server/src/main/java/mage/server/game/GameController.java index 2a768f4ebd9..868c161ae34 100644 --- a/Mage.Server/src/main/java/mage/server/game/GameController.java +++ b/Mage.Server/src/main/java/mage/server/game/GameController.java @@ -906,14 +906,14 @@ public class GameController implements GameCallback { perform(playerId, playerId1 -> getGameSession(playerId1).getMultiAmount(messages, min, max, options)); } - private void informOthers(UUID playerId) { + private void informOthers(UUID waitingPlayerId) { StringBuilder message = new StringBuilder(); if (game.getStep() != null) { message.append(game.getTurnStepType().toString()).append(" - "); } - message.append("Waiting for ").append(game.getPlayer(playerId).getLogName()); + message.append("Waiting for ").append(game.getPlayer(waitingPlayerId).getLogName()); for (final Entry entry : getGameSessionsMap().entrySet()) { - if (!entry.getKey().equals(playerId)) { + if (!entry.getKey().equals(waitingPlayerId)) { entry.getValue().inform(message.toString()); } } @@ -1030,7 +1030,7 @@ public class GameController implements GameCallback { // TODO: if watcher disconnects then game freezes with active timer, must be fix for such use case // same for another player (can be fixed by super-duper connection) if (informOthers) { - informOthers(playerId); + informOthers(realPlayerController.getId()); } } diff --git a/Mage.Server/src/main/java/mage/server/game/GameSessionPlayer.java b/Mage.Server/src/main/java/mage/server/game/GameSessionPlayer.java index 790d85d31db..b4b7bd6c7c3 100644 --- a/Mage.Server/src/main/java/mage/server/game/GameSessionPlayer.java +++ b/Mage.Server/src/main/java/mage/server/game/GameSessionPlayer.java @@ -212,13 +212,14 @@ public class GameSessionPlayer extends GameSessionWatcher { // game view calculation can take some time and can be called from non-game thread, // so use copy for thread save (protection from ConcurrentModificationException) Game sourceGame = game.copy(); - - Player player = sourceGame.getPlayer(playerId); // null for watcher GameView gameView = new GameView(sourceGame.getState(), sourceGame, playerId, null); - if (player != null) { - if (gameView.getPriorityPlayerName().equals(player.getName())) { - gameView.setCanPlayObjects(player.getPlayableObjects(sourceGame, Zone.ALL)); - } + + // playable info (if opponent under control then show opponent's playable) + Player player = sourceGame.getPlayer(playerId); // null for watcher + Player priorityPlayer = sourceGame.getPlayer(sourceGame.getPriorityPlayerId()); + Player controllingPlayer = priorityPlayer == null ? null : sourceGame.getPlayer(priorityPlayer.getTurnControlledBy()); + if (controllingPlayer != null && player == controllingPlayer) { + gameView.setCanPlayObjects(priorityPlayer.getPlayableObjects(sourceGame, Zone.ALL)); } processControlledPlayers(sourceGame, player, gameView); diff --git a/Mage.Tests/src/test/java/org/mage/test/load/LoadCallbackClient.java b/Mage.Tests/src/test/java/org/mage/test/load/LoadCallbackClient.java index f37ff6f5aac..e69ee664e21 100644 --- a/Mage.Tests/src/test/java/org/mage/test/load/LoadCallbackClient.java +++ b/Mage.Tests/src/test/java/org/mage/test/load/LoadCallbackClient.java @@ -104,18 +104,17 @@ public class LoadCallbackClient implements CallbackClient { GameClientMessage message = (GameClientMessage) callback.getData(); this.gameView = message.getGameView(); log.info(getLogStartInfo() + " target: " + message.getMessage()); - switch (message.getMessage()) { - case "Select a starting player": - session.sendPlayerUUID(gameId, playerId); - return; - case "Select a card to discard": - log.info(getLogStartInfo() + "hand size: " + gameView.getMyHand().size()); - SimpleCardView card = gameView.getMyHand().values().iterator().next(); - session.sendPlayerUUID(gameId, card.getId()); - return; - default: - log.error(getLogStartInfo() + "unknown GAME_TARGET message: " + message.toString()); - return; + if (message.getMessage().startsWith("Select a starting player")) { + session.sendPlayerUUID(gameId, playerId); + return; + } else if (message.getMessage().startsWith("Select a card to discard")) { + log.info(getLogStartInfo() + "hand size: " + gameView.getMyHand().size()); + SimpleCardView card = gameView.getMyHand().values().iterator().next(); + session.sendPlayerUUID(gameId, card.getId()); + return; + } else { + log.error(getLogStartInfo() + "unknown GAME_TARGET message: " + message.toString()); + return; } } diff --git a/Mage/src/main/java/mage/abilities/common/delayed/AtTheEndOfTurnStepPostDelayedTriggeredAbility.java b/Mage/src/main/java/mage/abilities/common/delayed/AtTheEndOfTurnStepPostDelayedTriggeredAbility.java deleted file mode 100644 index d0078125fa3..00000000000 --- a/Mage/src/main/java/mage/abilities/common/delayed/AtTheEndOfTurnStepPostDelayedTriggeredAbility.java +++ /dev/null @@ -1,42 +0,0 @@ - -package mage.abilities.common.delayed; - -import mage.abilities.DelayedTriggeredAbility; -import mage.abilities.effects.Effect; -import mage.game.Game; -import mage.game.events.GameEvent; - -/** - * @author nantuko - */ -public class AtTheEndOfTurnStepPostDelayedTriggeredAbility extends DelayedTriggeredAbility { - - public AtTheEndOfTurnStepPostDelayedTriggeredAbility(Effect effect) { - this(effect, false); - } - - public AtTheEndOfTurnStepPostDelayedTriggeredAbility(Effect effect, boolean usesStack) { - super(effect); - this.usesStack = usesStack; - setTriggerPhrase("At end of turn "); - } - - public AtTheEndOfTurnStepPostDelayedTriggeredAbility(AtTheEndOfTurnStepPostDelayedTriggeredAbility ability) { - super(ability); - } - - @Override - public AtTheEndOfTurnStepPostDelayedTriggeredAbility copy() { - return new AtTheEndOfTurnStepPostDelayedTriggeredAbility(this); - } - - @Override - public boolean checkEventType(GameEvent event, Game game) { - return event.getType() == GameEvent.EventType.END_TURN_STEP_POST; - } - - @Override - public boolean checkTrigger(GameEvent event, Game game) { - return true; - } -} diff --git a/Mage/src/main/java/mage/game/Game.java b/Mage/src/main/java/mage/game/Game.java index c6d8c2b0aa0..0dd53c256e2 100644 --- a/Mage/src/main/java/mage/game/Game.java +++ b/Mage/src/main/java/mage/game/Game.java @@ -550,6 +550,12 @@ public interface Game extends MageItem, Serializable, Copyable { @Deprecated // TODO: must research usage and remove it from all non engine code (example: Bestow ability, ProcessActions must be used instead) boolean checkStateAndTriggered(); + /** + * Play priority by all players + * + * @param activePlayerId starting priority player + * @param resuming false to reset passed priority and ask it again + */ void playPriority(UUID activePlayerId, boolean resuming); void resetControlAfterSpellResolve(UUID topId); diff --git a/Mage/src/main/java/mage/game/GameImpl.java b/Mage/src/main/java/mage/game/GameImpl.java index 66ea48734b6..dc2cb22fbaa 100644 --- a/Mage/src/main/java/mage/game/GameImpl.java +++ b/Mage/src/main/java/mage/game/GameImpl.java @@ -2987,21 +2987,35 @@ public abstract class GameImpl implements Game { } String message; if (this.canPlaySorcery(playerId)) { - message = "Play spells and abilities."; + message = "Play spells and abilities"; } else { - message = "Play instants and activated abilities."; + message = "Play instants and activated abilities"; } - playerQueryEventSource.select(playerId, message); + + message += getControllingPlayerHint(playerId); + + Player player = this.getPlayer(playerId); + playerQueryEventSource.select(player.getTurnControlledBy(), message); getState().clearLookedAt(); getState().clearRevealed(); } + private String getControllingPlayerHint(UUID playerId) { + Player player = this.getPlayer(playerId); + Player controllingPlayer = this.getPlayer(player.getTurnControlledBy()); + if (player != controllingPlayer) { + return " (as " + player.getLogName() + ")"; + } else { + return ""; + } + } + @Override public synchronized void fireSelectEvent(UUID playerId, String message) { if (simulation) { return; } - playerQueryEventSource.select(playerId, message); + playerQueryEventSource.select(playerId, message + getControllingPlayerHint(playerId)); } @Override @@ -3009,7 +3023,7 @@ public abstract class GameImpl implements Game { if (simulation) { return; } - playerQueryEventSource.select(playerId, message, options); + playerQueryEventSource.select(playerId, message + getControllingPlayerHint(playerId), options); } @Override @@ -3017,7 +3031,7 @@ public abstract class GameImpl implements Game { if (simulation) { return; } - playerQueryEventSource.playMana(playerId, message, options); + playerQueryEventSource.playMana(playerId, message + getControllingPlayerHint(playerId), options); } @Override @@ -3025,7 +3039,7 @@ public abstract class GameImpl implements Game { if (simulation) { return; } - playerQueryEventSource.playXMana(playerId, message); + playerQueryEventSource.playXMana(playerId, message + getControllingPlayerHint(playerId)); } @Override @@ -3038,7 +3052,7 @@ public abstract class GameImpl implements Game { if (simulation) { return; } - playerQueryEventSource.ask(playerId, message.getMessage(), source, addMessageToOptions(message, options)); + playerQueryEventSource.ask(playerId, message.getMessage() + getControllingPlayerHint(playerId), source, addMessageToOptions(message, options)); } @Override @@ -3050,7 +3064,7 @@ public abstract class GameImpl implements Game { if (object != null) { objectName = object.getName(); } - playerQueryEventSource.chooseAbility(playerId, message, objectName, choices); + playerQueryEventSource.chooseAbility(playerId, message + getControllingPlayerHint(playerId), objectName, choices); } @Override @@ -3058,7 +3072,7 @@ public abstract class GameImpl implements Game { if (simulation) { return; } - playerQueryEventSource.chooseMode(playerId, message, modes); + playerQueryEventSource.chooseMode(playerId, message + getControllingPlayerHint(playerId), modes); } @Override @@ -3066,7 +3080,7 @@ public abstract class GameImpl implements Game { if (simulation) { return; } - playerQueryEventSource.target(playerId, message.getMessage(), targets, required, addMessageToOptions(message, options)); + playerQueryEventSource.target(playerId, message.getMessage() + getControllingPlayerHint(playerId), targets, required, addMessageToOptions(message, options)); } @Override @@ -3074,7 +3088,7 @@ public abstract class GameImpl implements Game { if (simulation) { return; } - playerQueryEventSource.target(playerId, message.getMessage(), cards, required, addMessageToOptions(message, options)); + playerQueryEventSource.target(playerId, message.getMessage() + getControllingPlayerHint(playerId), cards, required, addMessageToOptions(message, options)); } /** @@ -3087,7 +3101,7 @@ public abstract class GameImpl implements Game { */ @Override public void fireSelectTargetTriggeredAbilityEvent(UUID playerId, String message, List abilities) { - playerQueryEventSource.target(playerId, message, abilities); + playerQueryEventSource.target(playerId, message + getControllingPlayerHint(playerId), abilities); } @Override @@ -3095,7 +3109,7 @@ public abstract class GameImpl implements Game { if (simulation) { return; } - playerQueryEventSource.target(playerId, message, perms, required); + playerQueryEventSource.target(playerId, message + getControllingPlayerHint(playerId), perms, required); } @Override @@ -3103,7 +3117,7 @@ public abstract class GameImpl implements Game { if (simulation) { return; } - playerQueryEventSource.amount(playerId, message, min, max); + playerQueryEventSource.amount(playerId, message + getControllingPlayerHint(playerId), min, max); } @Override @@ -3128,7 +3142,7 @@ public abstract class GameImpl implements Game { if (simulation) { return; } - playerQueryEventSource.choosePile(playerId, message, pile1, pile2); + playerQueryEventSource.choosePile(playerId, message + getControllingPlayerHint(playerId), pile1, pile2); } @Override diff --git a/Mage/src/main/java/mage/game/turn/BeginningPhase.java b/Mage/src/main/java/mage/game/turn/BeginningPhase.java index c62795ccc74..5a7da443928 100644 --- a/Mage/src/main/java/mage/game/turn/BeginningPhase.java +++ b/Mage/src/main/java/mage/game/turn/BeginningPhase.java @@ -1,5 +1,3 @@ - - package mage.game.turn; import mage.constants.TurnPhase; diff --git a/Mage/src/main/java/mage/game/turn/CleanupStep.java b/Mage/src/main/java/mage/game/turn/CleanupStep.java index 5bfe000c562..be102a7816c 100644 --- a/Mage/src/main/java/mage/game/turn/CleanupStep.java +++ b/Mage/src/main/java/mage/game/turn/CleanupStep.java @@ -1,14 +1,12 @@ - - package mage.game.turn; -import java.util.UUID; - import mage.constants.PhaseStep; import mage.game.Game; import mage.game.events.GameEvent.EventType; import mage.players.Player; +import java.util.UUID; + /** * @author BetaSteward_at_googlemail.com */ @@ -30,19 +28,31 @@ public class CleanupStep extends Step { super.beginStep(game, activePlayerId); Player activePlayer = game.getPlayer(activePlayerId); game.getState().setPriorityPlayerId(activePlayer.getId()); - //20091005 - 514.1 + + // 514.1 + // First, if the active player’s hand contains more cards than his or her maximum hand size + // (normally seven), he or she discards enough cards to reduce his or her hand size to that number. + // This turn-based action doesn’t use the stack. if (activePlayer.isInGame()) { activePlayer.discardToMax(game); } - //20100423 - 514.2 + + // 514.2 + // Second, the following actions happen simultaneously: all damage marked on permanents + // (including phased-out permanents) is removed and all "until end of turn" and "this turn" + // effects end. This turn-based action doesn’t use the stack. game.getBattlefield().endOfTurn(game); game.getState().removeEotEffects(game); + + // 514.3 + // Normally, no player receives priority during the cleanup step, so no spells can be cast + // and no abilities can be activated. However, this rule is subject to the following exception: 514.3a + // + // Look at EndPhase code to process 514.3 } @Override public void endStep(Game game, UUID activePlayerId) { - Player activePlayer = game.getPlayer(activePlayerId); - activePlayer.setGameUnderYourControl(true); super.endStep(game, activePlayerId); } diff --git a/Mage/src/main/java/mage/game/turn/EndPhase.java b/Mage/src/main/java/mage/game/turn/EndPhase.java index 03e69c4e8bc..d9dc0431cb5 100644 --- a/Mage/src/main/java/mage/game/turn/EndPhase.java +++ b/Mage/src/main/java/mage/game/turn/EndPhase.java @@ -31,16 +31,21 @@ public class EndPhase extends Phase { game.getState().increaseStepNum(); game.getTurn().setEndTurnRequested(false); // so triggers trigger again prePriority(game, activePlayerId); - // 514.3a At this point, the game checks to see if any state-based actions would be performed + + // 514.3. + // Normally, no player receives priority during the cleanup step, so no spells can be cast and + // no abilities can be activated. However, this rule is subject to the following exception: + // 514.3a + // At this point, the game checks to see if any state-based actions would be performed // and/or any triggered abilities are waiting to be put onto the stack (including those that // trigger "at the beginning of the next cleanup step"). If so, those state-based actions are // performed, then those triggered abilities are put on the stack, then the active player gets // priority. Players may cast spells and activate abilities. Once the stack is empty and all players // pass in succession, another cleanup step begins if (game.checkStateAndTriggered()) { - // Queues a new cleanup step + game.informPlayers("State-based actions or triggers happened on cleanup step, so players get priority due 514.3a"); + // queues a new cleanup step and request new priorities game.getState().getTurnMods().add(new TurnMod(activePlayerId).withExtraStep(new CleanupStep())); - // resume priority if (!game.isPaused() && !game.checkIfGameIsOver() && !game.executingRollback()) { currentStep.priority(game, activePlayerId, false); if (game.executingRollback()) { diff --git a/Mage/src/main/java/mage/game/turn/Step.java b/Mage/src/main/java/mage/game/turn/Step.java index 24e6e7debf6..56c18402355 100644 --- a/Mage/src/main/java/mage/game/turn/Step.java +++ b/Mage/src/main/java/mage/game/turn/Step.java @@ -60,6 +60,12 @@ public abstract class Step implements Serializable, Copyable { stepPart = StepPart.PRE; } + /** + * Play priority by all players + * + * @param activePlayerId starting priority player + * @param resuming false to reset passed priority and ask it again + */ public void priority(Game game, UUID activePlayerId, boolean resuming) { if (hasPriority) { stepPart = StepPart.PRIORITY; diff --git a/Mage/src/main/java/mage/game/turn/Turn.java b/Mage/src/main/java/mage/game/turn/Turn.java index 4c1d76fac3e..1ada5011092 100644 --- a/Mage/src/main/java/mage/game/turn/Turn.java +++ b/Mage/src/main/java/mage/game/turn/Turn.java @@ -107,12 +107,16 @@ public class Turn implements Serializable { )); return true; } - logStartOfTurn(game, activePlayer); - checkTurnIsControlledByOtherPlayer(game, activePlayer.getId()); + logStartOfTurn(game, activePlayer); + resetCounts(); this.activePlayerId = activePlayer.getId(); - resetCounts(); + this.currentPhase = null; + + // turn control must be called after potential turn skip due 720.1. + checkTurnIsControlledByOtherPlayer(game, activePlayer.getId()); + game.getPlayer(activePlayer.getId()).beginTurn(game); for (Phase phase : phases) { if (game.isPaused() || game.checkIfGameIsOver()) { @@ -220,8 +224,31 @@ public class Turn implements Serializable { } private void checkTurnIsControlledByOtherPlayer(Game game, UUID activePlayerId) { + // 720.1. + // Some cards allow a player to control another player during that player’s next turn. + // This effect applies to the next turn that the affected player actually takes. + // The affected player is controlled during the entire turn; the effect doesn’t end until + // the beginning of the next turn. + // + // 720.1b + // If a turn is skipped, any pending player-controlling effects wait until the player who would be + // affected actually takes a turn. + + // remove old under control + game.getPlayers().values().forEach(player -> { + if (player.isInGame() && !player.isGameUnderControl()) { + Player controllingPlayer = game.getPlayer(player.getTurnControlledBy()); + if (player != controllingPlayer && controllingPlayer != null) { + game.informPlayers(controllingPlayer.getLogName() + " lost control over " + player.getLogName()); + } + player.setGameUnderYourControl(true); + } + }); + + // add new under control TurnMod newControllerMod = game.getState().getTurnMods().useNextNewController(activePlayerId); if (newControllerMod != null && !newControllerMod.getNewControllerId().equals(activePlayerId)) { + // set player under new control // game logs added in child's call (controlPlayersTurn) game.getPlayer(newControllerMod.getNewControllerId()).controlPlayersTurn(game, activePlayerId, newControllerMod.getInfo()); } diff --git a/Mage/src/main/java/mage/players/Player.java b/Mage/src/main/java/mage/players/Player.java index 257f6443dc7..3f3073986d2 100644 --- a/Mage/src/main/java/mage/players/Player.java +++ b/Mage/src/main/java/mage/players/Player.java @@ -366,7 +366,7 @@ public interface Player extends MageItem, Copyable { boolean isGameUnderControl(); /** - * Returns false in case you don't control the game. + * False in case you don't control the game. *

* Note: For effects like "You control target player during that player's * next turn". diff --git a/Mage/src/main/java/mage/players/PlayerImpl.java b/Mage/src/main/java/mage/players/PlayerImpl.java index 4492b47d95a..87e0bac8875 100644 --- a/Mage/src/main/java/mage/players/PlayerImpl.java +++ b/Mage/src/main/java/mage/players/PlayerImpl.java @@ -7,7 +7,6 @@ import mage.abilities.ActivatedAbility.ActivationStatus; import mage.abilities.common.PassAbility; import mage.abilities.common.PlayLandAsCommanderAbility; import mage.abilities.common.WhileSearchingPlayFromLibraryAbility; -import mage.abilities.common.delayed.AtTheEndOfTurnStepPostDelayedTriggeredAbility; import mage.abilities.costs.*; import mage.abilities.costs.mana.AlternateManaPaymentAbility; import mage.abilities.costs.mana.ManaCost; @@ -15,7 +14,6 @@ import mage.abilities.costs.mana.ManaCosts; import mage.abilities.costs.mana.ManaCostsImpl; import mage.abilities.effects.RestrictionEffect; import mage.abilities.effects.RestrictionUntapNotMoreThanEffect; -import mage.abilities.effects.common.LoseControlOnOtherPlayersControllerEffect; import mage.abilities.keyword.*; import mage.abilities.mana.ActivatedManaAbilityImpl; import mage.abilities.mana.ManaOptions; @@ -609,11 +607,7 @@ public abstract class PlayerImpl implements Player, Serializable { if (!playerUnderControl.hasLeft() && !playerUnderControl.hasLost()) { playerUnderControl.setGameUnderYourControl(false); } - DelayedTriggeredAbility ability = new AtTheEndOfTurnStepPostDelayedTriggeredAbility( - new LoseControlOnOtherPlayersControllerEffect(this.getLogName(), playerUnderControl.getLogName())); - ability.setSourceId(getId()); - ability.setControllerId(getId()); - game.addDelayedTriggeredAbility(ability, null); + // control will reset on start of the turn } } diff --git a/Mage/src/main/java/mage/util/CardUtil.java b/Mage/src/main/java/mage/util/CardUtil.java index 609861d1f91..d77bdca4a6a 100644 --- a/Mage/src/main/java/mage/util/CardUtil.java +++ b/Mage/src/main/java/mage/util/CardUtil.java @@ -2127,9 +2127,10 @@ public final class CardUtil { return null; } - // not started game + // T0 - for not started game + // T2 - for starting of the turn if (gameState.getTurn().getStep() == null) { - return "T0"; + return "T" + gameState.getTurnNum(); } // normal game diff --git a/Mage/src/main/java/mage/util/GameLog.java b/Mage/src/main/java/mage/util/GameLog.java index 4e12e9fe55d..b2a6bae3b27 100644 --- a/Mage/src/main/java/mage/util/GameLog.java +++ b/Mage/src/main/java/mage/util/GameLog.java @@ -160,10 +160,6 @@ public final class GameLog { return "" + name + ""; } - public static String getSmallSecondLineText(String text) { - return "

" + text + "
"; - } - private static String getColorName(ObjectColor objectColor) { if (objectColor.isMulticolored()) { return LOG_COLOR_MULTI; -- 2.47.2 From 5626c5f932da749c8a7b2a290d929400c51e4d1f Mon Sep 17 00:00:00 2001 From: Oleg Agafonov Date: Tue, 7 Jan 2025 19:47:25 +0400 Subject: [PATCH 24/32] cheats: added default commands to take and remove control over another player (related to #12878) --- .../src/main/java/mage/utils/SystemUtil.java | 39 ++++++++++++++++++- Mage/src/main/java/mage/game/turn/Turn.java | 1 - .../main/java/mage/game/turn/TurnMods.java | 8 ++++ Mage/src/main/java/mage/util/CardUtil.java | 2 +- 4 files changed, 46 insertions(+), 4 deletions(-) diff --git a/Mage.Common/src/main/java/mage/utils/SystemUtil.java b/Mage.Common/src/main/java/mage/utils/SystemUtil.java index 9d5f3f3593b..604ae7f361a 100644 --- a/Mage.Common/src/main/java/mage/utils/SystemUtil.java +++ b/Mage.Common/src/main/java/mage/utils/SystemUtil.java @@ -24,6 +24,8 @@ import mage.game.command.Plane; import mage.game.permanent.Permanent; import mage.game.permanent.token.Token; import mage.players.Player; +import mage.target.Target; +import mage.target.TargetPlayer; import mage.util.CardUtil; import mage.util.MultiAmountMessage; import mage.util.RandomUtil; @@ -66,6 +68,8 @@ public final class SystemUtil { // [@mana add] -> MANA ADD private static final String COMMAND_CARDS_ADD_TO_HAND = "@card add to hand"; private static final String COMMAND_LANDS_ADD_TO_BATTLEFIELD = "@lands add"; + private static final String COMMAND_OPPONENT_UNDER_CONTROL_START = "@opponent under control start"; + private static final String COMMAND_OPPONENT_UNDER_CONTROL_END = "@opponent under control end"; private static final String COMMAND_MANA_ADD = "@mana add"; // TODO: not implemented private static final String COMMAND_RUN_CUSTOM_CODE = "@run custom code"; // TODO: not implemented private static final String COMMAND_SHOW_OPPONENT_HAND = "@show opponent hand"; @@ -80,6 +84,8 @@ public final class SystemUtil { supportedCommands.put(COMMAND_CARDS_ADD_TO_HAND, "CARDS: ADD TO HAND"); supportedCommands.put(COMMAND_MANA_ADD, "MANA ADD"); supportedCommands.put(COMMAND_LANDS_ADD_TO_BATTLEFIELD, "LANDS: ADD TO BATTLEFIELD"); + supportedCommands.put(COMMAND_OPPONENT_UNDER_CONTROL_START, "OPPONENT CONTROL: ENABLE"); + supportedCommands.put(COMMAND_OPPONENT_UNDER_CONTROL_END, "OPPONENT CONTROL: DISABLE"); supportedCommands.put(COMMAND_RUN_CUSTOM_CODE, "RUN CUSTOM CODE"); supportedCommands.put(COMMAND_SHOW_OPPONENT_HAND, "SHOW OPPONENT HAND"); supportedCommands.put(COMMAND_SHOW_OPPONENT_LIBRARY, "SHOW OPPONENT LIBRARY"); @@ -255,7 +261,7 @@ public final class SystemUtil { * * @param game * @param commandsFilePath file path with commands in init.txt format - * @param feedbackPlayer player to execute that cheats (will see choose dialogs) + * @param feedbackPlayer player to execute that cheats (will see choose dialogs) */ public static void executeCheatCommands(Game game, String commandsFilePath, Player feedbackPlayer) { @@ -301,6 +307,8 @@ public final class SystemUtil { // add default commands initLines.add(0, String.format("[%s]", COMMAND_LANDS_ADD_TO_BATTLEFIELD)); initLines.add(1, String.format("[%s]", COMMAND_CARDS_ADD_TO_HAND)); + initLines.add(2, String.format("[%s]", COMMAND_OPPONENT_UNDER_CONTROL_START)); + initLines.add(3, String.format("[%s]", COMMAND_OPPONENT_UNDER_CONTROL_END)); // collect all commands CommandGroup currentGroup = null; @@ -544,6 +552,34 @@ public final class SystemUtil { break; } + case COMMAND_OPPONENT_UNDER_CONTROL_START: { + Target target = new TargetPlayer().withNotTarget(true).withChooseHint("to take under your control"); + if (feedbackPlayer.chooseTarget(Outcome.GainControl, target, fakeSourceAbilityTemplate, game)) { + Player targetPlayer = game.getPlayer(target.getFirstTarget()); + if (targetPlayer != null && targetPlayer != feedbackPlayer) { + CardUtil.takeControlUnderPlayerStart(game, fakeSourceAbilityTemplate, feedbackPlayer, targetPlayer, false); + // allow priority play again in same step (for better cheat UX) + targetPlayer.resetPassed(); + } + // workaround for refresh priority dialog like avatar click (cheats called from priority in 99%) + game.firePriorityEvent(feedbackPlayer.getId()); + } + break; + } + + case COMMAND_OPPONENT_UNDER_CONTROL_END: { + Target target = new TargetPlayer().withNotTarget(true).withChooseHint("to free from your control"); + if (feedbackPlayer.chooseTarget(Outcome.GainControl, target, fakeSourceAbilityTemplate, game)) { + Player targetPlayer = game.getPlayer(target.getFirstTarget()); + if (targetPlayer != null && targetPlayer != feedbackPlayer && !targetPlayer.isGameUnderControl()) { + CardUtil.takeControlUnderPlayerEnd(game, fakeSourceAbilityTemplate, feedbackPlayer, targetPlayer); + } + // workaround for refresh priority dialog like avatar click (cheats called from priority in 99%) + game.firePriorityEvent(feedbackPlayer.getId()); + } + break; + } + default: { String mes = String.format("Unknown system command: %s", runGroup.name); errorsList.add(mes); @@ -551,7 +587,6 @@ public final class SystemUtil { break; } } - sendCheatCommandsFeedback(game, feedbackPlayer, errorsList); return; } diff --git a/Mage/src/main/java/mage/game/turn/Turn.java b/Mage/src/main/java/mage/game/turn/Turn.java index 1ada5011092..f163c2e7e9c 100644 --- a/Mage/src/main/java/mage/game/turn/Turn.java +++ b/Mage/src/main/java/mage/game/turn/Turn.java @@ -248,7 +248,6 @@ public class Turn implements Serializable { // add new under control TurnMod newControllerMod = game.getState().getTurnMods().useNextNewController(activePlayerId); if (newControllerMod != null && !newControllerMod.getNewControllerId().equals(activePlayerId)) { - // set player under new control // game logs added in child's call (controlPlayersTurn) game.getPlayer(newControllerMod.getNewControllerId()).controlPlayersTurn(game, activePlayerId, newControllerMod.getInfo()); } diff --git a/Mage/src/main/java/mage/game/turn/TurnMods.java b/Mage/src/main/java/mage/game/turn/TurnMods.java index c127110ced2..6019143fe81 100644 --- a/Mage/src/main/java/mage/game/turn/TurnMods.java +++ b/Mage/src/main/java/mage/game/turn/TurnMods.java @@ -62,6 +62,14 @@ public class TurnMods extends ArrayList implements Serializable, Copyab } public TurnMod useNextNewController(UUID playerId) { + // 720.1a + // Multiple player-controlling effects that affect the same player overwrite each other. + // The last one to be created is the one that works. + // + // 720.1b + // If a turn is skipped, any pending player-controlling effects wait until the player + // who would be affected actually takes a turn. + TurnMod lastNewControllerMod = null; // find last/actual mod diff --git a/Mage/src/main/java/mage/util/CardUtil.java b/Mage/src/main/java/mage/util/CardUtil.java index d77bdca4a6a..3f85a6fa591 100644 --- a/Mage/src/main/java/mage/util/CardUtil.java +++ b/Mage/src/main/java/mage/util/CardUtil.java @@ -1380,7 +1380,7 @@ public final class CardUtil { public static void takeControlUnderPlayerEnd(Game game, Ability source, Player controller, Player playerUnderControl) { playerUnderControl.setGameUnderYourControl(true, false); if (!playerUnderControl.getTurnControlledBy().equals(controller.getId())) { - game.informPlayers(controller + " return control of the turn to " + playerUnderControl.getLogName() + CardUtil.getSourceLogName(game, source)); + game.informPlayers(controller.getLogName() + " return control of the turn to " + playerUnderControl.getLogName() + CardUtil.getSourceLogName(game, source)); controller.getPlayersUnderYourControl().remove(playerUnderControl.getId()); } } -- 2.47.2 From 0c8d49ce56796418505fca0f9ace35988a2eefb3 Mon Sep 17 00:00:00 2001 From: Oleg Agafonov Date: Tue, 7 Jan 2025 20:05:27 +0400 Subject: [PATCH 25/32] merge fix --- Mage.Common/src/main/java/mage/utils/SystemUtil.java | 10 ++++++---- .../mage/test/serverside/cheats/LoadCheatsTest.java | 4 ++-- 2 files changed, 8 insertions(+), 6 deletions(-) diff --git a/Mage.Common/src/main/java/mage/utils/SystemUtil.java b/Mage.Common/src/main/java/mage/utils/SystemUtil.java index 604ae7f361a..4f71a78bca2 100644 --- a/Mage.Common/src/main/java/mage/utils/SystemUtil.java +++ b/Mage.Common/src/main/java/mage/utils/SystemUtil.java @@ -554,10 +554,11 @@ public final class SystemUtil { case COMMAND_OPPONENT_UNDER_CONTROL_START: { Target target = new TargetPlayer().withNotTarget(true).withChooseHint("to take under your control"); - if (feedbackPlayer.chooseTarget(Outcome.GainControl, target, fakeSourceAbilityTemplate, game)) { + Ability fakeSourceAbility = fakeSourceAbilityTemplate.copy(); + if (feedbackPlayer.chooseTarget(Outcome.GainControl, target, fakeSourceAbility, game)) { Player targetPlayer = game.getPlayer(target.getFirstTarget()); if (targetPlayer != null && targetPlayer != feedbackPlayer) { - CardUtil.takeControlUnderPlayerStart(game, fakeSourceAbilityTemplate, feedbackPlayer, targetPlayer, false); + CardUtil.takeControlUnderPlayerStart(game, fakeSourceAbility, feedbackPlayer, targetPlayer, false); // allow priority play again in same step (for better cheat UX) targetPlayer.resetPassed(); } @@ -569,10 +570,11 @@ public final class SystemUtil { case COMMAND_OPPONENT_UNDER_CONTROL_END: { Target target = new TargetPlayer().withNotTarget(true).withChooseHint("to free from your control"); - if (feedbackPlayer.chooseTarget(Outcome.GainControl, target, fakeSourceAbilityTemplate, game)) { + Ability fakeSourceAbility = fakeSourceAbilityTemplate.copy(); + if (feedbackPlayer.chooseTarget(Outcome.GainControl, target, fakeSourceAbility, game)) { Player targetPlayer = game.getPlayer(target.getFirstTarget()); if (targetPlayer != null && targetPlayer != feedbackPlayer && !targetPlayer.isGameUnderControl()) { - CardUtil.takeControlUnderPlayerEnd(game, fakeSourceAbilityTemplate, feedbackPlayer, targetPlayer); + CardUtil.takeControlUnderPlayerEnd(game, fakeSourceAbility, feedbackPlayer, targetPlayer); } // workaround for refresh priority dialog like avatar click (cheats called from priority in 99%) game.firePriorityEvent(feedbackPlayer.getId()); diff --git a/Mage.Tests/src/test/java/org/mage/test/serverside/cheats/LoadCheatsTest.java b/Mage.Tests/src/test/java/org/mage/test/serverside/cheats/LoadCheatsTest.java index 4f72de91e3c..1a7d63ed24d 100644 --- a/Mage.Tests/src/test/java/org/mage/test/serverside/cheats/LoadCheatsTest.java +++ b/Mage.Tests/src/test/java/org/mage/test/serverside/cheats/LoadCheatsTest.java @@ -55,11 +55,11 @@ public class LoadCheatsTest extends CardTestPlayerBase { setStopAt(1, PhaseStep.BEGIN_COMBAT); execute(); - setChoice(playerA, "5"); // choose [group 3]: 5 = 2 default menus + 3 group + setChoice(playerA, "7"); // choose [group 3]: 7 = 4 default menus + 3 group SystemUtil.executeCheatCommands(currentGame, commandsFile, playerA); assertHandCount(playerA, "Razorclaw Bear", 1); assertPermanentCount(playerA, "Mountain", 3); - assertHandCount(playerA, "Island", 10); // by cheats + assertHandCount(playerA, "Island", 10); // possible fail: changed in amount of default cheat commands } } -- 2.47.2 From 41b9c95be96d4d01ff7d65ad570c2b03a9a20ed5 Mon Sep 17 00:00:00 2001 From: xenohedron Date: Wed, 8 Jan 2025 22:47:01 -0500 Subject: [PATCH 26/32] less intrusive error handling for #12833 --- .../org/mage/plugins/card/images/DownloadPicturesService.java | 4 ++-- .../main/java/org/mage/plugins/card/utils/CardImageUtils.java | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/Mage.Client/src/main/java/org/mage/plugins/card/images/DownloadPicturesService.java b/Mage.Client/src/main/java/org/mage/plugins/card/images/DownloadPicturesService.java index a6622c16c62..1bb365f6b96 100644 --- a/Mage.Client/src/main/java/org/mage/plugins/card/images/DownloadPicturesService.java +++ b/Mage.Client/src/main/java/org/mage/plugins/card/images/DownloadPicturesService.java @@ -725,8 +725,8 @@ public class DownloadPicturesService extends DefaultBoundedRangeModel implements try { TVFS.umount(); } catch (FsSyncException e) { - logger.fatal("Couldn't unmount zip files " + e, e); - MageFrame.getInstance().showErrorDialog("Couldn't unmount zip files " + e, e); + logger.error("Couldn't unmount zip files " + e, e); + // this is not a critical error - just need to run it again - see issue #12833 } } diff --git a/Mage.Client/src/main/java/org/mage/plugins/card/utils/CardImageUtils.java b/Mage.Client/src/main/java/org/mage/plugins/card/utils/CardImageUtils.java index 3ded259a466..1598840389a 100644 --- a/Mage.Client/src/main/java/org/mage/plugins/card/utils/CardImageUtils.java +++ b/Mage.Client/src/main/java/org/mage/plugins/card/utils/CardImageUtils.java @@ -185,7 +185,7 @@ public final class CardImageUtils { try { TVFS.umount(); } catch (FsSyncException e) { - LOGGER.fatal("Couldn't unmount zip files on searching broken images " + e, e); + LOGGER.error("Couldn't unmount zip files on searching broken images " + e, e); } // real images check is slow, so it used on images download only (not here) -- 2.47.2 From 9c5c394c75402040e8b4ad16ac3389552d4460db Mon Sep 17 00:00:00 2001 From: xenohedron Date: Wed, 8 Jan 2025 22:47:36 -0500 Subject: [PATCH 27/32] refactor: TargetCreatureOrPlayer inheritance (#13199) * update TargetCreatureOrPlayer to be a subclass of TargetPermanentOrPlayer closes #11161 * fix usages --- .../java/mage/player/ai/ComputerPlayer.java | 73 ------- Mage.Sets/src/mage/cards/s/StarDestroyer.java | 8 +- .../src/mage/cards/t/ThermalDetonator.java | 9 +- .../java/org/mage/test/player/TestPlayer.java | 10 - .../filter/common/FilterCreatureOrPlayer.java | 61 +----- .../target/common/TargetCreatureOrPlayer.java | 189 +----------------- 6 files changed, 10 insertions(+), 340 deletions(-) diff --git a/Mage.Server.Plugins/Mage.Player.AI/src/main/java/mage/player/ai/ComputerPlayer.java b/Mage.Server.Plugins/Mage.Player.AI/src/main/java/mage/player/ai/ComputerPlayer.java index bb1fd669b17..002a3f9d493 100644 --- a/Mage.Server.Plugins/Mage.Player.AI/src/main/java/mage/player/ai/ComputerPlayer.java +++ b/Mage.Server.Plugins/Mage.Player.AI/src/main/java/mage/player/ai/ComputerPlayer.java @@ -311,37 +311,6 @@ public class ComputerPlayer extends PlayerImpl { } } - if (target.getOriginalTarget() instanceof TargetCreatureOrPlayer) { - List targets; - TargetCreatureOrPlayer origTarget = (TargetCreatureOrPlayer) target.getOriginalTarget(); - if (outcome.isGood()) { - targets = threats(abilityControllerId, source, ((FilterCreatureOrPlayer) origTarget.getFilter()).getCreatureFilter(), game, target.getTargets()); - } else { - targets = threats(randomOpponentId, source, ((FilterCreatureOrPlayer) origTarget.getFilter()).getCreatureFilter(), game, target.getTargets()); - } - for (Permanent permanent : targets) { - List alreadyTargeted = target.getTargets(); - if (target.canTarget(abilityControllerId, permanent.getId(), null, game)) { - if (alreadyTargeted != null && !alreadyTargeted.contains(permanent.getId())) { - target.add(permanent.getId(), game); - return true; - } - } - } - if (outcome.isGood()) { - if (target.canTarget(abilityControllerId, abilityControllerId, null, game)) { - target.add(abilityControllerId, game); - return true; - } - } else if (target.canTarget(abilityControllerId, randomOpponentId, null, game)) { - target.add(randomOpponentId, game); - return true; - } - if (!required) { - return false; - } - } - if (target.getOriginalTarget() instanceof TargetPermanentOrPlayer) { List targets; TargetPermanentOrPlayer origTarget = (TargetPermanentOrPlayer) target.getOriginalTarget(); @@ -752,48 +721,6 @@ public class ComputerPlayer extends PlayerImpl { return target.isChosen(game); } - if (target.getOriginalTarget() instanceof TargetCreatureOrPlayer) { - List targets; - TargetCreatureOrPlayer origTarget = ((TargetCreatureOrPlayer) target.getOriginalTarget()); - if (outcome.isGood()) { - targets = threats(abilityControllerId, source, ((FilterCreatureOrPlayer) origTarget.getFilter()).getCreatureFilter(), game, target.getTargets()); - } else { - targets = threats(randomOpponentId, source, ((FilterCreatureOrPlayer) origTarget.getFilter()).getCreatureFilter(), game, target.getTargets()); - } - - if (targets.isEmpty()) { - if (outcome.isGood()) { - if (target.canTarget(abilityControllerId, abilityControllerId, source, game)) { - return tryAddTarget(target, abilityControllerId, source, game); - } - } else if (target.canTarget(abilityControllerId, randomOpponentId, source, game)) { - return tryAddTarget(target, randomOpponentId, source, game); - } - } - - if (targets.isEmpty() && target.isRequired(source)) { - targets = game.getBattlefield().getActivePermanents(((FilterCreatureOrPlayer) origTarget.getFilter()).getCreatureFilter(), playerId, game); - } - for (Permanent permanent : targets) { - List alreadyTargeted = target.getTargets(); - if (target.canTarget(abilityControllerId, permanent.getId(), source, game)) { - if (alreadyTargeted != null && !alreadyTargeted.contains(permanent.getId())) { - return tryAddTarget(target, permanent.getId(), source, game); - } - } - } - - if (outcome.isGood()) { - if (target.canTarget(abilityControllerId, abilityControllerId, source, game)) { - return tryAddTarget(target, abilityControllerId, source, game); - } - } else if (target.canTarget(abilityControllerId, randomOpponentId, source, game)) { - return tryAddTarget(target, randomOpponentId, source, game); - } - - return false; - } - if (target.getOriginalTarget() instanceof TargetAnyTarget) { List targets; TargetAnyTarget origTarget = ((TargetAnyTarget) target.getOriginalTarget()); diff --git a/Mage.Sets/src/mage/cards/s/StarDestroyer.java b/Mage.Sets/src/mage/cards/s/StarDestroyer.java index 274c770005a..02bfcf1b8a1 100644 --- a/Mage.Sets/src/mage/cards/s/StarDestroyer.java +++ b/Mage.Sets/src/mage/cards/s/StarDestroyer.java @@ -1,7 +1,5 @@ - package mage.cards.s; -import java.util.UUID; import mage.MageInt; import mage.abilities.Ability; import mage.abilities.common.SimpleActivatedAbility; @@ -14,7 +12,6 @@ import mage.cards.CardImpl; import mage.cards.CardSetInfo; import mage.constants.CardType; import mage.constants.SubType; -import mage.constants.Zone; import mage.filter.common.FilterCreatureOrPlayer; import mage.filter.common.FilterCreaturePermanent; import mage.filter.predicate.Predicates; @@ -22,8 +19,9 @@ import mage.game.permanent.token.TIEFighterToken; import mage.target.common.TargetCreatureOrPlayer; import mage.target.common.TargetCreaturePermanent; +import java.util.UUID; + /** - * * @author Styxo */ public final class StarDestroyer extends CardImpl { @@ -33,7 +31,7 @@ public final class StarDestroyer extends CardImpl { static { filter1.add(CardType.ARTIFACT.getPredicate()); - filter3.getCreatureFilter().add(Predicates.not(SubType.STARSHIP.getPredicate())); + filter3.getPermanentFilter().add(Predicates.not(SubType.STARSHIP.getPredicate())); } public StarDestroyer(UUID ownerId, CardSetInfo setInfo) { diff --git a/Mage.Sets/src/mage/cards/t/ThermalDetonator.java b/Mage.Sets/src/mage/cards/t/ThermalDetonator.java index fb259b601d0..c10c5f6c530 100644 --- a/Mage.Sets/src/mage/cards/t/ThermalDetonator.java +++ b/Mage.Sets/src/mage/cards/t/ThermalDetonator.java @@ -1,6 +1,5 @@ package mage.cards.t; -import java.util.UUID; import mage.abilities.Ability; import mage.abilities.common.SimpleActivatedAbility; import mage.abilities.costs.common.SacrificeSourceCost; @@ -10,24 +9,22 @@ import mage.abilities.keyword.SpaceflightAbility; import mage.cards.CardImpl; import mage.cards.CardSetInfo; import mage.constants.CardType; -import mage.constants.Zone; import mage.filter.common.FilterCreatureOrPlayer; -import mage.filter.common.FilterCreaturePermanent; import mage.filter.predicate.Predicates; import mage.filter.predicate.mageobject.AbilityPredicate; import mage.target.common.TargetCreatureOrPlayer; +import java.util.UUID; + /** - * * @author NinthWorld */ public final class ThermalDetonator extends CardImpl { private static final FilterCreatureOrPlayer filter = new FilterCreatureOrPlayer("creature without spaceflight or target player"); - private static final FilterCreaturePermanent filterCreature = new FilterCreaturePermanent(); static { - filter.getCreatureFilter().add(Predicates.not(new AbilityPredicate(SpaceflightAbility.class))); + filter.getPermanentFilter().add(Predicates.not(new AbilityPredicate(SpaceflightAbility.class))); } public ThermalDetonator(UUID ownerId, CardSetInfo setInfo) { diff --git a/Mage.Tests/src/test/java/org/mage/test/player/TestPlayer.java b/Mage.Tests/src/test/java/org/mage/test/player/TestPlayer.java index b2433f4ff43..be036846ebc 100644 --- a/Mage.Tests/src/test/java/org/mage/test/player/TestPlayer.java +++ b/Mage.Tests/src/test/java/org/mage/test/player/TestPlayer.java @@ -2286,13 +2286,10 @@ public class TestPlayer implements Player { // TODO: Allow to choose a player with TargetPermanentOrPlayer if ((target.getOriginalTarget() instanceof TargetPermanent) - || (target.getOriginalTarget() instanceof TargetCreatureOrPlayer) // player target not implemented yet || (target.getOriginalTarget() instanceof TargetPermanentOrPlayer)) { // player target not implemented yet FilterPermanent filterPermanent; if (target.getOriginalTarget() instanceof TargetPermanentOrPlayer) { filterPermanent = ((TargetPermanentOrPlayer) target.getOriginalTarget()).getFilterPermanent(); - } else if (target.getOriginalTarget() instanceof TargetCreatureOrPlayer) { - filterPermanent = ((TargetCreatureOrPlayer) target.getOriginalTarget()).getFilterCreature(); } else { filterPermanent = ((TargetPermanent) target.getOriginalTarget()).getFilter(); } @@ -2516,8 +2513,6 @@ public class TestPlayer implements Player { // player if (target.getOriginalTarget() instanceof TargetPlayer - || target.getOriginalTarget() instanceof TargetAnyTarget - || target.getOriginalTarget() instanceof TargetCreatureOrPlayer || target.getOriginalTarget() instanceof TargetPermanentOrPlayer) { for (String targetDefinition : targets.stream().limit(takeMaxTargetsPerChoose).collect(Collectors.toList())) { if (!targetDefinition.startsWith("targetPlayer=")) { @@ -2539,8 +2534,6 @@ public class TestPlayer implements Player { // permanent in battlefield if ((target.getOriginalTarget() instanceof TargetPermanent) || (target.getOriginalTarget() instanceof TargetPermanentOrPlayer) - || (target.getOriginalTarget() instanceof TargetAnyTarget) - || (target.getOriginalTarget() instanceof TargetCreatureOrPlayer) || (target.getOriginalTarget() instanceof TargetPermanentOrSuspendedCard)) { for (String targetDefinition : targets.stream().limit(takeMaxTargetsPerChoose).collect(Collectors.toList())) { if (targetDefinition.startsWith("targetPlayer=")) { @@ -2564,9 +2557,6 @@ public class TestPlayer implements Player { } } Filter filter = target.getOriginalTarget().getFilter(); - if (filter instanceof FilterCreatureOrPlayer) { - filter = ((FilterCreatureOrPlayer) filter).getCreatureFilter(); - } if (filter instanceof FilterPermanentOrPlayer) { filter = ((FilterPermanentOrPlayer) filter).getPermanentFilter(); } diff --git a/Mage/src/main/java/mage/filter/common/FilterCreatureOrPlayer.java b/Mage/src/main/java/mage/filter/common/FilterCreatureOrPlayer.java index 3fd1003ca8b..665d4fd97f1 100644 --- a/Mage/src/main/java/mage/filter/common/FilterCreatureOrPlayer.java +++ b/Mage/src/main/java/mage/filter/common/FilterCreatureOrPlayer.java @@ -1,79 +1,22 @@ package mage.filter.common; -import mage.MageItem; -import mage.abilities.Ability; -import mage.filter.FilterImpl; -import mage.filter.FilterInPlay; import mage.filter.FilterPlayer; -import mage.game.Game; -import mage.game.permanent.Permanent; -import mage.players.Player; - -import java.util.UUID; /** * @author BetaSteward_at_googlemail.com */ -public class FilterCreatureOrPlayer extends FilterImpl implements FilterInPlay { - - protected FilterCreaturePermanent creatureFilter; - protected final FilterPlayer playerFilter; +public class FilterCreatureOrPlayer extends FilterPermanentOrPlayer { public FilterCreatureOrPlayer() { this("creature or player"); } public FilterCreatureOrPlayer(String name) { - super(name); - creatureFilter = new FilterCreaturePermanent(); - playerFilter = new FilterPlayer(); + super(name, new FilterCreaturePermanent(), new FilterPlayer()); } protected FilterCreatureOrPlayer(final FilterCreatureOrPlayer filter) { super(filter); - this.creatureFilter = filter.creatureFilter.copy(); - this.playerFilter = filter.playerFilter.copy(); - } - - @Override - public boolean checkObjectClass(Object object) { - return true; - } - - @Override - public boolean match(MageItem o, Game game) { - if (super.match(o, game)) { - if (o instanceof Player) { - return playerFilter.match((Player) o, game); - } else if (o instanceof Permanent) { - return creatureFilter.match((Permanent) o, game); - } - } - return false; - } - - @Override - public boolean match(MageItem o, UUID playerId, Ability source, Game game) { - if (super.match(o, game)) { // process predicates - if (o instanceof Player) { - return playerFilter.match((Player) o, playerId, source, game); - } else if (o instanceof Permanent) { - return creatureFilter.match((Permanent) o, playerId, source, game); - } - } - return false; - } - - public FilterCreaturePermanent getCreatureFilter() { - return this.creatureFilter; - } - - public FilterPlayer getPlayerFilter() { - return this.playerFilter; - } - - public void setCreatureFilter(FilterCreaturePermanent creatureFilter) { - this.creatureFilter = creatureFilter; } @Override diff --git a/Mage/src/main/java/mage/target/common/TargetCreatureOrPlayer.java b/Mage/src/main/java/mage/target/common/TargetCreatureOrPlayer.java index feb8640e659..838e9a95a41 100644 --- a/Mage/src/main/java/mage/target/common/TargetCreatureOrPlayer.java +++ b/Mage/src/main/java/mage/target/common/TargetCreatureOrPlayer.java @@ -1,26 +1,11 @@ package mage.target.common; -import mage.MageObject; -import mage.abilities.Ability; -import mage.constants.Zone; -import mage.filter.Filter; import mage.filter.common.FilterCreatureOrPlayer; -import mage.filter.common.FilterCreaturePermanent; -import mage.game.Game; -import mage.game.permanent.Permanent; -import mage.players.Player; -import mage.target.TargetImpl; - -import java.util.HashSet; -import java.util.Set; -import java.util.UUID; /** * @author BetaSteward_at_googlemail.com */ -public class TargetCreatureOrPlayer extends TargetImpl { - - protected FilterCreatureOrPlayer filter; +public class TargetCreatureOrPlayer extends TargetPermanentOrPlayer { public TargetCreatureOrPlayer() { this(1, 1, new FilterCreatureOrPlayer()); @@ -31,178 +16,11 @@ public class TargetCreatureOrPlayer extends TargetImpl { } public TargetCreatureOrPlayer(int minNumTargets, int maxNumTargets, FilterCreatureOrPlayer filter) { - this.minNumberOfTargets = minNumTargets; - this.maxNumberOfTargets = maxNumTargets; - this.zone = Zone.ALL; - this.filter = filter; - this.targetName = filter.getMessage(); + super(minNumTargets, maxNumTargets, filter, false); } protected TargetCreatureOrPlayer(final TargetCreatureOrPlayer target) { super(target); - this.filter = target.filter.copy(); - } - - @Override - public Filter getFilter() { - return this.filter; - } - - @Override - public boolean canTarget(UUID id, Game game) { - Permanent permanent = game.getPermanent(id); - if (permanent != null) { - return filter.match(permanent, game); - } - Player player = game.getPlayer(id); - return filter.match(player, game); - } - - @Override - public boolean canTarget(UUID id, Ability source, Game game) { - return canTarget(source.getControllerId(), id, source, game); - } - - @Override - public boolean canTarget(UUID controllerId, UUID id, Ability source, Game game) { - Permanent permanent = game.getPermanent(id); - Player player = game.getPlayer(id); - - if (source != null) { - MageObject targetSource = game.getObject(source); - if (permanent != null) { - return permanent.canBeTargetedBy(targetSource, source.getControllerId(), source, game) && filter.match(permanent, source.getControllerId(), source, game); - } - if (player != null) { - return player.canBeTargetedBy(targetSource, source.getControllerId(), source, game) && filter.match(player, game); - } - } - - if (permanent != null) { - return filter.match(permanent, game); - } - return filter.match(player, game); - } - - /** - * Checks if there are enough {@link Permanent} or {@link Player} that can - * be chosen. Should only be used for Ability targets since this checks for - * protection, shroud etc. - * - * @param sourceControllerId - controller of the target event source - * @param source - * @param game - * @return - true if enough valid {@link Permanent} or {@link Player} exist - */ - @Override - public boolean canChoose(UUID sourceControllerId, Ability source, Game game) { - int count = 0; - MageObject targetSource = game.getObject(source); - for (UUID playerId : game.getState().getPlayersInRange(sourceControllerId, game)) { - Player player = game.getPlayer(playerId); - if (player != null && player.canBeTargetedBy(targetSource, sourceControllerId, source, game) && filter.match(player, game)) { - count++; - if (count >= this.minNumberOfTargets) { - return true; - } - } - } - for (Permanent permanent : game.getBattlefield().getActivePermanents(filter.getCreatureFilter(), sourceControllerId, game)) { - if (permanent.canBeTargetedBy(targetSource, sourceControllerId, source, game) && filter.match(permanent, sourceControllerId, source, game)) { - count++; - if (count >= this.minNumberOfTargets) { - return true; - } - } - } - return false; - } - - /** - * Checks if there are enough {@link Permanent} or {@link Player} that can - * be selected. Should not be used for Ability targets since this does not - * check for protection, shroud etc. - * - * @param sourceControllerId - controller of the select event - * @param game - * @return - true if enough valid {@link Permanent} or {@link Player} exist - */ - @Override - public boolean canChoose(UUID sourceControllerId, Game game) { - int count = 0; - for (UUID playerId : game.getState().getPlayersInRange(sourceControllerId, game)) { - Player player = game.getPlayer(playerId); - if (filter.match(player, game)) { - count++; - if (count >= this.minNumberOfTargets) { - return true; - } - } - } - for (Permanent permanent : game.getBattlefield().getActivePermanents(filter.getCreatureFilter(), sourceControllerId, game)) { - if (filter.match(permanent, sourceControllerId, null, game)) { - count++; - if (count >= this.minNumberOfTargets) { - return true; - } - } - } - return false; - } - - @Override - public Set possibleTargets(UUID sourceControllerId, Ability source, Game game) { - Set possibleTargets = new HashSet<>(); - MageObject targetSource = game.getObject(source); - for (UUID playerId : game.getState().getPlayersInRange(sourceControllerId, game)) { - Player player = game.getPlayer(playerId); - if (player != null - && player.canBeTargetedBy(targetSource, sourceControllerId, source, game) - && filter.getPlayerFilter().match(player, sourceControllerId, source, game)) { - possibleTargets.add(playerId); - } - } - for (Permanent permanent : game.getBattlefield().getActivePermanents(filter.getCreatureFilter(), sourceControllerId, game)) { - if (permanent.canBeTargetedBy(targetSource, sourceControllerId, source, game) - && filter.getCreatureFilter().match(permanent, sourceControllerId, source, game)) { - possibleTargets.add(permanent.getId()); - } - } - return possibleTargets; - } - - @Override - public Set possibleTargets(UUID sourceControllerId, Game game) { - Set possibleTargets = new HashSet<>(); - for (UUID playerId : game.getState().getPlayersInRange(sourceControllerId, game)) { - Player player = game.getPlayer(playerId); - if (player != null && filter.getPlayerFilter().match(player, game)) { - possibleTargets.add(playerId); - } - } - for (Permanent permanent : game.getBattlefield().getActivePermanents(filter.getCreatureFilter(), sourceControllerId, game)) { - if (filter.getCreatureFilter().match(permanent, sourceControllerId, null, game)) { - possibleTargets.add(permanent.getId()); - } - } - return possibleTargets; - } - - @Override - public String getTargetedName(Game game) { - StringBuilder sb = new StringBuilder(); - for (UUID targetId : getTargets()) { - Permanent permanent = game.getPermanent(targetId); - if (permanent != null) { - sb.append(permanent.getLogName()).append(' '); - } else { - Player player = game.getPlayer(targetId); - if (player != null) { - sb.append(player.getLogName()).append(' '); - } - } - } - return sb.toString().trim(); } @Override @@ -210,7 +28,4 @@ public class TargetCreatureOrPlayer extends TargetImpl { return new TargetCreatureOrPlayer(this); } - public FilterCreaturePermanent getFilterCreature() { - return filter.getCreatureFilter().copy(); - } } -- 2.47.2 From 8772190c7be53d8d414cd9008def74e5f24be32e Mon Sep 17 00:00:00 2001 From: xenohedron Date: Thu, 9 Jan 2025 00:35:59 -0500 Subject: [PATCH 28/32] fix #13215 (Urza's Avenger, regression from #12619) --- Mage.Sets/src/mage/cards/u/UrzasAvenger.java | 7 +++---- 1 file changed, 3 insertions(+), 4 deletions(-) diff --git a/Mage.Sets/src/mage/cards/u/UrzasAvenger.java b/Mage.Sets/src/mage/cards/u/UrzasAvenger.java index 29c1dba39f9..d45e9f10516 100644 --- a/Mage.Sets/src/mage/cards/u/UrzasAvenger.java +++ b/Mage.Sets/src/mage/cards/u/UrzasAvenger.java @@ -1,4 +1,3 @@ - package mage.cards.u; import mage.MageInt; @@ -6,7 +5,7 @@ import mage.abilities.Ability; import mage.abilities.common.SimpleActivatedAbility; import mage.abilities.costs.mana.ManaCostsImpl; import mage.abilities.effects.common.GainsChoiceOfAbilitiesEffect; -import mage.abilities.effects.common.continuous.BoostTargetEffect; +import mage.abilities.effects.common.continuous.BoostSourceEffect; import mage.abilities.keyword.BandingAbility; import mage.abilities.keyword.FirstStrikeAbility; import mage.abilities.keyword.FlyingAbility; @@ -14,12 +13,12 @@ import mage.abilities.keyword.TrampleAbility; import mage.cards.CardImpl; import mage.cards.CardSetInfo; import mage.constants.CardType; +import mage.constants.Duration; import mage.constants.SubType; import java.util.UUID; /** - * * @author Styxo & L_J */ public final class UrzasAvenger extends CardImpl { @@ -32,7 +31,7 @@ public final class UrzasAvenger extends CardImpl { this.toughness = new MageInt(4); // {0}: Urza's Avenger gets -1/-1 and gains your choice of banding, flying, first strike, or trample until end of turn. - Ability ability = new SimpleActivatedAbility(new BoostTargetEffect(-1, -1) + Ability ability = new SimpleActivatedAbility(new BoostSourceEffect(-1, -1, Duration.EndOfTurn) .setText("{this} gets -1/-1"), new ManaCostsImpl<>("{0}")); ability.addEffect(new GainsChoiceOfAbilitiesEffect(GainsChoiceOfAbilitiesEffect.TargetType.Source, "", true, BandingAbility.getInstance(), FlyingAbility.getInstance(), FirstStrikeAbility.getInstance(), TrampleAbility.getInstance()) -- 2.47.2 From 30dac5cc9fd71d150e1e7442bcb840109cb3999b Mon Sep 17 00:00:00 2001 From: Marco Romano Date: Thu, 9 Jan 2025 21:07:13 +0100 Subject: [PATCH 29/32] [DSK] Implement Nowhere to Run (#13208) * [DSK] implemented Nowhere to Run * [DSK] implemented Nowhere to Run * NowhereToRun - fixed typo in static test * NowhereToRun - fixed hexproof effect --- Mage.Sets/src/mage/cards/n/NowhereToRun.java | 128 ++++++++++++++++++ .../src/mage/sets/DuskmournHouseOfHorror.java | 1 + .../cards/enchantments/NowhereToRunTest.java | 106 +++++++++++++++ 3 files changed, 235 insertions(+) create mode 100644 Mage.Sets/src/mage/cards/n/NowhereToRun.java create mode 100644 Mage.Tests/src/test/java/org/mage/test/cards/enchantments/NowhereToRunTest.java diff --git a/Mage.Sets/src/mage/cards/n/NowhereToRun.java b/Mage.Sets/src/mage/cards/n/NowhereToRun.java new file mode 100644 index 00000000000..fd774a8f04b --- /dev/null +++ b/Mage.Sets/src/mage/cards/n/NowhereToRun.java @@ -0,0 +1,128 @@ +package mage.cards.n; + +import mage.abilities.Ability; +import mage.abilities.common.EntersBattlefieldTriggeredAbility; +import mage.abilities.common.SimpleStaticAbility; +import mage.abilities.effects.AsThoughEffect; +import mage.abilities.effects.AsThoughEffectImpl; +import mage.abilities.effects.ContinuousEffect; +import mage.abilities.effects.ContinuousRuleModifyingEffectImpl; +import mage.abilities.effects.common.continuous.BoostTargetEffect; +import mage.abilities.keyword.FlashAbility; +import mage.abilities.keyword.WardAbility; +import mage.cards.CardImpl; +import mage.cards.CardSetInfo; +import mage.constants.AsThoughEffectType; +import mage.constants.CardType; +import mage.constants.Duration; +import mage.constants.Outcome; +import mage.game.Game; +import mage.game.events.GameEvent; +import mage.game.permanent.Permanent; +import mage.target.common.TargetOpponentsCreaturePermanent; + +import java.util.UUID; + +/** + * @author markort147 + */ +public final class NowhereToRun extends CardImpl { + + public NowhereToRun(UUID ownerId, CardSetInfo setInfo) { + super(ownerId, setInfo, new CardType[]{CardType.ENCHANTMENT}, "{1}{B}"); + + // Flash + this.addAbility(FlashAbility.getInstance()); + + // When Nowhere to Run enters, target creature an opponent controls gets -3/-3 until end of turn. + Ability etbAbility = new EntersBattlefieldTriggeredAbility(new BoostTargetEffect(-3, -3, Duration.EndOfTurn)); + etbAbility.addTarget(new TargetOpponentsCreaturePermanent()); + this.addAbility(etbAbility); + + // Creatures your opponents control can be the targets of spells and abilities as though they didn't have hexproof. Ward abilities of those creatures don't trigger. + Ability staticAbility = new SimpleStaticAbility(new NowhereToRunHexproofEffect()); + staticAbility.addEffect(new NowhereToRunWardEffect()); + this.addAbility(staticAbility); + } + + private NowhereToRun(final NowhereToRun card) { + super(card); + } + + @Override + public NowhereToRun copy() { + return new NowhereToRun(this); + } +} + +class NowhereToRunHexproofEffect extends AsThoughEffectImpl { + + NowhereToRunHexproofEffect() { + super(AsThoughEffectType.HEXPROOF, Duration.WhileOnBattlefield, Outcome.Benefit); + staticText = "Creatures your opponents control " + + "can be the targets of spells and " + + "abilities as though they didn't " + + "have hexproof."; + } + + private NowhereToRunHexproofEffect(final NowhereToRunHexproofEffect effect) { + super(effect); + } + + @Override + public boolean applies(UUID sourceId, Ability source, UUID affectedControllerId, Game game) { + if (affectedControllerId.equals(source.getControllerId())) { + Permanent creature = game.getPermanent(sourceId); + return creature != null + && creature.isCreature(game) + && game.getOpponents(source.getControllerId()).contains(creature.getControllerId()); + } + return false; + } + + @Override + public boolean apply(Game game, Ability source) { + return true; + } + + @Override + public AsThoughEffect copy() { + return new NowhereToRunHexproofEffect(this); + } +} + +class NowhereToRunWardEffect extends ContinuousRuleModifyingEffectImpl { + + + NowhereToRunWardEffect() { + super(Duration.WhileOnBattlefield, Outcome.Benefit); + staticText = "Ward abilities of those creatures don't trigger."; + } + + private NowhereToRunWardEffect(final NowhereToRunWardEffect effect) { + super(effect); + } + + @Override + public boolean checksEventType(GameEvent event, Game game) { + return event.getType().equals(GameEvent.EventType.NUMBER_OF_TRIGGERS); + } + + @Override + public boolean applies(GameEvent event, Ability source, Game game) { + Permanent permanent = game.getPermanent(event.getSourceId()); + if (permanent == null || !permanent.isCreature(game)) { + return false; + } + if (!game.getOpponents(source.getControllerId()).contains(permanent.getControllerId())) { + return false; + } + + return getValue("targetAbility") instanceof WardAbility; + } + + @Override + public ContinuousEffect copy() { + return new NowhereToRunWardEffect(this); + } +} diff --git a/Mage.Sets/src/mage/sets/DuskmournHouseOfHorror.java b/Mage.Sets/src/mage/sets/DuskmournHouseOfHorror.java index eb2e87f70c3..069107adced 100644 --- a/Mage.Sets/src/mage/sets/DuskmournHouseOfHorror.java +++ b/Mage.Sets/src/mage/sets/DuskmournHouseOfHorror.java @@ -166,6 +166,7 @@ public final class DuskmournHouseOfHorror extends ExpansionSet { cards.add(new SetCardInfo("Neglected Manor", 264, Rarity.COMMON, mage.cards.n.NeglectedManor.class)); cards.add(new SetCardInfo("Niko, Light of Hope", 224, Rarity.MYTHIC, mage.cards.n.NikoLightOfHope.class)); cards.add(new SetCardInfo("Norin, Swift Survivalist", 145, Rarity.UNCOMMON, mage.cards.n.NorinSwiftSurvivalist.class)); + cards.add(new SetCardInfo("Nowhere to Run", 111, Rarity.UNCOMMON, mage.cards.n.NowhereToRun.class)); cards.add(new SetCardInfo("Oblivious Bookworm", 225, Rarity.UNCOMMON, mage.cards.o.ObliviousBookworm.class)); cards.add(new SetCardInfo("Omnivorous Flytrap", 192, Rarity.RARE, mage.cards.o.OmnivorousFlytrap.class, NON_FULL_USE_VARIOUS)); cards.add(new SetCardInfo("Omnivorous Flytrap", 322, Rarity.RARE, mage.cards.o.OmnivorousFlytrap.class, NON_FULL_USE_VARIOUS)); diff --git a/Mage.Tests/src/test/java/org/mage/test/cards/enchantments/NowhereToRunTest.java b/Mage.Tests/src/test/java/org/mage/test/cards/enchantments/NowhereToRunTest.java new file mode 100644 index 00000000000..abef16e9738 --- /dev/null +++ b/Mage.Tests/src/test/java/org/mage/test/cards/enchantments/NowhereToRunTest.java @@ -0,0 +1,106 @@ +package org.mage.test.cards.enchantments; + +import mage.constants.PhaseStep; +import mage.constants.Zone; +import org.junit.Assert; +import org.junit.Test; +import org.mage.test.serverside.base.CardTestPlayerBase; + +/** + * @author markort147 + */ +public class NowhereToRunTest extends CardTestPlayerBase { + + // Prevent ward from triggering on opponent's creatures + @Test + public void testWardPreventingOnOpponentsCreatures() { + + addCard(Zone.BATTLEFIELD, playerB, "Waterfall Aerialist", 1); + addCard(Zone.BATTLEFIELD, playerA, "Plains", 1); + addCard(Zone.BATTLEFIELD, playerA, "Swamp", 1); + addCard(Zone.HAND, playerA, "Nowhere to Run", 1); + + setStrictChooseMode(true); + + castSpell(1, PhaseStep.PRECOMBAT_MAIN, playerA, "Nowhere to Run"); + addTarget(playerA, "Waterfall Aerialist"); + + setStopAt(1, PhaseStep.BEGIN_COMBAT); + execute(); + + assertPermanentCount(playerA, "Waterfall Aerialist", 0); + } + + // Does not prevent ward from triggering on own creatures + @Test + public void testWardOnOwnCreatures() { + + addCard(Zone.BATTLEFIELD, playerA, "Waterfall Aerialist", 1); + addCard(Zone.BATTLEFIELD, playerA, "Nowhere to Run", 1); + addCard(Zone.HAND, playerB, "Swords to Plowshares", 1); + addCard(Zone.BATTLEFIELD, playerB, "Plains", 1); + + setStrictChooseMode(true); + + castSpell(1, PhaseStep.PRECOMBAT_MAIN, playerB, "Swords to Plowshares"); + addTarget(playerB, "Waterfall Aerialist"); + setChoice(playerB, false); + + setStopAt(1, PhaseStep.BEGIN_COMBAT); + execute(); + + assertPermanentCount(playerA, "Waterfall Aerialist", 1); + assertGraveyardCount(playerB, "Swords to Plowshares", 1); + } + + // Prevent hexproof on opponent's creatures + @Test + public void testHexproofOnCreatures() { + + addCard(Zone.BATTLEFIELD, playerB, "Gladecover Scout", 1); + addCard(Zone.BATTLEFIELD, playerA, "Plains", 1); + addCard(Zone.BATTLEFIELD, playerA, "Swamp", 1); + addCard(Zone.BATTLEFIELD, playerA, "Nowhere to Run", 1); + addCard(Zone.HAND, playerA, "Go for the Throat", 1); + + setStrictChooseMode(true); + + castSpell(1, PhaseStep.PRECOMBAT_MAIN, playerA, "Go for the Throat"); + addTarget(playerA, "Gladecover Scout"); + + setStopAt(1, PhaseStep.BEGIN_COMBAT); + execute(); + + assertPermanentCount(playerB, "Gladecover Scout", 0); + } + + // Does not prevent hexproof on non-creature permanents + @Test + public void testHexproofOnOtherPermanents() { + + addCard(Zone.BATTLEFIELD, playerB, "Valgavoth's Lair", 1); + setChoice(playerB, "Red"); + + addCard(Zone.BATTLEFIELD, playerA, "Nowhere to Run", 1); + addCard(Zone.BATTLEFIELD, playerA, "Mountain", 3); + addCard(Zone.HAND, playerA, "Stone Rain", 1); + + setStrictChooseMode(true); + + castSpell(1, PhaseStep.PRECOMBAT_MAIN, playerA, "Stone Rain"); + addTarget(playerA, "Valgavoth's Lair"); + + setStopAt(1, PhaseStep.BEGIN_COMBAT); + + try { + execute(); + } catch (Throwable e) { + if (!e.getMessage().contains("Targets list was setup by addTarget with [Valgavoth's Lair], but not used")) { + Assert.fail("must throw error about bad targets, but got:\n" + e.getMessage()); + } + return; + } + Assert.fail("must throw exception on execute"); + } + +} -- 2.47.2 From 49b90820e0b77df0d378410f2a238b5132d753b0 Mon Sep 17 00:00:00 2001 From: xenohedron Date: Fri, 10 Jan 2025 00:19:58 -0500 Subject: [PATCH 30/32] fix Vampire Gourmand --- Mage.Sets/src/mage/cards/v/VampireGourmand.java | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/Mage.Sets/src/mage/cards/v/VampireGourmand.java b/Mage.Sets/src/mage/cards/v/VampireGourmand.java index 35643d351ca..7aff3649dbe 100644 --- a/Mage.Sets/src/mage/cards/v/VampireGourmand.java +++ b/Mage.Sets/src/mage/cards/v/VampireGourmand.java @@ -5,7 +5,7 @@ import mage.abilities.common.AttacksTriggeredAbility; import mage.abilities.costs.common.SacrificeTargetCost; import mage.abilities.effects.common.DoIfCostPaid; import mage.abilities.effects.common.DrawCardSourceControllerEffect; -import mage.abilities.effects.common.combat.CantBlockSourceEffect; +import mage.abilities.effects.common.combat.CantBeBlockedSourceEffect; import mage.cards.CardImpl; import mage.cards.CardSetInfo; import mage.constants.CardType; @@ -31,7 +31,7 @@ public final class VampireGourmand extends CardImpl { this.addAbility(new AttacksTriggeredAbility(new DoIfCostPaid( new DrawCardSourceControllerEffect(1), new SacrificeTargetCost(StaticFilters.FILTER_ANOTHER_CREATURE) - ).addEffect(new CantBlockSourceEffect(Duration.EndOfTurn).concatBy("and")))); + ).addEffect(new CantBeBlockedSourceEffect(Duration.EndOfTurn).concatBy("and")))); } private VampireGourmand(final VampireGourmand card) { -- 2.47.2 From 0505f5159e39d87272629fd0c1e29ca8c4fb8029 Mon Sep 17 00:00:00 2001 From: Oleg Agafonov Date: Fri, 10 Jan 2025 20:18:00 +0400 Subject: [PATCH 31/32] Turn under control reworked: - game: added support when a human is take control over a computer player (related to #12878); - game: fixed game freezes while controlling player leaves/disconnect on active priority/choose of another player; --- .../ai/ComputerPlayerControllableProxy.java | 401 ++++++++++++++++++ .../src/mage/player/human/HumanPlayer.java | 37 +- Mage.Server/config/config.xml | 2 +- Mage.Server/release/config/config.xml | 2 +- .../java/mage/server/game/GameController.java | 89 +++- .../mage/server/game/GameSessionPlayer.java | 2 +- Mage.Server/src/test/data/config_error.xml | 2 +- Mage.Tests/config/config.xml | 2 +- .../java/org/mage/test/player/TestPlayer.java | 4 - Mage/src/main/java/mage/game/GameImpl.java | 4 + Mage/src/main/java/mage/players/Player.java | 16 +- .../main/java/mage/players/PlayerImpl.java | 5 - 12 files changed, 534 insertions(+), 32 deletions(-) create mode 100644 Mage.Server.Plugins/Mage.Player.AI.MA/src/mage/player/ai/ComputerPlayerControllableProxy.java diff --git a/Mage.Server.Plugins/Mage.Player.AI.MA/src/mage/player/ai/ComputerPlayerControllableProxy.java b/Mage.Server.Plugins/Mage.Player.AI.MA/src/mage/player/ai/ComputerPlayerControllableProxy.java new file mode 100644 index 00000000000..dd63da4bdf0 --- /dev/null +++ b/Mage.Server.Plugins/Mage.Player.AI.MA/src/mage/player/ai/ComputerPlayerControllableProxy.java @@ -0,0 +1,401 @@ +package mage.player.ai; + +import mage.MageObject; +import mage.abilities.*; +import mage.abilities.costs.VariableCost; +import mage.abilities.costs.mana.ManaCost; +import mage.cards.Card; +import mage.cards.Cards; +import mage.cards.decks.Deck; +import mage.choices.Choice; +import mage.constants.ManaType; +import mage.constants.MultiAmountType; +import mage.constants.Outcome; +import mage.constants.RangeOfInfluence; +import mage.game.Game; +import mage.game.combat.CombatGroup; +import mage.game.draft.Draft; +import mage.game.match.Match; +import mage.game.permanent.Permanent; +import mage.game.tournament.Tournament; +import mage.players.Player; +import mage.target.Target; +import mage.target.TargetAmount; +import mage.target.TargetCard; +import mage.util.MultiAmountMessage; +import org.apache.log4j.Logger; + +import java.io.Serializable; +import java.util.List; +import java.util.Map; +import java.util.UUID; + +/** + * AI player that can be taken under control by another player (AI or human). + *

+ * Under control logic on choose dialog (under human): + * - create fake human player and assign it to real human data transfer object (for income answers); + * - call choose dialog from fake human (e.g. send choose data to real player); + * - game will process all sending and answering logic as "human under human" logic; + * - return choose dialog result without AI code processing; + * + * @author JayDi85 + */ +public class ComputerPlayerControllableProxy extends ComputerPlayer7 { + + private static final Logger logger = Logger.getLogger(ComputerPlayerControllableProxy.class); + + Player lastControllingPlayer = null; + + public ComputerPlayerControllableProxy(String name, RangeOfInfluence range, int skill) { + super(name, range, skill); + } + + public ComputerPlayerControllableProxy(final ComputerPlayerControllableProxy player) { + super(player); + this.lastControllingPlayer = player; + } + + @Override + public ComputerPlayerControllableProxy copy() { + return new ComputerPlayerControllableProxy(this); + } + + private boolean isUnderMe(Game game) { + return game.isSimulation() || this.isGameUnderControl(); + } + + private Player getControllingPlayer(Game game) { + Player player = game.getPlayer(this.getTurnControlledBy()); + this.lastControllingPlayer = player.prepareControllableProxy(this); + return this.lastControllingPlayer; + } + + @Override + public void setResponseString(String responseString) { + if (this.lastControllingPlayer != null) { + this.lastControllingPlayer.setResponseString(responseString); + } + } + + @Override + public void setResponseManaType(UUID manaTypePlayerId, ManaType responseManaType) { + if (this.lastControllingPlayer != null) { + this.lastControllingPlayer.setResponseManaType(manaTypePlayerId, responseManaType); + } + } + + @Override + public void setResponseUUID(UUID responseUUID) { + if (this.lastControllingPlayer != null) { + this.lastControllingPlayer.setResponseUUID(responseUUID); + } + } + + @Override + public void setResponseBoolean(Boolean responseBoolean) { + if (this.lastControllingPlayer != null) { + this.lastControllingPlayer.setResponseBoolean(responseBoolean); + } + } + + @Override + public void setResponseInteger(Integer responseInteger) { + if (this.lastControllingPlayer != null) { + this.lastControllingPlayer.setResponseInteger(responseInteger); + } + } + + @Override + public void signalPlayerCheat() { + if (this.lastControllingPlayer != null) { + this.lastControllingPlayer.signalPlayerCheat(); + } + } + + @Override + public void signalPlayerConcede(boolean stopCurrentChooseDialog) { + if (this.lastControllingPlayer != null) { + this.lastControllingPlayer.signalPlayerConcede(stopCurrentChooseDialog); + } + } + + @Override + public boolean priority(Game game) { + if (isUnderMe(game)) { + return super.priority(game); + } else { + Player player = getControllingPlayer(game); + try { + return player.priority(game); + } finally { + this.passed = player.isPassed(); // TODO: wtf, no needs? + } + } + } + + @Override + public boolean chooseMulligan(Game game) { + if (isUnderMe(game)) { + return super.chooseMulligan(game); + } else { + return getControllingPlayer(game).chooseMulligan(game); + } + } + + @Override + public boolean chooseUse(Outcome outcome, String message, Ability source, Game game) { + if (isUnderMe(game)) { + return super.chooseUse(outcome, message, source, game); + } else { + return getControllingPlayer(game).chooseUse(outcome, message, source, game); + } + } + + @Override + public boolean chooseUse(Outcome outcome, String message, String secondMessage, String trueText, String falseText, Ability source, Game game) { + if (isUnderMe(game)) { + return super.chooseUse(outcome, message, secondMessage, trueText, falseText, source, game); + } else { + return getControllingPlayer(game).chooseUse(outcome, message, secondMessage, trueText, falseText, source, game); + } + } + + @Override + public int chooseReplacementEffect(Map effectsMap, Map objectsMap, Game game) { + if (isUnderMe(game)) { + return super.chooseReplacementEffect(effectsMap, objectsMap, game); + } else { + return getControllingPlayer(game).chooseReplacementEffect(effectsMap, objectsMap, game); + } + } + + @Override + public boolean choose(Outcome outcome, Choice choice, Game game) { + if (isUnderMe(game)) { + return super.choose(outcome, choice, game); + } else { + return getControllingPlayer(game).choose(outcome, choice, game); + } + } + + @Override + public boolean choose(Outcome outcome, Target target, Ability source, Game game) { + if (isUnderMe(game)) { + return super.choose(outcome, target, source, game); + } else { + return getControllingPlayer(game).choose(outcome, target, source, game); + } + } + + @Override + public boolean choose(Outcome outcome, Target target, Ability source, Game game, Map options) { + if (isUnderMe(game)) { + return super.choose(outcome, target, source, game, options); + } else { + return getControllingPlayer(game).choose(outcome, target, source, game, options); + } + } + + @Override + public boolean chooseTarget(Outcome outcome, Target target, Ability source, Game game) { + if (isUnderMe(game)) { + return super.chooseTarget(outcome, target, source, game); + } else { + return getControllingPlayer(game).chooseTarget(outcome, target, source, game); + } + } + + @Override + public boolean choose(Outcome outcome, Cards cards, TargetCard target, Ability source, Game game) { + if (isUnderMe(game)) { + return super.choose(outcome, cards, target, source, game); + } else { + return getControllingPlayer(game).choose(outcome, cards, target, source, game); + } + } + + @Override + public boolean chooseTarget(Outcome outcome, Cards cards, TargetCard target, Ability source, Game game) { + if (isUnderMe(game)) { + return super.chooseTarget(outcome, cards, target, source, game); + } else { + return getControllingPlayer(game).chooseTarget(outcome, cards, target, source, game); + } + } + + @Override + public boolean chooseTargetAmount(Outcome outcome, TargetAmount target, Ability source, Game game) { + if (isUnderMe(game)) { + return super.chooseTargetAmount(outcome, target, source, game); + } else { + return getControllingPlayer(game).chooseTargetAmount(outcome, target, source, game); + } + } + + @Override + public TriggeredAbility chooseTriggeredAbility(java.util.List abilities, Game game) { + if (isUnderMe(game)) { + return super.chooseTriggeredAbility(abilities, game); + } else { + return getControllingPlayer(game).chooseTriggeredAbility(abilities, game); + } + } + + @Override + public boolean playMana(Ability abilityToCast, ManaCost unpaid, String promptText, Game game) { + if (isUnderMe(game)) { + return super.playMana(abilityToCast, unpaid, promptText, game); + } else { + return getControllingPlayer(game).playMana(abilityToCast, unpaid, promptText, game); + } + } + + @Override + public int announceXMana(int min, int max, String message, Game game, Ability ability) { + if (isUnderMe(game)) { + return super.announceXMana(min, max, message, game, ability); + } else { + return getControllingPlayer(game).announceXMana(min, max, message, game, ability); + } + } + + @Override + public int announceXCost(int min, int max, String message, Game game, Ability ability, VariableCost variableCost) { + if (isUnderMe(game)) { + return super.announceXCost(min, max, message, game, ability, variableCost); + } else { + return getControllingPlayer(game).announceXCost(min, max, message, game, ability, variableCost); + } + } + + @Override + public void selectAttackers(Game game, UUID attackingPlayerId) { + if (isUnderMe(game)) { + super.selectAttackers(game, attackingPlayerId); + } else { + getControllingPlayer(game).selectAttackers(game, attackingPlayerId); + } + } + + @Override + public void selectBlockers(Ability source, Game game, UUID defendingPlayerId) { + if (isUnderMe(game)) { + super.selectBlockers(source, game, defendingPlayerId); + } else { + getControllingPlayer(game).selectBlockers(source, game, defendingPlayerId); + } + } + + @Override + public UUID chooseAttackerOrder(java.util.List attackers, Game game) { + if (isUnderMe(game)) { + return super.chooseAttackerOrder(attackers, game); + } else { + return getControllingPlayer(game).chooseAttackerOrder(attackers, game); + } + } + + @Override + public UUID chooseBlockerOrder(java.util.List blockers, CombatGroup combatGroup, java.util.List blockerOrder, Game game) { + if (isUnderMe(game)) { + return super.chooseBlockerOrder(blockers, combatGroup, blockerOrder, game); + } else { + return getControllingPlayer(game).chooseBlockerOrder(blockers, combatGroup, blockerOrder, game); + } + } + + @Override + public int getAmount(int min, int max, String message, Game game) { + if (isUnderMe(game)) { + return super.getAmount(min, max, message, game); + } else { + return getControllingPlayer(game).getAmount(min, max, message, game); + } + } + + @Override + public List getMultiAmountWithIndividualConstraints( + Outcome outcome, + List messages, + int totalMin, + int totalMax, + MultiAmountType type, + Game game + ) { + if (isUnderMe(game)) { + return super.getMultiAmountWithIndividualConstraints(outcome, messages, totalMin, totalMax, type, game); + } else { + return getControllingPlayer(game).getMultiAmountWithIndividualConstraints(outcome, messages, totalMin, totalMax, type, game); + } + } + + @Override + public void sideboard(Match match, Deck deck) { + super.sideboard(match, deck); + } + + @Override + public void construct(Tournament tournament, Deck deck) { + super.construct(tournament, deck); + } + + @Override + public void pickCard(java.util.List cards, Deck deck, Draft draft) { + super.pickCard(cards, deck, draft); + } + + @Override + public boolean activateAbility(ActivatedAbility ability, Game game) { + // TODO: need research, see HumanPlayer's code + return super.activateAbility(ability, game); + } + + @Override + public SpellAbility chooseAbilityForCast(Card card, Game game, boolean noMana) { + if (isUnderMe(game)) { + return super.chooseAbilityForCast(card, game, noMana); + } else { + return getControllingPlayer(game).chooseAbilityForCast(card, game, noMana); + } + } + + @Override + public ActivatedAbility chooseLandOrSpellAbility(Card card, Game game, boolean noMana) { + if (isUnderMe(game)) { + return super.chooseLandOrSpellAbility(card, game, noMana); + } else { + return getControllingPlayer(game).chooseLandOrSpellAbility(card, game, noMana); + } + } + + @Override + public Mode chooseMode(Modes modes, Ability source, Game game) { + if (isUnderMe(game)) { + return super.chooseMode(modes, source, game); + } else { + return getControllingPlayer(game).chooseMode(modes, source, game); + } + } + + @Override + public boolean choosePile(Outcome outcome, String message, java.util.List pile1, java.util.List pile2, Game game) { + if (isUnderMe(game)) { + return super.choosePile(outcome, message, pile1, pile2, game); + } else { + return getControllingPlayer(game).choosePile(outcome, message, pile1, pile2, game); + } + } + + @Override + public void abort() { + // TODO: need research, is it require real player call? Concede/leave/timeout works by default + super.abort(); + } + + @Override + public void skip() { + // TODO: see abort comments above + super.skip(); + } +} diff --git a/Mage.Server.Plugins/Mage.Player.Human/src/mage/player/human/HumanPlayer.java b/Mage.Server.Plugins/Mage.Player.Human/src/mage/player/human/HumanPlayer.java index f55dcf7b402..705f2479182 100644 --- a/Mage.Server.Plugins/Mage.Player.Human/src/mage/player/human/HumanPlayer.java +++ b/Mage.Server.Plugins/Mage.Player.Human/src/mage/player/human/HumanPlayer.java @@ -97,7 +97,7 @@ public class HumanPlayer extends PlayerImpl { // * - GAME thread: on notify from response - check new answer value and process it (if it bad then repeat and wait the next one); private transient Boolean responseOpenedForAnswer = false; // GAME thread waiting new answer private transient long responseLastWaitingThreadId = 0; - private final transient PlayerResponse response = new PlayerResponse(); + private final transient PlayerResponse response; // data receiver from a client side (must be shared for one player between multiple clients) private final int RESPONSE_WAITING_TIME_SECS = 30; // waiting time before cancel current response private final int RESPONSE_WAITING_CHECK_MS = 100; // timeout for open status check @@ -105,7 +105,7 @@ public class HumanPlayer extends PlayerImpl { protected static FilterCreatureForCombat filterCreatureForCombat = new FilterCreatureForCombat(); protected static FilterAttackingCreature filterAttack = new FilterAttackingCreature(); protected static FilterBlockingCreature filterBlock = new FilterBlockingCreature(); - protected final Choice replacementEffectChoice; + protected Choice replacementEffectChoice = null; private static final Logger logger = Logger.getLogger(HumanPlayer.class); protected HashSet autoSelectReplacementEffects = new LinkedHashSet<>(); // must be sorted @@ -131,8 +131,12 @@ public class HumanPlayer extends PlayerImpl { public HumanPlayer(String name, RangeOfInfluence range, int skill) { super(name, range); - human = true; + this.human = true; + this.response = new PlayerResponse(); + initReplacementDialog(); + } + private void initReplacementDialog() { replacementEffectChoice = new ChoiceImpl(true); replacementEffectChoice.setMessage("Choose replacement effect to resolve first"); replacementEffectChoice.setSpecial( @@ -143,8 +147,20 @@ public class HumanPlayer extends PlayerImpl { ); } + /** + * Make fake player from any other + */ + public HumanPlayer(final PlayerImpl sourcePlayer, final PlayerResponse sourceResponse) { + super(sourcePlayer); + this.human = true; + this.response = sourceResponse; // need for sync and wait user's response from a network + initReplacementDialog(); + } + public HumanPlayer(final HumanPlayer player) { super(player); + this.response = player.response; + this.replacementEffectChoice = player.replacementEffectChoice; this.autoSelectReplacementEffects.addAll(player.autoSelectReplacementEffects); this.currentlyUnpaidMana = player.currentlyUnpaidMana; @@ -2940,11 +2956,6 @@ public class HumanPlayer extends PlayerImpl { return true; } - @Override - public String getHistory() { - return "no available"; - } - private boolean gameInCheckPlayableState(Game game) { return gameInCheckPlayableState(game, false); } @@ -2962,4 +2973,14 @@ public class HumanPlayer extends PlayerImpl { } return false; } + + @Override + public Player prepareControllableProxy(Player playerUnderControl) { + // make fake player, e.g. transform computer player to human player for choose dialogs under control + HumanPlayer fakePlayer = new HumanPlayer((PlayerImpl) playerUnderControl, this.response); + if (!fakePlayer.getTurnControlledBy().equals(this.getId())) { + throw new IllegalArgumentException("Wrong code usage: controllable proxy must be controlled by " + this.getName()); + } + return fakePlayer; + } } diff --git a/Mage.Server/config/config.xml b/Mage.Server/config/config.xml index bb39136505b..3f1eba60fb0 100644 --- a/Mage.Server/config/config.xml +++ b/Mage.Server/config/config.xml @@ -66,7 +66,7 @@ /> - + diff --git a/Mage.Server/release/config/config.xml b/Mage.Server/release/config/config.xml index e19d19ca6dd..1f77ab3d9b7 100644 --- a/Mage.Server/release/config/config.xml +++ b/Mage.Server/release/config/config.xml @@ -62,7 +62,7 @@ /> - + diff --git a/Mage.Server/src/main/java/mage/server/game/GameController.java b/Mage.Server/src/main/java/mage/server/game/GameController.java index 868c161ae34..2ffbf7e00b6 100644 --- a/Mage.Server/src/main/java/mage/server/game/GameController.java +++ b/Mage.Server/src/main/java/mage/server/game/GameController.java @@ -786,23 +786,23 @@ public class GameController implements GameCallback { } public void sendPlayerUUID(UUID userId, final UUID data) { - sendMessage(userId, playerId -> getGameSession(playerId).sendPlayerUUID(data)); + sendMessage(userId, playerId -> sendDirectPlayerUUID(playerId, data)); } public void sendPlayerString(UUID userId, final String data) { - sendMessage(userId, playerId -> getGameSession(playerId).sendPlayerString(data)); + sendMessage(userId, playerId -> sendDirectPlayerString(playerId, data)); } public void sendPlayerManaType(UUID userId, final UUID manaTypePlayerId, final ManaType data) { - sendMessage(userId, playerId -> getGameSession(playerId).sendPlayerManaType(data, manaTypePlayerId)); + sendMessage(userId, playerId -> sendDirectPlayerManaType(playerId, manaTypePlayerId, data)); } public void sendPlayerBoolean(UUID userId, final Boolean data) { - sendMessage(userId, playerId -> getGameSession(playerId).sendPlayerBoolean(data)); + sendMessage(userId, playerId -> sendDirectPlayerBoolean(playerId, data)); } public void sendPlayerInteger(UUID userId, final Integer data) { - sendMessage(userId, playerId -> getGameSession(playerId).sendPlayerInteger(data)); + sendMessage(userId, playerId -> sendDirectPlayerInteger(playerId, data)); } private void updatePriorityTimers() { @@ -1055,7 +1055,8 @@ public class GameController implements GameCallback { } else { // otherwise execute the action under other player's control for (UUID controlled : player.getPlayersUnderYourControl()) { - if (gameSessions.containsKey(controlled) && game.getPriorityPlayerId().equals(controlled)) { + Player controlledPlayer = game.getPlayer(controlled); + if ((gameSessions.containsKey(controlled) || controlledPlayer.isComputer()) && game.getPriorityPlayerId().equals(controlled)) { stopResponseIdleTimeout(); command.execute(controlled); } @@ -1098,7 +1099,6 @@ public class GameController implements GameCallback { @FunctionalInterface interface Command { - void execute(UUID player); } @@ -1138,6 +1138,81 @@ public class GameController implements GameCallback { return newGameSessionWatchers; } + private void sendDirectPlayerUUID(UUID playerId, UUID data) { + // real player + GameSessionPlayer session = getGameSession(playerId); + if (session != null) { + session.sendPlayerUUID(data); + return; + } + + // computer under control + Player player = game.getPlayer(playerId); + if (player != null && player.isComputer()) { + player.setResponseUUID(data); + } + } + + private void sendDirectPlayerString(UUID playerId, String data) { + // real player + GameSessionPlayer session = getGameSession(playerId); + if (session != null) { + session.sendPlayerString(data); + return; + } + + // computer under control + Player player = game.getPlayer(playerId); + if (player != null && player.isComputer()) { + player.setResponseString(data); + } + } + + private void sendDirectPlayerManaType(UUID playerId, UUID manaTypePlayerId, ManaType manaType) { + // real player + GameSessionPlayer session = getGameSession(playerId); + if (session != null) { + session.sendPlayerManaType(manaTypePlayerId, manaType); + return; + } + + // computer under control + Player player = game.getPlayer(playerId); + if (player != null && player.isComputer()) { + player.setResponseManaType(manaTypePlayerId, manaType); + } + } + + private void sendDirectPlayerBoolean(UUID playerId, Boolean data) { + // real player + GameSessionPlayer session = getGameSession(playerId); + if (session != null) { + session.sendPlayerBoolean(data); + return; + } + + // computer under control + Player player = game.getPlayer(playerId); + if (player != null && player.isComputer()) { + player.setResponseBoolean(data); + } + } + + private void sendDirectPlayerInteger(UUID playerId, Integer data) { + // real player + GameSessionPlayer session = getGameSession(playerId); + if (session != null) { + session.sendPlayerInteger(data); + return; + } + + // computer under control + Player player = game.getPlayer(playerId); + if (player != null && player.isComputer()) { + player.setResponseInteger(data); + } + } + private GameSessionPlayer getGameSession(UUID playerId) { // TODO: check parent callers - there are possible problems with sync, can be related to broken "fix" logs too // It modify players data, but: diff --git a/Mage.Server/src/main/java/mage/server/game/GameSessionPlayer.java b/Mage.Server/src/main/java/mage/server/game/GameSessionPlayer.java index b4b7bd6c7c3..db7fa74d173 100644 --- a/Mage.Server/src/main/java/mage/server/game/GameSessionPlayer.java +++ b/Mage.Server/src/main/java/mage/server/game/GameSessionPlayer.java @@ -183,7 +183,7 @@ public class GameSessionPlayer extends GameSessionWatcher { game.getPlayer(playerId).setResponseString(data); } - public void sendPlayerManaType(ManaType manaType, UUID manaTypePlayerId) { + public void sendPlayerManaType(UUID manaTypePlayerId, ManaType manaType) { game.getPlayer(playerId).setResponseManaType(manaTypePlayerId, manaType); } diff --git a/Mage.Server/src/test/data/config_error.xml b/Mage.Server/src/test/data/config_error.xml index 517fc0f9328..fd5bffb2f95 100644 --- a/Mage.Server/src/test/data/config_error.xml +++ b/Mage.Server/src/test/data/config_error.xml @@ -32,7 +32,7 @@ /> - + diff --git a/Mage.Tests/config/config.xml b/Mage.Tests/config/config.xml index b6d386674a2..568825bb274 100644 --- a/Mage.Tests/config/config.xml +++ b/Mage.Tests/config/config.xml @@ -3,7 +3,7 @@ - + diff --git a/Mage.Tests/src/test/java/org/mage/test/player/TestPlayer.java b/Mage.Tests/src/test/java/org/mage/test/player/TestPlayer.java index be036846ebc..b2270995726 100644 --- a/Mage.Tests/src/test/java/org/mage/test/player/TestPlayer.java +++ b/Mage.Tests/src/test/java/org/mage/test/player/TestPlayer.java @@ -4544,10 +4544,6 @@ public class TestPlayer implements Player { return AIPlayer; } - public String getHistory() { - return computerPlayer.getHistory(); - } - @Override public PlanarDieRollResult rollPlanarDie(Outcome outcome, Ability source, Game game, int numberChaosSides, int numberPlanarSides) { return computerPlayer.rollPlanarDie(outcome, source, game, numberChaosSides, numberPlanarSides); diff --git a/Mage/src/main/java/mage/game/GameImpl.java b/Mage/src/main/java/mage/game/GameImpl.java index dc2cb22fbaa..1bece076bd7 100644 --- a/Mage/src/main/java/mage/game/GameImpl.java +++ b/Mage/src/main/java/mage/game/GameImpl.java @@ -845,6 +845,10 @@ public abstract class GameImpl implements Game { // concede for itself // stop current player dialog and execute concede currentPriorityPlayer.signalPlayerConcede(true); + } else if (currentPriorityPlayer.getTurnControlledBy().equals(playerId)) { + // concede for itself while controlling another player + // stop current player dialog and execute concede + currentPriorityPlayer.signalPlayerConcede(true); } else { // concede for another player // allow current player to continue and check concede on any next priority diff --git a/Mage/src/main/java/mage/players/Player.java b/Mage/src/main/java/mage/players/Player.java index 3f3073986d2..df84549735d 100644 --- a/Mage/src/main/java/mage/players/Player.java +++ b/Mage/src/main/java/mage/players/Player.java @@ -46,7 +46,15 @@ import java.util.UUID; import java.util.stream.Collectors; /** - * @author BetaSteward_at_googlemail.com + * Warning, if you add new choose dialogs then must implement it for: + * - PlayerImpl (only if it use another default dialogs inside) + * - HumanPlayer (support client-server in human games) + * - ComputerPlayer (support AI in computer games) + * - StubPlayer (temp) + * - ComputerPlayerControllableProxy (support control of one player type over another player type) + * - TestPlayer (support unit tests) + * + * @author BetaSteward_at_googlemail.com, JayDi85 */ public interface Player extends MageItem, Copyable { @@ -1212,8 +1220,6 @@ public interface Player extends MageItem, Copyable { */ boolean addTargets(Ability ability, Game game); - String getHistory(); - boolean hasDesignation(DesignationType designationName); void addDesignation(Designation designation); @@ -1253,4 +1259,8 @@ public interface Player extends MageItem, Copyable { * so that's method helps to find real player that used by a game (in most use cases it's a PlayerImpl) */ Player getRealPlayer(); + + default Player prepareControllableProxy(Player playerUnderControl) { + return this; + } } diff --git a/Mage/src/main/java/mage/players/PlayerImpl.java b/Mage/src/main/java/mage/players/PlayerImpl.java index 87e0bac8875..4eaea5fff07 100644 --- a/Mage/src/main/java/mage/players/PlayerImpl.java +++ b/Mage/src/main/java/mage/players/PlayerImpl.java @@ -5372,11 +5372,6 @@ public abstract class PlayerImpl implements Player, Serializable { return true; } - @Override - public String getHistory() { - return "no available"; - } - @Override public boolean hasDesignation(DesignationType designationName) { for (Designation designation : designations) { -- 2.47.2 From a5c354f960a56808c47c590fc46bc43aaf079d95 Mon Sep 17 00:00:00 2001 From: Oleg Agafonov Date: Fri, 10 Jan 2025 22:04:21 +0400 Subject: [PATCH 32/32] You may play an additional land - added card hint to all lands about played count and max limit (#13216) --- .../java/mage/abilities/PlayLandAbility.java | 15 +++---- .../PlayAdditionalLandsAllEffect.java | 12 ++--- .../PlayAdditionalLandsControllerEffect.java | 12 ++--- .../common/CanPlayAdditionalLandsHint.java | 45 +++++++++++++++++++ 4 files changed, 60 insertions(+), 24 deletions(-) create mode 100644 Mage/src/main/java/mage/abilities/hint/common/CanPlayAdditionalLandsHint.java diff --git a/Mage/src/main/java/mage/abilities/PlayLandAbility.java b/Mage/src/main/java/mage/abilities/PlayLandAbility.java index 0288f7d415f..4425d6431e6 100644 --- a/Mage/src/main/java/mage/abilities/PlayLandAbility.java +++ b/Mage/src/main/java/mage/abilities/PlayLandAbility.java @@ -1,14 +1,13 @@ package mage.abilities; import mage.ApprovingObject; +import mage.abilities.hint.common.CanPlayAdditionalLandsHint; import mage.cards.Card; import mage.constants.AbilityType; import mage.constants.AsThoughEffectType; import mage.constants.Zone; import mage.game.Game; -import mage.players.Player; -import java.util.HashMap; import java.util.Set; import java.util.UUID; @@ -21,6 +20,8 @@ public class PlayLandAbility extends ActivatedAbilityImpl { super(AbilityType.PLAY_LAND, Zone.HAND); this.usesStack = false; this.name = "Play " + cardName; + + this.addHint(CanPlayAdditionalLandsHint.instance); } protected PlayLandAbility(final PlayLandAbility ability) { @@ -43,7 +44,7 @@ public class PlayLandAbility extends ActivatedAbilityImpl { } //20091005 - 114.2a - if(!game.isActivePlayer(playerId) + if (!game.isActivePlayer(playerId) || !game.getPlayer(playerId).canPlayLand() || !game.canPlaySorcery(playerId)) { return ActivationStatus.getFalse(); @@ -54,16 +55,15 @@ public class PlayLandAbility extends ActivatedAbilityImpl { if (!approvingObjects.isEmpty()) { Card card = game.getCard(sourceId); Zone zone = game.getState().getZone(sourceId); - if(card != null && card.isOwnedBy(playerId) && Zone.HAND.match(zone)) { + if (card != null && card.isOwnedBy(playerId) && Zone.HAND.match(zone)) { // Regular casting, to be an alternative to the AsThoughEffectType.PLAY_FROM_NOT_OWN_HAND_ZONE from hand (e.g. One with the Multiverse): approvingObjects.add(new ApprovingObject(this, game)); } } - if(approvingObjects.isEmpty()) { + if (approvingObjects.isEmpty()) { return ActivationStatus.withoutApprovingObject(true); - } - else { + } else { return new ActivationStatus(approvingObjects); } } @@ -87,5 +87,4 @@ public class PlayLandAbility extends ActivatedAbilityImpl { public PlayLandAbility copy() { return new PlayLandAbility(this); } - } diff --git a/Mage/src/main/java/mage/abilities/effects/common/continuous/PlayAdditionalLandsAllEffect.java b/Mage/src/main/java/mage/abilities/effects/common/continuous/PlayAdditionalLandsAllEffect.java index d96ac4736c6..0637319572b 100644 --- a/Mage/src/main/java/mage/abilities/effects/common/continuous/PlayAdditionalLandsAllEffect.java +++ b/Mage/src/main/java/mage/abilities/effects/common/continuous/PlayAdditionalLandsAllEffect.java @@ -1,4 +1,3 @@ - package mage.abilities.effects.common.continuous; import mage.abilities.Ability; @@ -9,6 +8,7 @@ import mage.constants.Outcome; import mage.constants.SubLayer; import mage.game.Game; import mage.players.Player; +import mage.util.CardUtil; /** * Each player may play an additional land on each of their turns. @@ -49,14 +49,10 @@ public class PlayAdditionalLandsAllEffect extends ContinuousEffectImpl { @Override public boolean apply(Game game, Ability source) { Player player = game.getPlayer(game.getActivePlayerId()); - if (player != null) { - if (numExtraLands == Integer.MAX_VALUE) { - player.setLandsPerTurn(Integer.MAX_VALUE); - } else { - player.setLandsPerTurn(player.getLandsPerTurn() + numExtraLands); - } - return true; + if (player == null) { + return false; } + player.setLandsPerTurn(CardUtil.overflowInc(player.getLandsPerTurn(), numExtraLands)); return true; } } diff --git a/Mage/src/main/java/mage/abilities/effects/common/continuous/PlayAdditionalLandsControllerEffect.java b/Mage/src/main/java/mage/abilities/effects/common/continuous/PlayAdditionalLandsControllerEffect.java index e82387dbea1..5c5135b365a 100644 --- a/Mage/src/main/java/mage/abilities/effects/common/continuous/PlayAdditionalLandsControllerEffect.java +++ b/Mage/src/main/java/mage/abilities/effects/common/continuous/PlayAdditionalLandsControllerEffect.java @@ -1,4 +1,3 @@ - package mage.abilities.effects.common.continuous; import mage.abilities.Ability; @@ -37,14 +36,11 @@ public class PlayAdditionalLandsControllerEffect extends ContinuousEffectImpl { @Override public boolean apply(Game game, Ability source) { Player player = game.getPlayer(source.getControllerId()); - if (player != null) { - if (player.getLandsPerTurn() == Integer.MAX_VALUE || this.additionalCards == Integer.MAX_VALUE) { - player.setLandsPerTurn(Integer.MAX_VALUE); - } else { - player.setLandsPerTurn(player.getLandsPerTurn() + this.additionalCards); - } - return true; + if (player == null) { + return false; } + + player.setLandsPerTurn(CardUtil.overflowInc(player.getLandsPerTurn(), additionalCards)); return true; } diff --git a/Mage/src/main/java/mage/abilities/hint/common/CanPlayAdditionalLandsHint.java b/Mage/src/main/java/mage/abilities/hint/common/CanPlayAdditionalLandsHint.java new file mode 100644 index 00000000000..4fb318ff52f --- /dev/null +++ b/Mage/src/main/java/mage/abilities/hint/common/CanPlayAdditionalLandsHint.java @@ -0,0 +1,45 @@ +package mage.abilities.hint.common; + +import mage.abilities.Ability; +import mage.abilities.hint.Hint; +import mage.abilities.hint.HintUtils; +import mage.game.Game; +import mage.players.Player; + +/** + * Global hint for all lands + * + * @author JayDi85 + */ +public enum CanPlayAdditionalLandsHint implements Hint { + + instance; + + @Override + public String getText(Game game, Ability ability) { + Player controller = game.getPlayer(ability.getControllerId()); + if (controller == null) { + return ""; + } + + // hide hint on default 1 land settings (useless to show) + if (controller.getLandsPerTurn() == 1) { + return ""; + } + + String stats = String.format(" (played %d of %s)", + controller.getLandsPlayed(), + (controller.getLandsPerTurn() == Integer.MAX_VALUE ? "any" : String.valueOf(controller.getLandsPerTurn())) + ); + if (controller.canPlayLand()) { + return HintUtils.prepareText("Can play more lands" + stats, null, HintUtils.HINT_ICON_GOOD); + } else { + return HintUtils.prepareText("Can't play lands" + stats, null, HintUtils.HINT_ICON_BAD); + } + } + + @Override + public Hint copy() { + return instance; + } +} -- 2.47.2