From e209ce1c972512b327daed985ba3c0e7b6a2b6cd Mon Sep 17 00:00:00 2001 From: Oleg Agafonov Date: Tue, 11 Jun 2024 00:30:00 +0400 Subject: [PATCH] server: fixed wrong cheater detection in some tourney sideboardings (closes #11877) --- .../src/mage/deck/Brawl.java | 2 +- .../java/mage/server/TableController.java | 7 +- .../server/managers/TournamentManager.java | 2 +- .../tournament/TournamentController.java | 4 +- .../tournament/TournamentManagerImpl.java | 4 +- .../server/tournament/TournamentSession.java | 4 +- .../test/serverside/deck/DeckHashTest.java | 91 ++++++++++++++++++- .../org/mage/test/stub/TournamentStub.java | 3 +- Mage/src/main/java/mage/cards/decks/Deck.java | 5 +- .../java/mage/cards/decks/DeckValidator.java | 19 +++- Mage/src/main/java/mage/game/match/Match.java | 2 +- .../main/java/mage/game/match/MatchImpl.java | 4 +- .../java/mage/game/match/MatchPlayer.java | 4 +- .../java/mage/game/tournament/Tournament.java | 2 +- .../mage/game/tournament/TournamentImpl.java | 4 +- .../game/tournament/TournamentPlayer.java | 4 +- Mage/src/main/java/mage/util/DeckUtil.java | 40 +++----- 17 files changed, 146 insertions(+), 55 deletions(-) diff --git a/Mage.Server.Plugins/Mage.Deck.Constructed/src/mage/deck/Brawl.java b/Mage.Server.Plugins/Mage.Deck.Constructed/src/mage/deck/Brawl.java index e093d9058a6..5badfb5cf60 100644 --- a/Mage.Server.Plugins/Mage.Deck.Constructed/src/mage/deck/Brawl.java +++ b/Mage.Server.Plugins/Mage.Deck.Constructed/src/mage/deck/Brawl.java @@ -112,7 +112,7 @@ public class Brawl extends Constructed { Set basicsInDeck = new HashSet<>(); if (colorIdentity.isColorless()) { for (Card card : deck.getCards()) { - if (basicLandNames.contains(card.getName())) { + if (ALL_BASIC_LAND_NAMES.contains(card.getName())) { basicsInDeck.add(card.getName()); } } diff --git a/Mage.Server/src/main/java/mage/server/TableController.java b/Mage.Server/src/main/java/mage/server/TableController.java index da74b4c64d9..4fd60f048a9 100644 --- a/Mage.Server/src/main/java/mage/server/TableController.java +++ b/Mage.Server/src/main/java/mage/server/TableController.java @@ -503,15 +503,18 @@ public class TableController { } private void updateDeck(UUID userId, UUID playerId, Deck deck) { + // strict mode - players can't add any lands while sideboarding in single game + // ignore mode - players can add any lands while construction/sideboarding in draft tourney + boolean ignoreMainBasicLands = table.isTournament() || table.isTournamentSubTable(); if (table.isTournament()) { if (tournament != null) { // TODO: is it possible to update from direct call command in game?! - managerFactory.tournamentManager().updateDeck(tournament.getId(), playerId, deck); + managerFactory.tournamentManager().updateDeck(tournament.getId(), playerId, deck, ignoreMainBasicLands); } else { logger.fatal("Tournament == null table: " + table.getId() + " userId: " + userId); } } else if (table.getState() == TableState.SIDEBOARDING) { - match.updateDeck(playerId, deck); + match.updateDeck(playerId, deck, ignoreMainBasicLands); } else { // deck was meanwhile submitted so the autoupdate can be ignored // TODO: need research diff --git a/Mage.Server/src/main/java/mage/server/managers/TournamentManager.java b/Mage.Server/src/main/java/mage/server/managers/TournamentManager.java index d5574011ae1..5636a0a0010 100644 --- a/Mage.Server/src/main/java/mage/server/managers/TournamentManager.java +++ b/Mage.Server/src/main/java/mage/server/managers/TournamentManager.java @@ -22,7 +22,7 @@ public interface TournamentManager { void submitDeck(UUID tournamentId, UUID playerId, Deck deck); - void updateDeck(UUID tournamentId, UUID playerId, Deck deck); + void updateDeck(UUID tournamentId, UUID playerId, Deck deck, boolean ignoreMainBasicLands); TournamentView getTournamentView(UUID tournamentId); diff --git a/Mage.Server/src/main/java/mage/server/tournament/TournamentController.java b/Mage.Server/src/main/java/mage/server/tournament/TournamentController.java index 87e5f809b7a..d6d8dd8d6d1 100644 --- a/Mage.Server/src/main/java/mage/server/tournament/TournamentController.java +++ b/Mage.Server/src/main/java/mage/server/tournament/TournamentController.java @@ -326,13 +326,13 @@ public class TournamentController { } } - public void updateDeck(UUID playerId, Deck deck) { + public void updateDeck(UUID playerId, Deck deck, boolean ignoreMainBasicLands) { TournamentSession session = tournamentSessions.getOrDefault(playerId, null); if (session == null) { return; } - session.updateDeck(deck); + session.updateDeck(deck, ignoreMainBasicLands); } public void timeout(UUID userId) { diff --git a/Mage.Server/src/main/java/mage/server/tournament/TournamentManagerImpl.java b/Mage.Server/src/main/java/mage/server/tournament/TournamentManagerImpl.java index 18fb4bd4094..480ec55149e 100644 --- a/Mage.Server/src/main/java/mage/server/tournament/TournamentManagerImpl.java +++ b/Mage.Server/src/main/java/mage/server/tournament/TournamentManagerImpl.java @@ -62,8 +62,8 @@ public class TournamentManagerImpl implements TournamentManager { } @Override - public void updateDeck(UUID tournamentId, UUID playerId, Deck deck) { - controllers.get(tournamentId).updateDeck(playerId, deck); + public void updateDeck(UUID tournamentId, UUID playerId, Deck deck, boolean ignoreMainBasicLands) { + controllers.get(tournamentId).updateDeck(playerId, deck, ignoreMainBasicLands); } @Override diff --git a/Mage.Server/src/main/java/mage/server/tournament/TournamentSession.java b/Mage.Server/src/main/java/mage/server/tournament/TournamentSession.java index 8496fb1cf57..e90ba2326e0 100644 --- a/Mage.Server/src/main/java/mage/server/tournament/TournamentSession.java +++ b/Mage.Server/src/main/java/mage/server/tournament/TournamentSession.java @@ -83,8 +83,8 @@ public class TournamentSession { tournament.submitDeck(playerId, deck); } - public void updateDeck(Deck deck) { - tournament.updateDeck(playerId, deck); + public void updateDeck(Deck deck, boolean ignoreMainBasicLands) { + tournament.updateDeck(playerId, deck, ignoreMainBasicLands); } public void setKilled() { diff --git a/Mage.Tests/src/test/java/org/mage/test/serverside/deck/DeckHashTest.java b/Mage.Tests/src/test/java/org/mage/test/serverside/deck/DeckHashTest.java index 5abd1069b82..59ac858f50b 100644 --- a/Mage.Tests/src/test/java/org/mage/test/serverside/deck/DeckHashTest.java +++ b/Mage.Tests/src/test/java/org/mage/test/serverside/deck/DeckHashTest.java @@ -4,8 +4,10 @@ import mage.cards.decks.Deck; import mage.cards.decks.DeckCardInfo; import mage.cards.decks.DeckCardLayout; import mage.cards.decks.DeckCardLists; +import mage.cards.repository.CardScanner; import mage.game.GameException; import org.junit.Assert; +import org.junit.Before; import org.junit.Test; import org.mage.test.serverside.base.MageTestPlayerBase; @@ -24,12 +26,25 @@ public class DeckHashTest extends MageTestPlayerBase { private static final DeckCardInfo GOOD_CARD_1_x3 = new DeckCardInfo("Lightning Bolt", "141", "CLU", 3); private static final DeckCardInfo GOOD_CARD_2 = new DeckCardInfo("Bear's Companion", "182", "2x2"); private static final DeckCardInfo GOOD_CARD_3 = new DeckCardInfo("Amplifire", "92", "RNA"); + private static final DeckCardInfo GOOD_CARD_4_BASIC_LAND = new DeckCardInfo("Forest", "276", "XLN"); + private static final DeckCardInfo GOOD_CARD_5_BASIC_LAND = new DeckCardInfo("Plains", "260", "XLN"); + private static final DeckCardInfo GOOD_CARD_6_BASIC_LAND_SNOW = new DeckCardInfo("Snow-Covered Forest", "383", "ICE"); + private static final DeckCardInfo GOOD_CARD_7_WASTES = new DeckCardInfo("Wastes", "408", "M3C"); + + @Before + public void setUp() { + CardScanner.scan(); + } private void assertDecks(String check, boolean mustBeSame, Deck deck1, Deck deck2) { + assertDecks(check, mustBeSame, deck1, deck2, false); + } + + private void assertDecks(String check, boolean mustBeSame, Deck deck1, Deck deck2, boolean ignoreBasicLands) { Assert.assertEquals( check + " - " + (mustBeSame ? "hash code must be same" : "hash code must be different"), mustBeSame, - deck1.getDeckHash() == deck2.getDeckHash() + deck1.getDeckHash(ignoreBasicLands) == deck2.getDeckHash(ignoreBasicLands) ); } @@ -238,4 +253,78 @@ public class DeckHashTest extends MageTestPlayerBase { } Assert.fail("must raise exception on too big amount"); } + + @Test + public void test_IgnoreBasicLands() { + // related rules: + // Players may add an unlimited number of cards named Plains, Island, Swamp, Mountain, or Forest to their + // deck and sideboard. They may not add additional snow basic land cards (e.g., Snow-Covered Forest, etc) + // or Wastes basic land cards, even in formats in which they are legal. + + assertDecks( + "same basic lands - strict mode", + true, + prepareCardsDeck(Arrays.asList(GOOD_CARD_1, GOOD_CARD_4_BASIC_LAND, GOOD_CARD_2), Arrays.asList()), + prepareCardsDeck(Arrays.asList(GOOD_CARD_1, GOOD_CARD_4_BASIC_LAND, GOOD_CARD_2), Arrays.asList()), + false + ); + + assertDecks( + "same basic lands - ignore mode", + true, + prepareCardsDeck(Arrays.asList(GOOD_CARD_1, GOOD_CARD_4_BASIC_LAND, GOOD_CARD_2), Arrays.asList()), + prepareCardsDeck(Arrays.asList(GOOD_CARD_1, GOOD_CARD_4_BASIC_LAND, GOOD_CARD_2), Arrays.asList()), + true + ); + + assertDecks( + "diff basic lands - strict mode", + false, + prepareCardsDeck(Arrays.asList(GOOD_CARD_1, GOOD_CARD_4_BASIC_LAND, GOOD_CARD_2), Arrays.asList()), + prepareCardsDeck(Arrays.asList(GOOD_CARD_1, GOOD_CARD_5_BASIC_LAND, GOOD_CARD_2), Arrays.asList()), + false + ); + + assertDecks( + "diff basic lands - ignore mode", + true, + prepareCardsDeck(Arrays.asList(GOOD_CARD_1, GOOD_CARD_4_BASIC_LAND, GOOD_CARD_2), Arrays.asList()), + prepareCardsDeck(Arrays.asList(GOOD_CARD_1, GOOD_CARD_5_BASIC_LAND, GOOD_CARD_2), Arrays.asList()), + true + ); + + // snow covered and wastes must be checked as normal cards (not ignore) + + assertDecks( + "diff snow-covered basic lands - strict mode", + false, + prepareCardsDeck(Arrays.asList(GOOD_CARD_1, GOOD_CARD_4_BASIC_LAND, GOOD_CARD_2), Arrays.asList()), + prepareCardsDeck(Arrays.asList(GOOD_CARD_1, GOOD_CARD_6_BASIC_LAND_SNOW, GOOD_CARD_2), Arrays.asList()), + false + ); + + assertDecks( + "diff snow-covered basic lands - ignore mode", + false, + prepareCardsDeck(Arrays.asList(GOOD_CARD_1, GOOD_CARD_4_BASIC_LAND, GOOD_CARD_2), Arrays.asList()), + prepareCardsDeck(Arrays.asList(GOOD_CARD_1, GOOD_CARD_6_BASIC_LAND_SNOW, GOOD_CARD_2), Arrays.asList()), + true + ); + + assertDecks( + "diff wastes basic lands - strict mode", + false, + prepareCardsDeck(Arrays.asList(GOOD_CARD_1, GOOD_CARD_4_BASIC_LAND, GOOD_CARD_2), Arrays.asList()), + prepareCardsDeck(Arrays.asList(GOOD_CARD_1, GOOD_CARD_7_WASTES, GOOD_CARD_2), Arrays.asList()), + false + ); + + assertDecks( + "diff wastes basic lands - ignore mode", + false, + prepareCardsDeck(Arrays.asList(GOOD_CARD_1, GOOD_CARD_4_BASIC_LAND, GOOD_CARD_2), Arrays.asList()), + prepareCardsDeck(Arrays.asList(GOOD_CARD_1, GOOD_CARD_7_WASTES, GOOD_CARD_2), Arrays.asList()), + true + ); + } } diff --git a/Mage.Tests/src/test/java/org/mage/test/stub/TournamentStub.java b/Mage.Tests/src/test/java/org/mage/test/stub/TournamentStub.java index 572f3d37a85..5f516fbf7f3 100644 --- a/Mage.Tests/src/test/java/org/mage/test/stub/TournamentStub.java +++ b/Mage.Tests/src/test/java/org/mage/test/stub/TournamentStub.java @@ -85,8 +85,7 @@ public class TournamentStub implements Tournament { } @Override - public void updateDeck(UUID playerId, Deck deck) { - + public void updateDeck(UUID playerId, Deck deck, boolean ignoreMainBasicLands) { } @Override diff --git a/Mage/src/main/java/mage/cards/decks/Deck.java b/Mage/src/main/java/mage/cards/decks/Deck.java index 7fbc8c80e8a..846a834e505 100644 --- a/Mage/src/main/java/mage/cards/decks/Deck.java +++ b/Mage/src/main/java/mage/cards/decks/Deck.java @@ -254,10 +254,11 @@ public class Deck implements Serializable, Copyable { return new Deck(this); } - public long getDeckHash() { + public long getDeckHash(boolean ignoreMainBasicLands) { return DeckUtil.getDeckHash( this.cards.stream().map(MageObject::getName).collect(Collectors.toList()), - this.sideboard.stream().map(MageObject::getName).collect(Collectors.toList()) + this.sideboard.stream().map(MageObject::getName).collect(Collectors.toList()), + ignoreMainBasicLands ); } } diff --git a/Mage/src/main/java/mage/cards/decks/DeckValidator.java b/Mage/src/main/java/mage/cards/decks/DeckValidator.java index c4cee146e87..eb2bd19506e 100644 --- a/Mage/src/main/java/mage/cards/decks/DeckValidator.java +++ b/Mage/src/main/java/mage/cards/decks/DeckValidator.java @@ -11,12 +11,15 @@ import java.util.stream.Collectors; */ public abstract class DeckValidator implements Serializable { - protected static final List basicLandNames = Arrays.asList( + public static final HashSet MAIN_BASIC_LAND_NAMES = new HashSet<>(Arrays.asList( "Plains", "Island", "Swamp", "Mountain", - "Forest", + "Forest" + )); + + public static final HashSet ADDITIONAL_BASIC_LAND_NAMES = new HashSet<>(Arrays.asList( "Wastes", "Snow-Covered Plains", "Snow-Covered Island", @@ -24,11 +27,19 @@ public abstract class DeckValidator implements Serializable { "Snow-Covered Mountain", "Snow-Covered Forest", "Snow-Covered Wastes" - ); + )); + + public static final HashSet ALL_BASIC_LAND_NAMES = new HashSet<>(); + { + ALL_BASIC_LAND_NAMES.addAll(MAIN_BASIC_LAND_NAMES); + ALL_BASIC_LAND_NAMES.addAll(ADDITIONAL_BASIC_LAND_NAMES); + } + protected static final Map maxCopiesMap = new HashMap<>(); static { - basicLandNames.stream().forEach(s -> maxCopiesMap.put(s, Integer.MAX_VALUE)); + MAIN_BASIC_LAND_NAMES.forEach(s -> maxCopiesMap.put(s, Integer.MAX_VALUE)); + ADDITIONAL_BASIC_LAND_NAMES.forEach(s -> maxCopiesMap.put(s, Integer.MAX_VALUE)); maxCopiesMap.put("Relentless Rats", Integer.MAX_VALUE); maxCopiesMap.put("Shadowborn Apostle", Integer.MAX_VALUE); maxCopiesMap.put("Rat Colony", Integer.MAX_VALUE); diff --git a/Mage/src/main/java/mage/game/match/Match.java b/Mage/src/main/java/mage/game/match/Match.java index bf3fe7d64e8..76e74e71683 100644 --- a/Mage/src/main/java/mage/game/match/Match.java +++ b/Mage/src/main/java/mage/game/match/Match.java @@ -41,7 +41,7 @@ public interface Match { void submitDeck(UUID playerId, Deck deck); - void updateDeck(UUID playerId, Deck deck); + void updateDeck(UUID playerId, Deck deck, boolean ignoreMainBasicLands); void startMatch(); diff --git a/Mage/src/main/java/mage/game/match/MatchImpl.java b/Mage/src/main/java/mage/game/match/MatchImpl.java index fcd3f1e337d..9d3b6463035 100644 --- a/Mage/src/main/java/mage/game/match/MatchImpl.java +++ b/Mage/src/main/java/mage/game/match/MatchImpl.java @@ -389,14 +389,14 @@ public abstract class MatchImpl implements Match { } @Override - public void updateDeck(UUID playerId, Deck deck) { + public void updateDeck(UUID playerId, Deck deck, boolean ignoreMainBasicLands) { // used for auto-save deck MatchPlayer player = getPlayer(playerId); if (player == null) { return; } - player.updateDeck(deck); + player.updateDeck(deck, ignoreMainBasicLands); } protected String createGameStartMessage() { diff --git a/Mage/src/main/java/mage/game/match/MatchPlayer.java b/Mage/src/main/java/mage/game/match/MatchPlayer.java index aa8b0945898..7123fb86171 100644 --- a/Mage/src/main/java/mage/game/match/MatchPlayer.java +++ b/Mage/src/main/java/mage/game/match/MatchPlayer.java @@ -97,7 +97,7 @@ public class MatchPlayer implements Serializable { this.doneSideboarding = true; } - public boolean updateDeck(Deck newDeck) { + public boolean updateDeck(Deck newDeck, boolean ignoreMainBasicLands) { // used for auto-save by timeout from client side // workaround to keep deck name for Tiny Leaders because it must be hidden for players @@ -106,7 +106,7 @@ public class MatchPlayer implements Serializable { } // make sure it's the same deck (player do not add or remove something) - boolean isGood = (this.deck.getDeckHash() == newDeck.getDeckHash()); + boolean isGood = (this.deck.getDeckHash(ignoreMainBasicLands) == newDeck.getDeckHash(ignoreMainBasicLands)); if (!isGood) { logger.error("Found cheating player " + player.getName() + " with changed deck, main " + newDeck.getCards().size() + ", side " + newDeck.getSideboard().size()); diff --git a/Mage/src/main/java/mage/game/tournament/Tournament.java b/Mage/src/main/java/mage/game/tournament/Tournament.java index 6bdf6d372d5..c0136095aa8 100644 --- a/Mage/src/main/java/mage/game/tournament/Tournament.java +++ b/Mage/src/main/java/mage/game/tournament/Tournament.java @@ -49,7 +49,7 @@ public interface Tournament { void submitDeck(UUID playerId, Deck deck); - void updateDeck(UUID playerId, Deck deck); + void updateDeck(UUID playerId, Deck deck, boolean ignoreMainBasicLands); void autoSubmit(UUID playerId, Deck deck); diff --git a/Mage/src/main/java/mage/game/tournament/TournamentImpl.java b/Mage/src/main/java/mage/game/tournament/TournamentImpl.java index 9fd1d41a9fa..3f8e606a1ef 100644 --- a/Mage/src/main/java/mage/game/tournament/TournamentImpl.java +++ b/Mage/src/main/java/mage/game/tournament/TournamentImpl.java @@ -139,13 +139,13 @@ public abstract class TournamentImpl implements Tournament { } @Override - public void updateDeck(UUID playerId, Deck deck) { + public void updateDeck(UUID playerId, Deck deck, boolean ignoreMainBasicLands) { TournamentPlayer player = players.getOrDefault(playerId, null); if (player == null) { return; } - player.updateDeck(deck); + player.updateDeck(deck, ignoreMainBasicLands); } protected Round createRoundRandom() { diff --git a/Mage/src/main/java/mage/game/tournament/TournamentPlayer.java b/Mage/src/main/java/mage/game/tournament/TournamentPlayer.java index 1e1631d019c..314a09ab1e8 100644 --- a/Mage/src/main/java/mage/game/tournament/TournamentPlayer.java +++ b/Mage/src/main/java/mage/game/tournament/TournamentPlayer.java @@ -92,11 +92,11 @@ public class TournamentPlayer { this.setState(TournamentPlayerState.WAITING); } - public boolean updateDeck(Deck deck) { + public boolean updateDeck(Deck deck, boolean ignoreMainBasicLands) { // used for auto-save deck // make sure it's the same deck (player do not add or remove something) - boolean isGood = (this.getDeck().getDeckHash() == deck.getDeckHash()); + boolean isGood = (this.getDeck().getDeckHash(ignoreMainBasicLands) == deck.getDeckHash(ignoreMainBasicLands)); if (!isGood) { logger.error("Found cheating tourney player " + player.getName() + " with changed deck, main " + deck.getCards().size() + ", side " + deck.getSideboard().size()); diff --git a/Mage/src/main/java/mage/util/DeckUtil.java b/Mage/src/main/java/mage/util/DeckUtil.java index 10f484d2d4d..51c6d60303a 100644 --- a/Mage/src/main/java/mage/util/DeckUtil.java +++ b/Mage/src/main/java/mage/util/DeckUtil.java @@ -1,7 +1,6 @@ package mage.util; -import mage.cards.decks.Deck; -import mage.players.Player; +import mage.cards.decks.DeckValidator; import org.apache.log4j.Logger; import java.io.BufferedWriter; @@ -22,10 +21,22 @@ public final class DeckUtil { /** * Find deck hash (main + sideboard) * It must be same after sideboard (do not depend on main/sb structure) + * + * @param ignoreMainBasicLands - drafts allow to use any basic lands, so ignore it for hash calculation */ - public static long getDeckHash(List mainCards, List sideboardCards) { + public static long getDeckHash(List mainCards, List sideboardCards, boolean ignoreMainBasicLands) { List all = new ArrayList<>(mainCards); all.addAll(sideboardCards); + + // related rules: + // 7.2 Card Use in Limited Tournaments + // Players may add an unlimited number of cards named Plains, Island, Swamp, Mountain, or Forest to their + // deck and sideboard. They may not add additional snow basic land cards (e.g., Snow-Covered Forest, etc) + // or Wastes basic land cards, even in formats in which they are legal. + if (ignoreMainBasicLands) { + all.removeIf(DeckValidator.MAIN_BASIC_LAND_NAMES::contains); + } + Collections.sort(all); return getStringHash(all.toString()); } @@ -58,27 +69,4 @@ public final class DeckUtil { } return null; } - - /** - * make sure it's the same deck (player do not add or remove something) - * - * @param newDeck will be clear on cheating - */ - public boolean checkDeckForModification(String checkInfo, Player player, Deck oldDeck, Deck newDeck) { - boolean isGood = (oldDeck.getDeckHash() == newDeck.getDeckHash()); - if (!isGood) { - String message = String.format("Found cheating player [%s] in [%s] with changed deck, main %d -> %d, side %d -> %d", - player.getName(), - checkInfo, - oldDeck.getCards().size(), - newDeck.getCards().size(), - oldDeck.getSideboard().size(), - newDeck.getSideboard().size() - ); - logger.error(message); - newDeck.getCards().clear(); - newDeck.getSideboard().clear(); - } - return isGood; - } }