
XMage ist eine Client / Server-Anwendung zum Spielen von Magic: The Gathering (MTG). XMage begann sich Anfang 2010 weiterzuentwickeln. Während dieser Zeit wurden 182 Veröffentlichungen veröffentlicht, eine ganze Armee von Mitwirkenden versammelt, und das Projekt entwickelt sich immer noch aktiv weiter. Eine ausgezeichnete Gelegenheit, auch an seiner Entwicklung teilzunehmen! Daher wird das Einhorn von PVS-Studio heute die XMage-Codebasis überprüfen, und wer weiß, es könnte mit jemandem im Kampf zusammenstoßen.
Kurz über das Projekt
XMage entwickelt sich seit 10 Jahren aktiv weiter. Sein Ziel ist es, eine kostenlose Open-Source-Online-Version des ursprünglichen Magic: the Gathering- Kartenspiels zu erstellen .
Anwendungsfunktionen:
- Zugang zu ~ 19.000 einzigartigen Karten, die in der 20-jährigen Geschichte von MTG ausgestellt wurden;
- automatische Steuerung und Anwendung aller vorhandenen Spielregeln;
- ;
- (AI);
- (Standard, Modern, Vintage, Commander );
- , .
Stolperte über die Arbeit von Studenten der Technischen Universität Delft 2018 (Master in Softwarearchitektur ). Es bestand darin, dass die Jungs aktiv an Open-Source-Projekten teilnahmen, die recht komplex sein und sich aktiv entwickeln mussten. Über einen Zeitraum von acht Wochen studierten die Studenten den Kurs und Open-Source-Projekte, um die Architektur der ausgewählten Software zu verstehen und zu beschreiben.
Also. In dieser Arbeit analysierten die Jungs das XMage-Projekt und einer der Aspekte ihrer Arbeit bestand darin, mithilfe von SonarQube verschiedene Metriken zu erhalten (Anzahl der Codezeilen, zyklomatische Komplexität, Codeduplizierung, Codegerüche, Fehler, Schwachstellen usw.).
Meine Aufmerksamkeit wurde durch die Tatsache erregt, dass das SonarQube-Scannen zum Zeitpunkt des Jahres 2018 700 Fehler (Fehler, Schwachstellen) pro 1.000.000 Codezeilen aufwies.
Nachdem ich mich mit der Geschichte der beteiligten Personen befasst hatte, stellte ich fest, dass sie aus dem erhaltenen Bericht mit Warnungen eine Pull-Anfrage stellten, um etwa 30 Fehler aus der Kategorie "Blocker" oder "Kritisch" zu beheben. Was ist mit dem Rest der Warnungen ist unbekannt, aber ich hoffe, dass sie nicht übersehen wurden.
Seitdem sind 2 Jahre vergangen und die Codebasis ist um etwa 250.000 Codezeilen gewachsen - ein guter Grund, um zu sehen, wie die Dinge laufen.
Über die Analyse
Zur Analyse habe ich die XMage-Version 1.4.44V0 verwendet .
Ich hatte großes Glück mit dem Projekt. Das Erstellen von XMage mit Maven erwies sich als sehr einfach (wie in der Dokumentation beschrieben):
mvn clean install -DskipTests
Von mir wurde nichts mehr verlangt. Cool?
Auch bei der Integration des PVS-Studio-Plugins in Maven gab es keine Probleme: Alles ist wie in der Dokumentation .
Nach der Analyse gingen 911 Warnungen ein, von denen 674 Warnungen mit einem Konfidenzniveau von 1 und 2 betrafen. Für die Zwecke dieses Artikels habe ich Warnungen der Stufe 3 nicht berücksichtigt, da normalerweise ein hoher Prozentsatz falsch positiver Ergebnisse vorliegt. Ich möchte Ihre Aufmerksamkeit auf die Tatsache lenken, dass Sie bei der Verwendung eines statischen Analysators in einem echten Kampf solche Warnungen nicht ignorieren können, da sie auch auf erhebliche Fehler im Code hinweisen können.
Außerdem habe ich die Warnungen einiger Regeln nicht berücksichtigt, weil sie von denjenigen, die mit dem Projekt vertraut sind, besser berücksichtigt werden als ich:
- V6022, /. 336 .
- V6014, , . 73 .
- V6021, , . 36 .
- V6048, , . 17 .
Darüber hinaus ergaben mehrere Diagnoseregeln etwa 20 offensichtliche Fehlalarme des gleichen Typs. Aufgenommen in todo!
Wenn wir also alles subtrahieren, wurden mir ungefähr 190 positive Ergebnisse zur Prüfung vorgelegt.
Bei der Überprüfung der Auslöser wurden viele kleinere Fehler des gleichen Typs identifiziert, die entweder mit dem Debuggen oder einer bedeutungslosen Überprüfung oder Operation zusammenhängen. Viele positive Ergebnisse waren auch mit einem sehr seltsamen Code verbunden, der um Refactoring bat.
Als Ergebnis habe ich für diesen Artikel 11 Diagnoseregeln identifiziert und einen der interessantesten Auslöser analysiert.
Werfen wir einen Blick darauf, was passiert ist.
Warnung N1
V6003 Die Verwendung des Musters 'if (card! = Null) {...} else if (card! = Null) {...}' wurde erkannt. Es besteht die Wahrscheinlichkeit eines logischen Fehlers. TorrentialGearhulk.java (90), TorrentialGearhulk.java (102)
@Override
public boolean apply(Game game, Ability source) {
....
Card card = game.getCard(....);
if (card != null) {
....
} else if (card != null) {
....
}
....
}
Hier ist alles einfach: Der Hauptteil der zweiten bedingten Anweisung if (card! = Null) in der if-else-if-Konstruktion wird niemals ausgeführt, da das Programm diesen Punkt entweder nicht erreicht oder card! = Null immer falsch ist .
Warnung N2
V6004 Die Anweisung 'then' entspricht der Anweisung 'else'. AsThoughEffectImpl.java (35), AsThoughEffectImpl.java (37)
@Override
public boolean applies(....) {
// affectedControllerId = player to check
if (getAsThoughEffectType().equals(AsThoughEffectType.LOOK_AT_FACE_DOWN)) {
return applies(objectId, source, playerId, game);
} else {
return applies(objectId, source, playerId, game);
}
}
Ein häufiger Fehler, der in meiner Praxis bei der Überprüfung von Open-Source-Projekten häufig auftrat. Kopieren Einfügen? Oder fehlt mir etwas? Ich gehe davon aus, dass Sie im else- Zweig immer noch false zurückgeben müssen .
PS Wenn überhaupt, gilt kein rekursiver Aufruf (....) , da es sich um unterschiedliche Methoden handelt.
Ähnliche Auslösung:
- V6004 Die Anweisung 'then' entspricht der Anweisung 'else'. GuiDisplayUtil.java (194), GuiDisplayUtil.java (198)
Warnung N3
V6007 Ausdruck 'filter.getMessage (). ToLowerCase (Locale.ENGLISH) .startsWith ("Each")' ist immer falsch. SetPowerToughnessAllEffect.java (107)
@Override
public String getText(Mode mode) {
StringBuilder sb = new StringBuilder();
....
if (filter.getMessage().toLowerCase(Locale.ENGLISH).startsWith("Each ")) {
sb.append(" has base power and toughness ");
} else {
sb.append(" have base power and toughness ");
}
....
return sb.toString();
}
Die Trigger der V6007- Diagnoseregeln sind bei jedem zu überprüfenden Projekt sehr beliebt. XMage ist keine Ausnahme (79 Stück). Die Aktivierung der Regel ist im Prinzip alles auf den Fall, aber viele Fälle fallen auf Debug, dann auf Rückversicherung, dann auf etwas anderes. Im Allgemeinen sind solche positiven Ergebnisse für den Autor des Codes besser zu sehen als für mich.
Diese Auslösung ist jedoch definitiv ein Fehler. Abhängig vom Zeilenanfang filter.getMessage () bis jdnDer Text "hat ..." oder "haben ..." wird hinzugefügt. Der Fehler ist jedoch, dass die Entwickler überprüfen, ob die Zeichenfolge mit einem Großbuchstaben beginnt, nachdem sie dieselbe Zeichenfolge zuvor in Kleinbuchstaben konvertiert haben. Hoppla. Infolgedessen lautet die hinzugefügte Zeile immer "haben ...". Das Ergebnis des Defekts ist nicht kritisch, aber auch unangenehm: Irgendwo erscheint ein Analphabet.
Die positiven Aspekte, die ich am interessantesten fand:
- V6007 Der Ausdruck 't.startsWith ("-")' ist immer falsch. BoostSourceEffect.java (103)
- V6007 Der Ausdruck 'setNames.isEmpty ()' ist immer falsch. DownloadPicturesService.java (300)
- V6007 Der Ausdruck 'existentBucketName == null' ist immer falsch. S3Uploader.java (23)
- V6007 Ausdruck '! LastRule.endsWith (".")' Ist immer wahr. Effects.java (76)
- V6007 Der Ausdruck 'subtypesToIgnore :: enthält' ist immer falsch. VerifyCardDataTest.java (893)
- V6007 Der Ausdruck 'notStartedTables == 1' ist immer falsch. MageServerImpl.java (1330)
Warnung N4
V6008 Null-Dereferenzierung von 'savedSpecialRares'. DragonsMaze.java (230)
public final class DragonsMaze extends ExpansionSet {
....
private List<CardInfo> savedSpecialRares = new ArrayList<>();
....
@Override
public List<CardInfo> getSpecialRare() {
if (savedSpecialRares == null) { // <=
CardCriteria criteria = new CardCriteria();
criteria.setCodes("GTC").name("Breeding Pool");
savedSpecialRares.addAll(....); // <=
criteria = new CardCriteria();
criteria.setCodes("GTC").name("Godless Shrine");
savedSpecialRares.addAll(....);
....
}
return new ArrayList<>(savedSpecialRares);
}
}
Der Parser beschwert sich über die Dereferenzierung der Nullreferenz savedSpecialRares, wenn die Ausführung die erste Füllung der Sammlung erreicht.
Das erste, was mir in den Sinn kommt, ist einfach, savedSpecialRares == null mit savedSpecialRares! = Null zu verwechseln. In einem solchen Fall kann NPE im Konstruktor von ArrayList auftreten, wenn die Auflistung von der Methode zurückgegeben wird, da savedSpecialRares == null weiterhin möglich ist. Es ist keine gute Option, den Code mit der ersten Lösung zu reparieren, die mir in den Sinn kommt. Nachdem ich den Code ein wenig verstanden hatte, stellte ich fest, dass s avedSpecialRares beim Deklarieren sofort durch eine leere Sammlung definiert und nirgendwo anders neu zugewiesen wird. Das sagt uns dassavedSpecialRares wird niemals null sein, und die Dereferenzierung einer Nullreferenz , vor der der Analysator warnt, wird niemals stattfinden, da sie niemals die Sammlung erreichen wird. Infolgedessen gibt die Methode immer eine leere Sammlung zurück.
PS Um das Problem zu beheben, müssen Sie savedSpecialRares == null durch savedSpecialRares.isEmpty () ersetzen .
PPS Leider können Sie beim Spielen von XMage keine speziellen seltenen Karten für die Dragon's Maze- Sammlung erhalten .
Ein weiterer Fall der Dereferenzierung einer Nullreferenz:
- V6008 Null-Dereferenzierung von 'Übereinstimmung'. TableController.java (973)
Warnung N5
V6012 Der Operator '?:' Gibt unabhängig von seinem bedingten Ausdruck immer ein und denselben Wert 'table.getCreateTime ()' zurück. TableManager.java (418), TableManager.java (418)
private void checkTableHealthState() {
....
logger.debug(.... + formatter.format(table.getStartTime() == null
? table.getCreateTime()
: table.getCreateTime()) + ....);
....
}
Hier der ternäre Operator ?: Gibt unabhängig von der Bedingung table.getStartTime () == null denselben Wert zurück . Ich glaube, dass die Code-Vervollständigung einen grausamen Witz für den Entwickler gespielt hat. Korrekturoption:
private void checkTableHealthState() {
....
logger.debug(.... + formatter.format(table.getStartTime() == null
? table.getCreateTime()
: table.getStartTime()) + ....);
....
}
Warnung N6
V6026 Dieser Wert ist bereits der Variablen 'this.loseOther' zugeordnet. BecomesCreatureTypeTargetEffect.java (54)
public
BecomesCreatureTypeTargetEffect(final BecomesCreatureTypeTargetEffect effect) {
super(effect);
this.subtypes.addAll(effect.subtypes);
this.loseOther = effect.loseOther;
this.loseOther = effect.loseOther;
}
Doppelte Zuweisungszeichenfolge. Es sieht so aus, als wäre der Entwickler mit Hotkeys ein wenig mitgerissen worden und hätte es nicht bemerkt. Da der Effekt jedoch eine große Anzahl von Feldern aufweist, sollte das Fragment fokussiert werden.
Warnung N7
V6036 Der Wert aus dem nicht initialisierten optionalen 'selectUser' wird verwendet. Session.java (227)
public String connectUserHandling(String userName, String password)
{
....
if (!selectUser.isPresent()) { // user already exists
selectUser = UserManager.instance.getUserByName(userName);
if (selectUser.isPresent()) {
User user = selectUser.get();
....
}
}
User user = selectUser.get(); // <=
....
}
Aus der Warnung des Analysators können wir schließen, dass selectUser.get () möglicherweise eine NoSuchElementException auslöst.
Schauen wir uns genauer an, was hier los ist.
Wenn Sie dem Kommentar glauben, dass der Benutzer bereits vorhanden ist, wird keine Ausnahme ausgelöst:
....
if (!selectUser.isPresent()) { // user already exists
....
}
User user = selectUser.get()
....
In diesem Fall wird die Programmausführung nicht in den Hauptteil der bedingten Anweisung übernommen. Und alles wird gut. Aber dann stellt sich die Frage: Warum brauchen wir einen bedingten Operator mit einer komplexen Logik, wenn er niemals ausgeführt wird?
Aber was ist, wenn der Kommentar nichts ist?
....
if (!selectUser.isPresent()) { // user already exists
selectUser = UserManager.instance.getUserByName(userName);
if (selectUser.isPresent()) {
....
}
}
User user = selectUser.get(); // <=
....
Dann tritt die Ausführung in den Hauptteil der bedingten Anweisung ein und ruft den Benutzer über getUserByName () erneut ab. Der Benutzer wird erneut auf Gültigkeit überprüft, was darauf hindeutet, dass der selectUser möglicherweise nicht initialisiert ist. Für diesen Fall gibt es keinen anderen Zweig , der zu einer NoSuchElementException in der betreffenden Codezeile führt.
Warnung N8
V6042 Der Ausdruck wird auf Kompatibilität mit Typ 'A' geprüft, aber in Typ 'B' umgewandelt. CheckBoxList.java (586)
/**
* sets the model - must be an instance of CheckBoxListModel
*
* @param model the model to use
* @throws IllegalArgumentException if the model is not an instance of
* CheckBoxListModel
* @see CheckBoxListModel
*/
@Override
public void setModel(ListModel model) {
if (!(model instanceof CheckBoxListModel)) {
if (model instanceof javax.swing.DefaultListModel) {
super.setModel((CheckBoxListModel)model); // <=
}
else {
throw new IllegalArgumentException(
"Model must be an instance of CheckBoxListModel!");
}
}
else {
super.setModel(model);
}
}
Der Autor des Codes ist hier über etwas verwirrt: Zuerst stellt er sicher, dass das Modell kein CheckBoxListModel ist , und wandelt das Objekt dann explizit in diesen Typ um. Aus diesem Grund löst die setModel- Methode sofort eine ClassCastException aus, wenn sie dort ankommt .
Die Datei CheckBoxList.java wurde vor 2 Jahren hinzugefügt und dieser Fehler lebt seitdem im Code weiter. Anscheinend gibt es keine Tests für falsche Parameter, es gibt keine wirkliche Verwendung dieser Methode bei Objekten unangemessener Typen, so dass sie lebt.
Wenn sich plötzlich jemand an diese Methode anschließt und das Javadoc liest, erwartet er eine IllegalArgumentException , keine ClassCastException... Ich glaube nicht, dass jemand absichtlich auf diese Ausnahme stoßen wird, aber wer weiß.
In Anbetracht der Dokumentation sollte der Code höchstwahrscheinlich folgendermaßen aussehen:
public void setModel(ListModel model) {
if (!(model instanceof CheckBoxListModel)) {
throw new IllegalArgumentException(
"Model must be an instance of CheckBoxListModel!");
}
else {
super.setModel(model);
}
}
Warnung N9
V6060 Die ' Spieler' -Referenz wurde verwendet, bevor sie gegen Null verifiziert wurde. VigeanIntuition.java (79), VigeanIntuition.java (78)
@Override
public boolean apply(Game game, Ability source) {
MageObject sourceObject = game.getObject(source.getSourceId());
Player player = game.getPlayer(source.getControllerId());
Library library = player.getLibrary(); // <=
if (player != null && sourceObject != null && library != null) { // <=
....
}
}
V6060 warnt den Entwickler, dass auf ein Objekt zugegriffen wird, bevor es auf null überprüft wird . Auslöser dieser Regel finden sich häufig in Artikeln zum Überprüfen von Open-Source-Projekten: Der Grund dafür ist normalerweise das erfolglose Refactoring oder das Ändern von Verträgen für Methoden. Wenn Sie auf die Deklaration der Methode getPlayer () achten , wird alles sofort zusammenpassen:
// Result must be checked for null.
// Possible errors search pattern: (\S*) = game.getPlayer.+\n(?!.+\1 != null)
Player getPlayer(UUID playerId);
Warnung N10
V6072 Es wurden zwei ähnliche Codefragmente gefunden. Möglicherweise ist dies ein Tippfehler und die Variable 'playerB' sollte anstelle von 'playerA' verwendet werden. SubTypeChangingEffectsTest.java (162), SubTypeChangingEffectsTest.java (158), SubTypeChangingEffectsTest.java (156), SubTypeChangingEffectsTest.java (160)
@Test
public void testArcaneAdaptationGiveType() {
addCard(Zone.HAND, playerA, "Arcane Adaptation", 1); // Enchantment {2}{U}
addCard(Zone.BATTLEFIELD, playerA, "Island", 3);
addCard(Zone.HAND, playerA, "Silvercoat Lion");
addCard(Zone.BATTLEFIELD, playerA, "Silvercoat Lion");
addCard(Zone.GRAVEYARD, playerA, "Silvercoat Lion"); // <=
addCard(Zone.HAND, playerB, "Silvercoat Lion");
addCard(Zone.BATTLEFIELD, playerB, "Silvercoat Lion");
addCard(Zone.GRAVEYARD, playerA, "Silvercoat Lion"); // <=
....
for (Card card : playerB.getGraveyard().getCards(currentGame)) {
if (card.isCreature()) {
Assert.assertEquals(card.getName() + " should not have ORC type",
false, card.getSubtype(currentGame).contains(SubType.ORC));
Assert.assertEquals(card.getName() + " should have CAT type",
true, card.getSubtype(currentGame).contains(SubType.CAT));
}
}
}
Nachdem Sie gesehen haben, dass dieser Fehler in den Tests enthalten ist, können Sie den gefundenen Fehler sofort abwerten und denken: "Nun, das sind Tests." Wenn ja, dann stimme ich Ihnen nicht zu. Schließlich spielen Tests eine ziemlich wichtige Rolle in der Entwicklung (obwohl sie nicht so auffällig sind wie die Programmierung), und wenn in einer Version ein Fehler auftritt, zeigen sie sofort mit den Fingern auf die Tests / Tester. Fehlerhafte Tests sind also unhaltbar. Warum werden dann solche Tests benötigt? Warum Ressourcen für sie verschwenden?
Die Methode testArcaneAdaptationGiveType () testet die Karte "Arcane Adaptation". Jeder Spieler erhält Karten für einen bestimmten Spielbereich. Und dank Copy-Paste erhielt Spieler A 2 identische "Silvercoat Lion" -Karten im Spielbereich "Cemetery" und Spieler B.also nichts bekommen. Dann etwas Magie und sich selbst testen.
Wenn das Testen in der aktuellen Rallye auf den "Friedhof" von Spieler B kommt , wird die Testausführung nie in die Schleife aufgenommen, da sich nichts auf dem "Friedhof" befand. Dies habe ich mit dem guten alten System.out.println () herausgefunden, als ich den Test gestartet habe .
Korrigiertes Kopieren und Einfügen:
....
addCard(Zone.HAND, playerA, "Silvercoat Lion");
addCard(Zone.BATTLEFIELD, playerA, "Silvercoat Lion");
addCard(Zone.GRAVEYARD, playerA, "Silvercoat Lion"); // <=
addCard(Zone.HAND, playerB, "Silvercoat Lion");
addCard(Zone.BATTLEFIELD, playerB, "Silvercoat Lion");
addCard(Zone.GRAVEYARD, playerB, "Silvercoat Lion"); // <=
....
Nachdem ich den Code angepasst hatte , als ich den Test durchführte, begann die Suche nach Kreaturen auf dem Friedhof von Spieler B zu funktionieren. Ave, System.out.println () !
Der Test ist sowohl vor als auch nach der Korrektur grün, was viel Glück ist. Im Falle von Änderungen, die die Logik der Programmausführung ändern, führt ein solcher Test zu einem schlechten Service und benachrichtigt Sie über den erfolgreichen Abschluss, selbst wenn Fehler vorliegen.
Gleiche Kopie an anderer Stelle einfügen:
- V6072 Es wurden zwei ähnliche Codefragmente gefunden. Möglicherweise ist dies ein Tippfehler und die Variable 'playerB' sollte anstelle von 'playerA' verwendet werden. PaintersServantTest.java (33), PaintersServantTest.java (29), PaintersServantTest.java (27), PaintersServantTest.java (31)
- V6072 Es wurden zwei ähnliche Codefragmente gefunden. Möglicherweise ist dies ein Tippfehler und die Variable 'playerB' sollte anstelle von 'playerA' verwendet werden. SubTypeChangingEffectsTest.java (32), SubTypeChangingEffectsTest.java (28), SubTypeChangingEffectsTest.java (26), SubTypeChangingEffectsTest.java (30)
Warnung N11
V6086 Formatierung von verdächtigem Code. Das Schlüsselwort 'else' fehlt wahrscheinlich. DeckImporter.java (23)
public static DeckImporter getDeckImporter(String file) {
if (file == null) {
return null;
} if (file.toLowerCase(Locale.ENGLISH).endsWith("dec")) { // <=
return new DecDeckImporter();
} else if (file.toLowerCase(Locale.ENGLISH).endsWith("mwdeck")) {
return new MWSDeckImporter();
} else if (file.toLowerCase(Locale.ENGLISH).endsWith("txt")) {
return new TxtDeckImporter(haveSideboardSection(file));
}
....
else {
return null;
}
}
Diagnoseregel V6086 Diagnosen falsch if-else-if Formatierung , was auf die Unterlassung von anderen .
Dieses Code-Snippet demonstriert dies. In diesem Fall führt die Ungenauigkeit der Formatierung aufgrund des Ausdrucks return null zu nichts, aber es ist trotzdem cool, solche Fälle zu finden, da dies nicht sofort erforderlich ist.
Betrachten wir einen Fall, in dem das Weglassen des Anderen zu unerwartetem Verhalten führen kann:
public SomeType smtMethod(SomeType obj) {
....
if (obj == null) {
obj = getNewObject();
} if (obj.isSomeObject()) {
// some logic
} else if (obj.isOtherSomething()) {
obj = calulateNewObject(obj);
// some logic
}
....
else {
// some logic
}
return obj;
}
Im Fall von obj == null wird dem betreffenden Objekt ein Wert zugewiesen, und das fehlende else bewirkt, dass das neu zugewiesene Objekt entlang der if-else-if- Kette überprüft wird , während das Objekt sofort zurückkehren sollte Methode.
Fazit
Das Überprüfen von XMage ist ein weiterer Artikel, der die Funktionen moderner statischer Analysegeräte aufzeigt. In der modernen Entwicklung wächst der Bedarf an ihnen nur, wenn die Komplexität der Software zunimmt. Und egal wie viele Releases, Tests und Benutzerfeedbacks Sie haben: Ein Fehler findet immer eine Lücke, um in Ihre Codebasis zu gelangen. Warum also nicht eine weitere Barriere zu Ihrer Verteidigung hinzufügen?
Wie Sie verstehen, sind Analysatoren anfällig für Fehlalarme (einschließlich PVS-Studio Java). Dies kann sowohl auf einen offensichtlichen Fehler als auch auf einen zu verwirrenden Code zurückzuführen sein (leider hat der Analysator dies nicht herausgefunden). Sie müssen sie verständnisvoll behandeln und ohne zu zögern sofort abbestellen. Während falsch positive Ergebnisse auf ihre Korrektur warten, können Sie eine der Methoden anwendenWarnungen unterdrücken.
Abschließend empfehle ich Ihnen persönlich „touch“ den Analysator durch das Herunterladen von unserer Website.

Wenn Sie diesen Artikel einem englischsprachigen Publikum zugänglich machen möchten, verwenden Sie bitte den Übersetzungslink: Maxim Stefanov. Überprüfen Sie den Code von XMage und warum Sie nicht in der Lage sind, die speziellen seltenen Karten der Dragon's Maze-Sammlung zu erhalten .