diff --git a/.gitignore b/.gitignore index 92368d784d..2b2ae770ee 100644 --- a/.gitignore +++ b/.gitignore @@ -135,3 +135,4 @@ client_secrets.json dependency-reduced-pom.xml mage-bundle +/Mage.Client/game-*.json diff --git a/Mage.Client/pom.xml b/Mage.Client/pom.xml index db453f4cd3..40fcfe1b91 100644 --- a/Mage.Client/pom.xml +++ b/Mage.Client/pom.xml @@ -15,6 +15,7 @@ Mage Client + org.mage mage @@ -68,6 +69,11 @@ jetlang 0.2.9 + + com.amazonaws + aws-java-sdk-s3 + 1.11.286 + com.jgoodies forms diff --git a/Mage.Client/src/main/java/mage/client/remote/CallbackClientImpl.java b/Mage.Client/src/main/java/mage/client/remote/CallbackClientImpl.java index 386c2156a6..f16b0bee0e 100644 --- a/Mage.Client/src/main/java/mage/client/remote/CallbackClientImpl.java +++ b/Mage.Client/src/main/java/mage/client/remote/CallbackClientImpl.java @@ -31,7 +31,11 @@ import java.awt.event.KeyEvent; import java.util.List; import java.util.UUID; import javax.swing.*; + +import com.google.gson.Gson; +import com.google.gson.GsonBuilder; import mage.cards.decks.Deck; +import mage.client.remote.S3Uploader; import mage.client.MageFrame; import mage.client.SessionHandler; import mage.client.chat.ChatPanelBasic; @@ -47,6 +51,9 @@ import mage.client.util.audio.AudioManager; import mage.client.util.object.SaveObjectUtil; import mage.interfaces.callback.CallbackClient; import mage.interfaces.callback.ClientCallback; +import mage.remote.ActionData; +import mage.remote.Session; +import mage.remote.SessionImpl; import mage.utils.CompressUtil; import mage.view.*; import mage.view.ChatMessage.MessageType; @@ -102,7 +109,6 @@ public class CallbackClientImpl implements CallbackClient { break; case CHATMESSAGE: { ChatMessage message = (ChatMessage) callback.getData(); - // Drop messages from ignored users if (message.getUsername() != null && IgnoreList.IGNORED_MESSAGE_TYPES.contains(message.getMessageType())) { final String serverAddress = SessionHandler.getSession().getServerHostname().orElseGet(() -> ""); @@ -183,13 +189,22 @@ public class CallbackClientImpl implements CallbackClient { case GAME_INIT: { GamePanel panel = MageFrame.getGame(callback.getObjectId()); if (panel != null) { + appendJsonEvent("GAME_INIT", callback.getObjectId(), (GameView) callback.getData()); panel.init((GameView) callback.getData()); } break; } case GAME_OVER: { + GamePanel panel = MageFrame.getGame(callback.getObjectId()); + if (panel != null) { + appendJsonEvent("GAME_OVER", callback.getObjectId(), callback.getData()); + ActionData actionData = appendJsonEvent("GAME_OVER", callback.getObjectId(), callback.getData()); + String logFileName = "game-" + actionData.gameId + ".json"; + + S3Uploader.upload(logFileName, actionData.gameId.toString()); + panel.endMessage((String) callback.getData(), callback.getMessageId()); } break; @@ -201,6 +216,7 @@ public class CallbackClientImpl implements CallbackClient { GameClientMessage message = (GameClientMessage) callback.getData(); GamePanel panel = MageFrame.getGame(callback.getObjectId()); if (panel != null) { + appendJsonEvent("GAME_ASK", callback.getObjectId(), message); panel.ask(message.getMessage(), message.getGameView(), callback.getMessageId(), message.getOptions()); } break; @@ -208,8 +224,10 @@ public class CallbackClientImpl implements CallbackClient { case GAME_TARGET: // e.g. Pick triggered ability { GameClientMessage message = (GameClientMessage) callback.getData(); + GamePanel panel = MageFrame.getGame(callback.getObjectId()); if (panel != null) { + appendJsonEvent("GAME_TARGET", callback.getObjectId(), message); panel.pickTarget(message.getMessage(), message.getCardsView(), message.getGameView(), message.getTargets(), message.isFlag(), message.getOptions(), callback.getMessageId()); } @@ -217,8 +235,10 @@ public class CallbackClientImpl implements CallbackClient { } case GAME_SELECT: { GameClientMessage message = (GameClientMessage) callback.getData(); + GamePanel panel = MageFrame.getGame(callback.getObjectId()); if (panel != null) { + appendJsonEvent("GAME_SELECT", callback.getObjectId(), message); panel.select(message.getMessage(), message.getGameView(), callback.getMessageId(), message.getOptions()); } break; @@ -226,6 +246,7 @@ public class CallbackClientImpl implements CallbackClient { case GAME_CHOOSE_ABILITY: { GamePanel panel = MageFrame.getGame(callback.getObjectId()); if (panel != null) { + appendJsonEvent("GAME_CHOOSE_PILE", callback.getObjectId(), callback.getData()); panel.pickAbility((AbilityPickerView) callback.getData()); } break; @@ -234,15 +255,18 @@ public class CallbackClientImpl implements CallbackClient { GameClientMessage message = (GameClientMessage) callback.getData(); GamePanel panel = MageFrame.getGame(callback.getObjectId()); if (panel != null) { + appendJsonEvent("GAME_CHOOSE_PILE", callback.getObjectId(), message); panel.pickPile(message.getMessage(), message.getPile1(), message.getPile2()); } break; } case GAME_CHOOSE_CHOICE: { GameClientMessage message = (GameClientMessage) callback.getData(); + GamePanel panel = MageFrame.getGame(callback.getObjectId()); if (panel != null) { + appendJsonEvent("GAME_CHOOSE_CHOICE", callback.getObjectId(), message); panel.getChoice(message.getChoice(), callback.getObjectId()); } break; @@ -251,35 +275,45 @@ public class CallbackClientImpl implements CallbackClient { GameClientMessage message = (GameClientMessage) callback.getData(); GamePanel panel = MageFrame.getGame(callback.getObjectId()); if (panel != null) { + appendJsonEvent("GAME_PLAY_MANA", callback.getObjectId(), message); panel.playMana(message.getMessage(), message.getGameView(), message.getOptions(), callback.getMessageId()); } break; } case GAME_PLAY_XMANA: { GameClientMessage message = (GameClientMessage) callback.getData(); + GamePanel panel = MageFrame.getGame(callback.getObjectId()); if (panel != null) { + appendJsonEvent("GAME_PLAY_XMANA", callback.getObjectId(), message); panel.playXMana(message.getMessage(), message.getGameView(), callback.getMessageId()); } break; } case GAME_GET_AMOUNT: { GameClientMessage message = (GameClientMessage) callback.getData(); + GamePanel panel = MageFrame.getGame(callback.getObjectId()); if (panel != null) { + appendJsonEvent("GAME_GET_AMOUNT", callback.getObjectId(), message); + panel.getAmount(message.getMin(), message.getMax(), message.getMessage()); } break; } case GAME_UPDATE: { GamePanel panel = MageFrame.getGame(callback.getObjectId()); + if (panel != null) { + appendJsonEvent("GAME_UPDATE", callback.getObjectId(), callback.getData()); + panel.updateGame((GameView) callback.getData()); } break; } case END_GAME_INFO: MageFrame.getInstance().showGameEndDialog((GameEndView) callback.getData()); + break; case SHOW_USERMESSAGE: List messageData = (List) callback.getData(); @@ -293,6 +327,7 @@ public class CallbackClientImpl implements CallbackClient { GameClientMessage message = (GameClientMessage) callback.getData(); GamePanel panel = MageFrame.getGame(callback.getObjectId()); if (panel != null) { + appendJsonEvent("GAME_INFORM", callback.getObjectId(), message); panel.inform(message.getMessage(), message.getGameView(), callback.getMessageId()); } } @@ -375,7 +410,13 @@ public class CallbackClientImpl implements CallbackClient { } }); } - + private ActionData appendJsonEvent(String name, UUID gameId, Object value) { + Session session = SessionHandler.getSession(); + ActionData actionData = new ActionData(name, gameId); + actionData.value = value; + session.appendJsonLog(actionData); + return actionData; + } private void createChatStartMessage(ChatPanelBasic chatPanel) { chatPanel.setStartMessageDone(true); ChatPanelBasic usedPanel = chatPanel; diff --git a/Mage.Client/src/main/java/mage/client/remote/S3Uploader.java b/Mage.Client/src/main/java/mage/client/remote/S3Uploader.java new file mode 100644 index 0000000000..21e9504f41 --- /dev/null +++ b/Mage.Client/src/main/java/mage/client/remote/S3Uploader.java @@ -0,0 +1,47 @@ +package mage.client.remote; + +import java.io.File; + +import com.amazonaws.AmazonClientException; +import com.amazonaws.auth.BasicAWSCredentials; +import com.amazonaws.services.s3.transfer.TransferManager; +import com.amazonaws.services.s3.transfer.Upload; +import org.apache.log4j.Logger; + +import javax.xml.crypto.Data; + + +public class S3Uploader { + private static final Logger logger = Logger.getLogger(S3Uploader.class); + + public static Boolean upload(String filePath, String keyName) throws Exception { + String existingBucketName = System.getenv("S3_BUCKET") != null ? System.getenv("S3_BUCKET") + : "xmage-game-logs-dev"; + + String accessKeyId = System.getenv("AWS_ACCESS_ID"); + String secretKeyId = System.getenv("AWS_SECRET_KEY"); + + if(accessKeyId == "" || secretKeyId == "" || existingBucketName == "") { + logger.info("Aborting json log sync."); + return false; + } + + String path = new File("./" + filePath).getCanonicalPath(); + logger.info("Syncing " + path + " to bucket: " + existingBucketName + " with AWS Access Id: " + accessKeyId); + + BasicAWSCredentials awsCreds = new BasicAWSCredentials(accessKeyId, secretKeyId); + TransferManager tm = new TransferManager(awsCreds); + Upload upload = tm.upload(existingBucketName, "/game/" + keyName + ".json", new File(path)); + + try { + upload.waitForUploadResult(); + logger.info("Sync Complete For " + path + " to bucket: " + existingBucketName + " with AWS Access Id: " + accessKeyId); + new File(path); + return true; + } catch (AmazonClientException amazonClientException) { + System.out.println("Unable to upload file, upload was aborted."); + amazonClientException.printStackTrace(); + return false; + } + } +} diff --git a/Mage.Common/pom.xml b/Mage.Common/pom.xml index 27914c196d..17075941d0 100644 --- a/Mage.Common/pom.xml +++ b/Mage.Common/pom.xml @@ -25,6 +25,7 @@ jspf-core 0.9.1 + org.jboss.remoting jboss-remoting @@ -50,7 +51,11 @@ trove 1.0.2 - + + com.google.code.gson + gson + 2.8.2 + diff --git a/Mage.Common/src/main/java/mage/remote/ActionData.java b/Mage.Common/src/main/java/mage/remote/ActionData.java new file mode 100644 index 0000000000..d173535b14 --- /dev/null +++ b/Mage.Common/src/main/java/mage/remote/ActionData.java @@ -0,0 +1,139 @@ +/* + * Copyright 2018 nanarpuss_at_googlemail.com. All rights reserved. + * + * Redistribution and use in source and binary forms, with or without modification, are + * permitted provided that the following conditions are met: + * + * 1. Redistributions of source code must retain the above copyright notice, this list of + * conditions and the following disclaimer. + * + * 2. Redistributions in binary form must reproduce the above copyright notice, this list + * of conditions and the following disclaimer in the documentation and/or other materials + * provided with the distribution. + * + * THIS SOFTWARE IS PROVIDED BY BetaSteward_at_googlemail.com ``AS IS'' AND ANY EXPRESS OR IMPLIED + * WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND + * FITNESS FOR A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL BetaSteward_at_googlemail.com OR + * CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR + * CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR + * SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON + * ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING + * NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF + * ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. + * + * The views and conclusions contained in the software and documentation are those of the + * authors and should not be interpreted as representing official policies, either expressed + * or implied, of BetaSteward_at_googlemail.com. + */ + +package mage.remote; + +import com.google.gson.Gson; +import com.google.gson.GsonBuilder; +import mage.remote.interfaces.*; + +import java.util.UUID; + +import com.google.gson.annotations.Expose; +import com.google.gson.ExclusionStrategy; +import com.google.gson.FieldAttributes; + +public class ActionData { + @Expose + public UUID gameId; + @Expose + public String sessionId; + @Expose + public String type; + @Expose + public Object value; + @Expose + public String message; + + public String toJson() { + GsonBuilder gsonBuilder = new GsonBuilder(); + Gson gson = gsonBuilder.setExclusionStrategies(new CustomExclusionStrategy()).create(); + + return gson.toJson(this); + } + + public ActionData(String type, UUID gameId, String sessionId) { + this.type = type; + this.sessionId = sessionId; + this.gameId = gameId; + } + + public ActionData(String type, UUID gameId) { + this.type = type; + this.gameId = gameId; + } + + public class CustomExclusionStrategy implements ExclusionStrategy { + // FIXME: Very crude way of whitelisting, as it applies to all levels of the JSON tree. + private final java.util.Set KEEP = new java.util.HashSet( + java.util.Arrays.asList( + new String[]{ + "id", + "choice", + "damage", + "abilityType", + "ability", + "abilities", + "method", + "data", + "options", + "life", + "players", + "zone", + "step", + "phase", + "attackers", + "blockers", + "tapped", + "damage", + "combat", + "paid", + "hand", + "stack", + "convertedManaCost", + "gameId", + "canPlayInHand", + "gameView", + "sessionId", + "power", + "choices", + "targets", + "loyalty", + "toughness", + "power", + "type", + "priorityTime", + "manaCost", + "value", + "message", + "cardsView", + "name", + "count", + "counters", + "battlefield", + "parentId" + })); + + public CustomExclusionStrategy() {} + + // This method is called for all fields. if the method returns true the + // field is excluded from serialization + @Override + public boolean shouldSkipField(FieldAttributes f) { + String name = f.getName(); + return !KEEP.contains(name); + } + + // This method is called for all classes. If the method returns true the + // class is excluded. + @Override + public boolean shouldSkipClass(Class clazz) { + return false; + } + } +} diff --git a/Mage.Common/src/main/java/mage/remote/Session.java b/Mage.Common/src/main/java/mage/remote/Session.java index 8cb062c63e..67022d59e8 100644 --- a/Mage.Common/src/main/java/mage/remote/Session.java +++ b/Mage.Common/src/main/java/mage/remote/Session.java @@ -38,6 +38,7 @@ import mage.remote.interfaces.PlayerActions; import mage.remote.interfaces.Replays; import mage.remote.interfaces.ServerState; import mage.remote.interfaces.Testable; +import mage.remote.ActionData; /** * Extracted interface for SessionImpl class. @@ -45,5 +46,5 @@ import mage.remote.interfaces.Testable; * @author noxx */ public interface Session extends ClientData, Connect, GamePlay, GameTypes, ServerState, ChatSession, Feedback, PlayerActions, Replays, Testable { - + public void appendJsonLog(ActionData actionData); } diff --git a/Mage.Common/src/main/java/mage/remote/SessionImpl.java b/Mage.Common/src/main/java/mage/remote/SessionImpl.java index 1f7bca2c0c..907de603b0 100644 --- a/Mage.Common/src/main/java/mage/remote/SessionImpl.java +++ b/Mage.Common/src/main/java/mage/remote/SessionImpl.java @@ -32,6 +32,11 @@ import java.lang.reflect.UndeclaredThrowableException; import java.net.*; import java.util.*; import java.util.concurrent.TimeUnit; +import java.io.BufferedWriter; +import java.io.PrintWriter; +import java.io.FileWriter; +import com.google.gson.Gson; +import com.google.gson.GsonBuilder; import mage.MageException; import mage.cards.decks.DeckCardLists; import mage.cards.repository.CardInfo; @@ -50,6 +55,7 @@ import mage.interfaces.callback.ClientCallback; import mage.players.PlayerType; import mage.players.net.UserData; import mage.utils.CompressUtil; +import mage.remote.ActionData; import mage.view.*; import org.apache.log4j.Logger; import org.jboss.remoting.*; @@ -798,6 +804,9 @@ public class SessionImpl implements Session { public boolean sendPlayerUUID(UUID gameId, UUID data) { try { if (isConnected()) { + ActionData actionData = new ActionData("SEND_PLAYER_UUID", gameId, getSessionId()); + actionData.value = data; + appendJsonLog(actionData); server.sendPlayerUUID(gameId, sessionId, data); return true; } @@ -813,6 +822,10 @@ public class SessionImpl implements Session { public boolean sendPlayerBoolean(UUID gameId, boolean data) { try { if (isConnected()) { + ActionData actionData = new ActionData("SEND_PLAYER_BOOLEAN", gameId, getSessionId()); + actionData.value = data; + appendJsonLog(actionData); + server.sendPlayerBoolean(gameId, sessionId, data); return true; } @@ -828,6 +841,10 @@ public class SessionImpl implements Session { public boolean sendPlayerInteger(UUID gameId, int data) { try { if (isConnected()) { + ActionData actionData = new ActionData("SEND_PLAYER_INTEGER", gameId, getSessionId()); + actionData.value = data; + appendJsonLog(actionData); + server.sendPlayerInteger(gameId, sessionId, data); return true; } @@ -843,6 +860,10 @@ public class SessionImpl implements Session { public boolean sendPlayerString(UUID gameId, String data) { try { if (isConnected()) { + ActionData actionData = new ActionData("SEND_PLAYER_STRING", gameId, getSessionId()); + actionData.value = data; + appendJsonLog(actionData); + server.sendPlayerString(gameId, sessionId, data); return true; } @@ -858,6 +879,9 @@ public class SessionImpl implements Session { public boolean sendPlayerManaType(UUID gameId, UUID playerId, ManaType data) { try { if (isConnected()) { + ActionData actionData = new ActionData("SEND_PLAYER_MANA_TYPE", gameId, getSessionId()); + actionData.value = data; + appendJsonLog(actionData); server.sendPlayerManaType(gameId, playerId, sessionId, data); return true; } @@ -869,6 +893,19 @@ public class SessionImpl implements Session { return false; } + @Override + public void appendJsonLog(ActionData actionData) { + actionData.sessionId = getSessionId(); + + String logFileName = "game-" + actionData.gameId + ".json"; + System.out.println("Logging to " + logFileName); + try(PrintWriter out = new PrintWriter(new BufferedWriter(new FileWriter(logFileName, true)))) { + out.println(actionData.toJson()); + } catch (IOException e) { + System.err.println(e); + } + } + @Override public DraftPickView sendCardPick(UUID draftId, UUID cardId, Set hiddenCards) { try { @@ -1274,6 +1311,11 @@ public class SessionImpl implements Session { public boolean sendPlayerAction(PlayerAction passPriorityAction, UUID gameId, Object data) { try { if (isConnected()) { + ActionData actionData = new ActionData("SEND_PLAYER_ACTION", gameId, getSessionId()); + + actionData.value = data; + appendJsonLog(actionData); + server.sendPlayerAction(passPriorityAction, gameId, sessionId, data); return true; } diff --git a/Mage.Common/src/main/java/mage/view/CardView.java b/Mage.Common/src/main/java/mage/view/CardView.java index d9154f7418..b2ed194eb8 100644 --- a/Mage.Common/src/main/java/mage/view/CardView.java +++ b/Mage.Common/src/main/java/mage/view/CardView.java @@ -53,6 +53,8 @@ import mage.target.Target; import mage.target.Targets; import mage.util.SubTypeList; +import com.google.gson.annotations.Expose; + /** * @author BetaSteward_at_googlemail.com */ @@ -61,11 +63,17 @@ public class CardView extends SimpleCardView { private static final long serialVersionUID = 1L; protected UUID parentId; + @Expose protected String name; + @Expose protected String displayName; + @Expose protected List rules; + @Expose protected String power; + @Expose protected String toughness; + @Expose protected String loyalty = ""; protected String startingLoyalty; protected EnumSet cardTypes; @@ -110,7 +118,6 @@ public class CardView extends SimpleCardView { protected ArtRect artRect = ArtRect.NORMAL; protected List targets; - protected UUID pairedCard; protected List bandedCards; protected boolean paid; diff --git a/Mage.Common/src/main/java/mage/view/GameClientMessage.java b/Mage.Common/src/main/java/mage/view/GameClientMessage.java index 33af5e25e9..1774c766f9 100644 --- a/Mage.Common/src/main/java/mage/view/GameClientMessage.java +++ b/Mage.Common/src/main/java/mage/view/GameClientMessage.java @@ -32,6 +32,11 @@ import java.io.Serializable; import java.util.Map; import java.util.Set; import java.util.UUID; + +import com.google.gson.Gson; +import com.google.gson.GsonBuilder; +import com.google.gson.annotations.Expose; + import mage.choices.Choice; /** @@ -39,18 +44,29 @@ import mage.choices.Choice; * @author BetaSteward_at_googlemail.com */ public class GameClientMessage implements Serializable { + @Expose private static final long serialVersionUID = 1L; - + @Expose private GameView gameView; + @Expose private CardsView cardsView; + @Expose private CardsView cardsView2; + @Expose private String message; + @Expose private boolean flag; + @Expose private String[] strings; + @Expose private Set targets; + @Expose private int min; + @Expose private int max; + @Expose private Map options; + @Expose private Choice choice; public GameClientMessage(GameView gameView) { @@ -155,4 +171,11 @@ public class GameClientMessage implements Serializable { return choice; } + public String toJson() { + Gson gson = new GsonBuilder() + .excludeFieldsWithoutExposeAnnotation() + .create(); + return gson.toJson(this); + } + } diff --git a/Mage.Common/src/main/java/mage/view/GameView.java b/Mage.Common/src/main/java/mage/view/GameView.java index 2e16415ad6..b354503c56 100644 --- a/Mage.Common/src/main/java/mage/view/GameView.java +++ b/Mage.Common/src/main/java/mage/view/GameView.java @@ -28,11 +28,17 @@ package mage.view; import java.io.Serializable; +import java.io.BufferedWriter; +import java.io.PrintWriter; +import java.io.FileWriter; +import java.io.IOException; + import java.util.ArrayList; import java.util.List; import java.util.Map; import java.util.Set; import java.util.UUID; + import mage.MageObject; import mage.abilities.costs.Cost; import mage.cards.Card; @@ -54,6 +60,8 @@ import mage.game.stack.StackObject; import mage.players.Player; import mage.watchers.common.CastSpellLastTurnWatcher; import org.apache.log4j.Logger; +import com.google.gson.Gson; +import com.google.gson.GsonBuilder; /** * @@ -64,7 +72,6 @@ public class GameView implements Serializable { private static final long serialVersionUID = 1L; private static final Logger LOGGER = Logger.getLogger(GameView.class); - private final int priorityTime; private final List players = new ArrayList<>(); private CardsView hand; @@ -351,4 +358,8 @@ public class GameView implements Serializable { return rollbackTurnsAllowed; } + public String toJson() { + Gson gson = new GsonBuilder().create(); + return gson.toJson(this); + } } diff --git a/Mage.Common/src/main/java/mage/view/SimpleCardView.java b/Mage.Common/src/main/java/mage/view/SimpleCardView.java index 709e45ad83..a137f0ca0e 100644 --- a/Mage.Common/src/main/java/mage/view/SimpleCardView.java +++ b/Mage.Common/src/main/java/mage/view/SimpleCardView.java @@ -28,6 +28,8 @@ package mage.view; +import com.google.gson.annotations.Expose; + import java.io.Serializable; import java.util.UUID; @@ -36,6 +38,7 @@ import java.util.UUID; * @author BetaSteward_at_googlemail.com */ public class SimpleCardView implements Serializable { + @Expose protected UUID id; protected String expansionSetCode; protected String tokenSetCode; diff --git a/Mage.Server/pom.xml b/Mage.Server/pom.xml index f756f0f7c1..20899230cc 100644 --- a/Mage.Server/pom.xml +++ b/Mage.Server/pom.xml @@ -76,6 +76,12 @@ ${project.version} runtime + + org.apache.commons + commons-compress + 1.16.1 + + ${project.groupId} mage-game-commanderfreeforall