deck improves:

* gui: removed public deck hash info;
* gui: improved xmage dck-file - now it correctly load a card's amount (related to files from third party services);
* server: fixed wrong cheating warning on deck construction (closes #11877);
* refactor: removed outdated hash code and calculations;
* other: added docs, added multiple deck hash tests;
This commit is contained in:
Oleg Agafonov 2024-04-10 22:18:07 +04:00
parent 889c1125e8
commit 7817a5cac6
32 changed files with 551 additions and 247 deletions

View file

@ -1,28 +1,34 @@
package mage.cards.decks;
import mage.MageObject;
import mage.cards.Card;
import mage.cards.repository.CardInfo;
import mage.cards.repository.CardRepository;
import mage.game.GameException;
import mage.util.Copyable;
import mage.util.DeckUtil;
import org.apache.log4j.Logger;
import java.io.Serializable;
import java.util.*;
import java.util.LinkedHashSet;
import java.util.Map;
import java.util.Set;
import java.util.UUID;
import java.util.stream.Collectors;
/**
* Server side deck with workable cards, also can be used in GUI
* <p>
* If you need text only deck then look at DeckCardLists
*/
public class Deck implements Serializable, Copyable<Deck> {
static final int MAX_CARDS_PER_DECK = 2000;
private String name;
private String name; // TODO: must rework somehow - tiny leaders use deck name to find commander card and hide it for a user
private final Set<Card> cards = new LinkedHashSet<>();
private final Set<Card> sideboard = new LinkedHashSet<>();
private DeckCardLayout cardsLayout;
private DeckCardLayout sideboardLayout;
private long deckHashCode = 0;
private long deckCompleteHashCode = 0;
private DeckCardLayout cardsLayout; // client side only
private DeckCardLayout sideboardLayout; // client side only
public Deck() {
super();
@ -34,8 +40,6 @@ public class Deck implements Serializable, Copyable<Deck> {
this.sideboard.addAll(deck.sideboard.stream().map(Card::copy).collect(Collectors.toList()));
this.cardsLayout = deck.cardsLayout == null ? null : deck.cardsLayout.copy();
this.sideboardLayout = deck.sideboardLayout == null ? null : deck.sideboardLayout.copy();
this.deckHashCode = deck.deckHashCode;
this.deckCompleteHashCode = deck.deckCompleteHashCode;
}
public static Deck load(DeckCardLists deckCardLists) throws GameException {
@ -46,27 +50,15 @@ public class Deck implements Serializable, Copyable<Deck> {
return Deck.load(deckCardLists, ignoreErrors, true);
}
public static Deck append(Deck deckToAppend, Deck currentDeck) throws GameException {
List<String> deckCardNames = new ArrayList<>();
for (Card card : deckToAppend.getCards()) {
if (card != null) {
currentDeck.cards.add(card);
deckCardNames.add(card.getName());
}
}
List<String> sbCardNames = new ArrayList<>();
for (Card card : deckToAppend.getSideboard()) {
if (card != null) {
currentDeck.sideboard.add(card);
deckCardNames.add(card.getName());
}
}
Collections.sort(deckCardNames);
Collections.sort(sbCardNames);
String deckString = deckCardNames.toString() + sbCardNames.toString();
currentDeck.setDeckHashCode(DeckUtil.fixedHash(deckString));
return currentDeck;
public static Deck append(Deck sourceDeck, Deck currentDeck) throws GameException {
Deck newDeck = currentDeck.copy();
sourceDeck.getCards().forEach(card -> {
newDeck.cards.add(card.copy());
});
sourceDeck.getSideboard().forEach(card -> {
newDeck.sideboard.add(card.copy());
});
return newDeck;
}
public static Deck load(DeckCardLists deckCardLists, boolean ignoreErrors, boolean mockCards) throws GameException {
@ -87,49 +79,45 @@ public class Deck implements Serializable, Copyable<Deck> {
deck.setName(deckCardLists.getName());
deck.cardsLayout = deckCardLists.getCardLayout() == null ? null : deckCardLists.getCardLayout().copy();
deck.sideboardLayout = deckCardLists.getSideboardLayout() == null ? null : deckCardLists.getSideboardLayout().copy();
List<String> deckCardNames = new ArrayList<>();
int totalCards = 0;
for (DeckCardInfo deckCardInfo : deckCardLists.getCards()) {
Card card = createCard(deckCardInfo, mockCards, cardInfoCache);
if (card != null) {
if (totalCards > MAX_CARDS_PER_DECK) {
break;
}
deck.cards.add(card);
deckCardNames.add(card.getName());
totalCards++;
} else if (!ignoreErrors) {
throw createCardNotFoundGameException(deckCardInfo, deckCardLists.getName());
}
}
List<String> sbCardNames = new ArrayList<>();
for (DeckCardInfo deckCardInfo : deckCardLists.getSideboard()) {
Card card = createCard(deckCardInfo, mockCards, cardInfoCache);
if (card != null) {
// load only real cards
int totalCards = 0;
// main
main:
for (DeckCardInfo deckCardInfo : deckCardLists.getCards()) {
for (int i = 1; i <= deckCardInfo.getAmount(); i++) {
if (totalCards > MAX_CARDS_PER_DECK) {
break;
break main;
}
Card card = createCard(deckCardInfo, mockCards, cardInfoCache);
if (card != null) {
deck.cards.add(card);
totalCards++;
} else if (!ignoreErrors) {
throw createCardNotFoundGameException(deckCardInfo, deckCardLists.getName());
}
deck.sideboard.add(card);
sbCardNames.add(card.getName());
totalCards++;
} else if (!ignoreErrors) {
throw createCardNotFoundGameException(deckCardInfo, deckCardLists.getName());
}
}
Collections.sort(deckCardNames);
Collections.sort(sbCardNames);
String deckString = deckCardNames.toString() + sbCardNames.toString();
deck.setDeckHashCode(DeckUtil.fixedHash(deckString));
if (sbCardNames.isEmpty()) {
deck.setDeckCompleteHashCode(deck.getDeckHashCode());
} else {
List<String> deckAllCardNames = new ArrayList<>();
deckAllCardNames.addAll(deckCardNames);
deckAllCardNames.addAll(sbCardNames);
Collections.sort(deckAllCardNames);
deck.setDeckCompleteHashCode(DeckUtil.fixedHash(deckAllCardNames.toString()));
// sideboard
side:
for (DeckCardInfo deckCardInfo : deckCardLists.getSideboard()) {
for (int i = 1; i <= deckCardInfo.getAmount(); i++) {
if (totalCards > MAX_CARDS_PER_DECK) {
break side;
}
Card card = createCard(deckCardInfo, mockCards, cardInfoCache);
if (card != null) {
deck.sideboard.add(card);
totalCards++;
} else if (!ignoreErrors) {
throw createCardNotFoundGameException(deckCardInfo, deckCardLists.getName());
}
}
}
return deck;
}
@ -149,7 +137,7 @@ public class Deck implements Serializable, Copyable<Deck> {
String cardError = String.format("Card not found - %s - %s - %s in deck %s.",
deckCardInfo.getCardName(),
deckCardInfo.getSetCode(),
deckCardInfo.getCardNum(),
deckCardInfo.getCardNumber(),
deckName
);
cardError += "\n\nPossible reasons:";
@ -168,15 +156,15 @@ public class Deck implements Serializable, Copyable<Deck> {
CardInfo cardInfo;
if (cardInfoCache != null) {
// from cache
String key = String.format("%s_%s", deckCardInfo.getSetCode(), deckCardInfo.getCardNum());
String key = String.format("%s_%s", deckCardInfo.getSetCode(), deckCardInfo.getCardNumber());
cardInfo = cardInfoCache.getOrDefault(key, null);
if (cardInfo == null) {
cardInfo = CardRepository.instance.findCard(deckCardInfo.getSetCode(), deckCardInfo.getCardNum());
cardInfo = CardRepository.instance.findCard(deckCardInfo.getSetCode(), deckCardInfo.getCardNumber());
cardInfoCache.put(key, cardInfo);
}
} else {
// from db
cardInfo = CardRepository.instance.findCard(deckCardInfo.getSetCode(), deckCardInfo.getCardNum());
cardInfo = CardRepository.instance.findCard(deckCardInfo.getSetCode(), deckCardInfo.getCardNumber());
}
if (cardInfo == null) {
@ -190,13 +178,18 @@ public class Deck implements Serializable, Copyable<Deck> {
}
}
public DeckCardLists getDeckCardLists() {
/**
* Prepare new deck with cards only without layout and other client side settings.
* Use it for any export and server's deck update/submit
*/
public DeckCardLists prepareCardsOnlyDeck() {
DeckCardLists deckCardLists = new DeckCardLists();
deckCardLists.setName(name);
for (Card card : cards) {
deckCardLists.getCards().add(new DeckCardInfo(card.getName(), card.getCardNumber(), card.getExpansionSetCode()));
}
for (Card card : sideboard) {
deckCardLists.getSideboard().add(new DeckCardInfo(card.getName(), card.getCardNumber(), card.getExpansionSetCode()));
}
@ -227,6 +220,7 @@ public class Deck implements Serializable, Copyable<Deck> {
return cards;
}
// TODO: delete and replace by getCards()
public Set<Card> getMaindeckCards() {
return cards
.stream()
@ -262,22 +256,6 @@ public class Deck implements Serializable, Copyable<Deck> {
return sideboardLayout;
}
public long getDeckHashCode() {
return deckHashCode;
}
public void setDeckHashCode(long deckHashCode) {
this.deckHashCode = deckHashCode;
}
public long getDeckCompleteHashCode() {
return deckCompleteHashCode;
}
public void setDeckCompleteHashCode(long deckHashCode) {
this.deckCompleteHashCode = deckHashCode;
}
public void clearLayouts() {
this.cardsLayout = null;
this.sideboardLayout = null;
@ -287,4 +265,11 @@ public class Deck implements Serializable, Copyable<Deck> {
public Deck copy() {
return new Deck(this);
}
public long getDeckHash() {
return DeckUtil.getDeckHash(
this.cards.stream().map(MageObject::getName).collect(Collectors.toList()),
this.sideboard.stream().map(MageObject::getName).collect(Collectors.toList())
);
}
}

View file

@ -1,5 +1,3 @@
package mage.cards.decks;
import mage.util.Copyable;
@ -7,14 +5,18 @@ import mage.util.Copyable;
import java.io.Serializable;
/**
* Client side: card record in deck file
*
* @author LevelX2
*/
public class DeckCardInfo implements Serializable, Copyable<DeckCardInfo> {
static final int MAX_AMOUNT_PER_CARD = 99;
private String cardName;
private String setCode;
private String cardNum;
private int quantity;
private String cardNumber;
private int amount;
public DeckCardInfo() {
super();
@ -23,19 +25,29 @@ public class DeckCardInfo implements Serializable, Copyable<DeckCardInfo> {
protected DeckCardInfo(final DeckCardInfo info) {
this.cardName = info.cardName;
this.setCode = info.setCode;
this.cardNum = info.cardNum;
this.quantity = info.quantity;
this.cardNumber = info.cardNumber;
this.amount = info.amount;
}
public DeckCardInfo(String cardName, String cardNum, String setCode) {
this(cardName, cardNum, setCode, 1);
public DeckCardInfo(String cardName, String cardNumber, String setCode) {
this(cardName, cardNumber, setCode, 1);
}
public DeckCardInfo(String cardName, String cardNum, String setCode, int quantity) {
public static void makeSureCardAmountFine(int amount, String cardName) {
// runtime check
if (amount > MAX_AMOUNT_PER_CARD) {
// xmage uses 1 for amount all around, but keep that protection anyway
throw new IllegalArgumentException("Found too big amount for a deck's card: " + cardName + " - " + amount);
}
}
public DeckCardInfo(String cardName, String cardNumber, String setCode, int amount) {
makeSureCardAmountFine(amount, cardName);
this.cardName = cardName;
this.cardNum = cardNum;
this.cardNumber = cardNumber;
this.setCode = setCode;
this.quantity = quantity;
this.amount = amount;
}
public String getCardName() {
@ -46,21 +58,16 @@ public class DeckCardInfo implements Serializable, Copyable<DeckCardInfo> {
return setCode;
}
public String getCardNum() {
return cardNum;
public String getCardNumber() {
return cardNumber;
}
public int getQuantity() {
return quantity;
}
public DeckCardInfo increaseQuantity() {
quantity++;
return this;
public int getAmount() {
return amount;
}
public String getCardKey() {
return setCode + cardNum;
return setCode + cardNumber;
}
@Override

View file

@ -6,9 +6,11 @@ import mage.util.Copyable;
import java.io.Serializable;
import java.util.ArrayList;
import java.util.List;
import java.util.stream.Collectors;
/**
* Client side deck with text only.
* <p>
* Can contain restricted, un-implemented or unknown cards
*
* @author BetaSteward_at_googlemail.com, JayDi85
*/
@ -46,12 +48,15 @@ public class DeckCardLists implements Serializable, Copyable<DeckCardLists> {
public DeckCardLayout getCardLayout() {
return cardLayout;
}
public void setCardLayout(DeckCardLayout layout) {
this.cardLayout = layout;
}
public DeckCardLayout getSideboardLayout() {
return sideboardLayout;
}
public void setSideboardLayout(DeckCardLayout layout) {
this.sideboardLayout = layout;
}

View file

@ -72,6 +72,8 @@ public enum DeckFormats {
return res;
}
// TODO: need refactor and remove all that methods to 1-3 versions only
public static void writeDeck(String file, DeckCardLists deck) throws IOException {
writeDeck(new File(file), deck);
}

View file

@ -56,8 +56,9 @@ public enum ExpansionRepository {
instanceInitialized = true;
eventSource.fireRepositoryDbLoaded();
} catch (SQLException ex) {
ex.printStackTrace();
} catch (SQLException e) {
// TODO: add app close?
e.printStackTrace();
}
}

View file

@ -11,7 +11,6 @@ import mage.cards.repository.CardRepository;
import mage.constants.Zone;
import mage.game.command.Emblem;
import mage.util.CardUtil;
import org.apache.log4j.Logger;
import java.util.List;
import java.util.stream.Collectors;
@ -50,7 +49,7 @@ public final class EmblemOfCard extends Emblem {
public static Card cardFromDeckInfo(DeckCardInfo info) {
return lookupCard(
info.getCardName(),
info.getCardNum(),
info.getCardNumber(),
info.getSetCode(),
"DeckCardInfo"
);

View file

@ -41,7 +41,7 @@ public interface Match {
void submitDeck(UUID playerId, Deck deck);
boolean updateDeck(UUID playerId, Deck deck);
void updateDeck(UUID playerId, Deck deck);
void startMatch();

View file

@ -379,9 +379,8 @@ public abstract class MatchImpl implements Match {
public void submitDeck(UUID playerId, Deck deck) {
MatchPlayer player = getPlayer(playerId);
if (player != null) {
// make sure the deck name (needed for Tiny Leaders) won't get lost by sideboarding
// workaround to keep deck name for Tiny Leaders because it must be hidden for players
deck.setName(player.getDeck().getName());
deck.setDeckHashCode(player.getDeck().getDeckHashCode());
player.submitDeck(deck);
}
synchronized (this) {
@ -390,20 +389,14 @@ public abstract class MatchImpl implements Match {
}
@Override
public boolean updateDeck(UUID playerId, Deck deck) {
boolean validDeck = true;
public void updateDeck(UUID playerId, Deck deck) {
// used for auto-save deck
MatchPlayer player = getPlayer(playerId);
if (player != null) {
// Check if the cards included in the deck are the same as in the original deck
validDeck = (player.getDeck().getDeckCompleteHashCode() == deck.getDeckCompleteHashCode());
if (validDeck == false) {
// clear the deck so the player cheating looses the game
deck.getCards().clear();
deck.getSideboard().clear();
}
player.updateDeck(deck);
if (player == null) {
return;
}
return validDeck;
player.updateDeck(deck);
}
protected String createGameStartMessage() {
@ -416,9 +409,6 @@ public abstract class MatchImpl implements Match {
sb.append(" QUITTED");
}
sb.append("<br/>");
if (mp.getDeck() != null) {
sb.append("DeckHash: ").append(mp.getDeck().getDeckHashCode()).append("<br/>");
}
}
if (getDraws() > 0) {
sb.append(" Draws: ").append(getDraws()).append("<br/>");

View file

@ -4,6 +4,7 @@ import mage.cards.Card;
import mage.cards.decks.Deck;
import mage.cards.decks.DeckValidator;
import mage.players.Player;
import org.apache.log4j.Logger;
import java.io.Serializable;
@ -12,13 +13,15 @@ import java.io.Serializable;
*/
public class MatchPlayer implements Serializable {
private static final Logger logger = Logger.getLogger(MatchPlayer.class);
private int wins;
private int winsNeeded;
private boolean matchWinner;
private Deck deck;
private Player player;
private String name;
private final Player player;
private final String name;
private boolean quit;
private boolean doneSideboarding;
@ -89,22 +92,33 @@ public class MatchPlayer implements Serializable {
return viewerDeck;
}
public void submitDeck(Deck deck) {
this.deck = deck;
public void submitDeck(Deck newDeck) {
this.deck = newDeck;
this.doneSideboarding = true;
}
public void updateDeck(Deck deck) {
public boolean updateDeck(Deck newDeck) {
// used for auto-save by timeout from client side
// workaround to keep deck name for Tiny Leaders because it must be hidden for players
if (this.deck != null) {
// preserver deck name, important for Tiny Leaders format
deck.setName(this.getDeck().getName());
// preserve the original deck hash code before sideboarding to give no information if cards were swapped
deck.setDeckHashCode(this.getDeck().getDeckHashCode());
newDeck.setName(this.getDeck().getName());
}
this.deck = deck;
// make sure it's the same deck (player do not add or remove something)
boolean isGood = (this.deck.getDeckHash() == newDeck.getDeckHash());
if (!isGood) {
logger.error("Found cheating player " + player.getName()
+ " with changed deck, main " + newDeck.getCards().size() + ", side " + newDeck.getSideboard().size());
newDeck.getCards().clear();
newDeck.getSideboard().clear();
}
this.deck = newDeck;
return isGood;
}
public Deck generateDeck(DeckValidator deckValidator) {
public Deck autoCompleteDeck(DeckValidator deckValidator) {
// auto complete deck
while (deck.getMaindeckCards().size() < deckValidator.getDeckMinSize() && !deck.getSideboard().isEmpty()) {
Card card = deck.getSideboard().iterator().next();

View file

@ -49,7 +49,7 @@ public interface Tournament {
void submitDeck(UUID playerId, Deck deck);
boolean updateDeck(UUID playerId, Deck deck);
void updateDeck(UUID playerId, Deck deck);
void autoSubmit(UUID playerId, Deck deck);

View file

@ -139,11 +139,13 @@ public abstract class TournamentImpl implements Tournament {
}
@Override
public boolean updateDeck(UUID playerId, Deck deck) {
if (players.containsKey(playerId)) {
return players.get(playerId).updateDeck(deck);
public void updateDeck(UUID playerId, Deck deck) {
TournamentPlayer player = players.getOrDefault(playerId, null);
if (player == null) {
return;
}
return false;
player.updateDeck(deck);
}
protected Round createRoundRandom() {

View file

@ -93,18 +93,18 @@ public class TournamentPlayer {
}
public boolean updateDeck(Deck deck) {
// Check if the cards included in the deck are the same as in the original deck
boolean validDeck = (getDeck().getDeckCompleteHashCode() == deck.getDeckCompleteHashCode());
if (!validDeck) {
// Clear the deck so the player cheating looses the game
// TODO: inform other players about cheating?!
logger.error("Found cheating player " + getPlayer().getName()
// 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());
if (!isGood) {
logger.error("Found cheating tourney player " + player.getName()
+ " with changed deck, main " + deck.getCards().size() + ", side " + deck.getSideboard().size());
deck.getCards().clear();
deck.getSideboard().clear();
}
this.deck = deck;
return validDeck;
return isGood;
}
/**

View file

@ -1,20 +1,36 @@
package mage.util;
import mage.cards.decks.Deck;
import mage.players.Player;
import org.apache.log4j.Logger;
import java.io.BufferedWriter;
import java.io.File;
import java.io.FileWriter;
import java.io.IOException;
import java.util.ArrayList;
import java.util.Collections;
import java.util.List;
/**
* @author LevelX2
* @author LevelX2, JayDi85
*/
public final class DeckUtil {
private static final Logger logger = Logger.getLogger(DeckUtil.class);
public static long fixedHash(String string) {
/**
* Find deck hash (main + sideboard)
* It must be same after sideboard (do not depend on main/sb structure)
*/
public static long getDeckHash(List<String> mainCards, List<String> sideboardCards) {
List<String> all = new ArrayList<>(mainCards);
all.addAll(sideboardCards);
Collections.sort(all);
return getStringHash(all.toString());
}
private static long getStringHash(String string) {
long h = 1125899906842597L; // prime
int len = string.length();
@ -42,4 +58,27 @@ 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;
}
}