commit ff1629b47ba0f831cb52f34b146633ec961a474f
Author: Kevin Corvisier <git@kevincorvisier.fr>
Date: Sat, 12 Oct 2024 18:11:40 +0900
Version 0.1.0
Diffstat:
26 files changed, 4008 insertions(+), 0 deletions(-)
diff --git a/.gitignore b/.gitignore
@@ -0,0 +1,92 @@
+#
+# Eclipse
+#
+
+.metadata
+bin/
+tmp/
+*.tmp
+*.bak
+*.swp
+*~.nib
+local.properties
+.settings/
+.loadpath
+.recommenders
+
+# External tool builders
+.externalToolBuilders/
+
+# PyDev specific (Python IDE for Eclipse)
+*.pydevproject
+
+# CDT-specific (C/C++ Development Tooling)
+.cproject
+
+# CDT- autotools
+.autotools
+
+# Java annotation processor (APT)
+.factorypath
+
+# PDT-specific (PHP Development Tools)
+.buildpath
+
+# sbteclipse plugin
+.target
+
+# Tern plugin
+.tern-project
+
+# TeXlipse plugin
+.texlipse
+
+# STS (Spring Tool Suite)
+.springBeans
+
+# Code Recommenders
+.recommenders/
+
+# Annotation Processing
+.apt_generated/
+.apt_generated_test/
+
+# Scala IDE specific (Scala & Java development for Eclipse)
+.cache-main
+.scala_dependencies
+.worksheet
+
+# Uncomment this line if you wish to ignore the project description file.
+# Typically, this file would be tracked if it contains build/dependency configurations:
+#.project
+
+
+#
+# Maven
+#
+
+target/
+pom.xml.tag
+pom.xml.releaseBackup
+pom.xml.versionsBackup
+pom.xml.next
+release.properties
+dependency-reduced-pom.xml
+buildNumber.properties
+.mvn/timing.properties
+# https://github.com/takari/maven-wrapper#usage-without-binary-jar
+.mvn/wrapper/maven-wrapper.jar
+
+# Eclipse m2e generated files
+# Eclipse Core
+.project
+# JDT-specific (Eclipse Java Development Tools)
+.classpath
+
+
+#
+# Decks downloader
+#
+
+!src/main/packaged-resources/bin
+var
diff --git a/README.md b/README.md
@@ -0,0 +1,6 @@
+To generate `ai_banlist.txt` from Forge's extracted `res/cardsfolder/cardsfolder.zip`:
+```
+grep --no-filename -B 100 -A 100 -R "AI:RemoveDeck:All" * | grep "Name:" | cut -d ':' -f 2- | sort -u > ~/tmp/ai_banlist.txt
+```
+
+`ec_banlist.txt` is the banlist from Eternal Central: https://www.eternalcentral.com/middleschoolrules/
+\ No newline at end of file
diff --git a/pom.xml b/pom.xml
@@ -0,0 +1,96 @@
+<?xml version="1.0" encoding="UTF-8"?>
+<project xmlns="http://maven.apache.org/POM/4.0.0" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 http://maven.apache.org/xsd/maven-4.0.0.xsd">
+ <modelVersion>4.0.0</modelVersion>
+
+ <groupId>fr.kevincorvisier.mtg</groupId>
+ <artifactId>decks-downloader</artifactId>
+ <version>0.1.0</version>
+
+ <properties>
+ <project.build.sourceEncoding>UTF-8</project.build.sourceEncoding>
+ <project.reporting.outputEncoding>UTF-8</project.reporting.outputEncoding>
+ <maven.compiler.target>17</maven.compiler.target>
+ <maven.compiler.source>17</maven.compiler.source>
+
+ <htmlunit-driver.version>3.58.0</htmlunit-driver.version>
+ <logback.version>1.2.10</logback.version>
+ <lombok.version>1.18.22</lombok.version>
+ <sqlite-jdbc.version>3.45.0.0</sqlite-jdbc.version>
+ </properties>
+
+ <dependencies>
+ <!--HtmlUnit Driver -->
+ <dependency>
+ <groupId>org.seleniumhq.selenium</groupId>
+ <artifactId>htmlunit-driver</artifactId>
+ <version>${htmlunit-driver.version}</version>
+ </dependency>
+ <dependency>
+ <groupId>jakarta.validation</groupId>
+ <artifactId>jakarta.validation-api</artifactId>
+ <version>2.0.2</version>
+ </dependency>
+
+ <dependency>
+ <groupId>ch.qos.logback</groupId>
+ <artifactId>logback-core</artifactId>
+ <version>${logback.version}</version>
+ </dependency>
+ <dependency>
+ <groupId>ch.qos.logback</groupId>
+ <artifactId>logback-classic</artifactId>
+ <version>${logback.version}</version>
+ </dependency>
+
+ <dependency>
+ <groupId>org.xerial</groupId>
+ <artifactId>sqlite-jdbc</artifactId>
+ <version>${sqlite-jdbc.version}</version>
+ </dependency>
+
+ <!-- Project Lombok -->
+ <dependency>
+ <groupId>org.projectlombok</groupId>
+ <artifactId>lombok</artifactId>
+ <version>${lombok.version}</version>
+ <scope>provided</scope>
+ </dependency>
+
+ <!-- Unit tests -->
+ <dependency>
+ <groupId>org.junit.jupiter</groupId>
+ <artifactId>junit-jupiter-api</artifactId>
+ <version>5.10.2</version>
+ <scope>test</scope>
+ </dependency>
+ <dependency>
+ <groupId>org.junit.jupiter</groupId>
+ <artifactId>junit-jupiter-engine</artifactId>
+ <version>5.10.2</version>
+ <scope>test</scope>
+ </dependency>
+ </dependencies>
+
+ <build>
+ <plugins>
+ <plugin>
+ <artifactId>maven-assembly-plugin</artifactId>
+ <configuration>
+ <descriptors>
+ <descriptor>src/assembly/assembly.xml</descriptor>
+ </descriptors>
+ <appendAssemblyId>false</appendAssemblyId>
+ </configuration>
+ <executions>
+ <execution>
+ <phase>package</phase>
+ <goals>
+ <goal>single</goal>
+ </goals>
+ </execution>
+ </executions>
+ </plugin>
+ </plugins>
+ </build>
+
+</project>
+\ No newline at end of file
diff --git a/src/assembly/assembly.xml b/src/assembly/assembly.xml
@@ -0,0 +1,44 @@
+<assembly xmlns="http://maven.apache.org/ASSEMBLY/2.0.0" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" xsi:schemaLocation="http://maven.apache.org/ASSEMBLY/2.0.0 http://maven.apache.org/xsd/assembly-2.0.0.xsd">
+ <id>release</id>
+
+ <formats>
+ <format>tar.gz</format>
+ </formats>
+
+ <fileSets>
+ <!-- bin & cfg directories -->
+ <fileSet>
+ <directory>src/main/packaged-resources</directory>
+ <outputDirectory>.</outputDirectory>
+ <includes>
+ <include>*/**</include>
+ </includes>
+ </fileSet>
+ <!-- var/log: empty directory for logs -->
+ <fileSet>
+ <directory>.</directory>
+ <outputDirectory>var/log</outputDirectory>
+ <excludes>
+ <exclude>*/**</exclude>
+ </excludes>
+ </fileSet>
+ </fileSets>
+
+ <dependencySets>
+ <!-- lib: my libraries -->
+ <dependencySet>
+ <outputDirectory>lib</outputDirectory>
+ <includes>
+ <include>fr.kevincorvisier*:*</include>
+ </includes>
+ </dependencySet>
+ <!-- ext: external libraries -->
+ <dependencySet>
+ <outputDirectory>ext</outputDirectory>
+ <excludes>
+ <exclude>fr.kevincorvisier*:*</exclude>
+ </excludes>
+ </dependencySet>
+ </dependencySets>
+
+</assembly>
diff --git a/src/main/java/fr/kevincorvisier/mtg/dd/Cache.java b/src/main/java/fr/kevincorvisier/mtg/dd/Cache.java
@@ -0,0 +1,90 @@
+package fr.kevincorvisier.mtg.dd;
+
+import java.io.BufferedReader;
+import java.io.BufferedWriter;
+import java.io.IOException;
+import java.io.StringReader;
+import java.net.URL;
+import java.sql.Connection;
+import java.sql.DriverManager;
+import java.sql.PreparedStatement;
+import java.sql.ResultSet;
+import java.sql.SQLException;
+import java.time.LocalDate;
+import java.util.Optional;
+import java.util.Properties;
+
+import org.apache.commons.io.output.StringBuilderWriter;
+
+import fr.kevincorvisier.mtg.dd.model.DeckItem;
+import fr.kevincorvisier.mtg.dd.model.DeckItemFactory;
+import fr.kevincorvisier.mtg.dd.mtgo.MagicOnlineFileReader;
+import fr.kevincorvisier.mtg.dd.mtgo.MagicOnlineFileWriter;
+
+public class Cache
+{
+ private static final String QUERY_INITIALIZE = "CREATE TABLE IF NOT EXISTS `decks` (`url` TEXT PRIMARY KEY,`player` TEXT,`archetype` TEXT,`date` TEXT,`content` TEXT);";
+
+ private final Connection connection;
+ private final DeckItemFactory deckItemFactory;
+
+ public Cache(final DeckItemFactory deckItemFactory, final Properties props) throws SQLException
+ {
+ final String file = props.getProperty("cache.database");
+ if (file == null)
+ throw new RuntimeException("Property cache.database must be present and have a value");
+
+ this.deckItemFactory = deckItemFactory;
+ connection = DriverManager.getConnection("jdbc:sqlite:" + file);
+ initialize();
+ }
+
+ private void initialize() throws SQLException
+ {
+ try (PreparedStatement ps = connection.prepareStatement(QUERY_INITIALIZE))
+ {
+ ps.executeUpdate();
+ }
+ }
+
+ public Optional<DeckItem> findByUrl(final URL url) throws SQLException, IOException
+ {
+ try (PreparedStatement ps = connection.prepareStatement("SELECT * FROM decks WHERE url = ?"))
+ {
+ ps.setString(1, url.toString());
+ final ResultSet rs = ps.executeQuery();
+
+ if (!rs.next())
+ return Optional.empty();
+
+ final DeckItem deck = deckItemFactory.create(url, rs.getString("player"), rs.getString("archetype"), LocalDate.parse(rs.getString("date")));
+
+ try (BufferedReader reader = new BufferedReader(new StringReader(rs.getString("content"))))
+ {
+ deck.setMain(MagicOnlineFileReader.read(reader));
+ }
+
+ return Optional.of(deck);
+ }
+ }
+
+ public void save(final DeckItem deck) throws SQLException, IOException
+ {
+ final StringBuilder builder = new StringBuilder();
+
+ try (BufferedWriter writer = new BufferedWriter(new StringBuilderWriter(builder)))
+ {
+ MagicOnlineFileWriter.write(writer, deck);
+ }
+
+ try (PreparedStatement ps = connection.prepareStatement("INSERT INTO `decks` (`url`, `player`, `archetype`,`date`, `content`) VALUES (?, ?, ?, ?, ?);"))
+ {
+ ps.setString(1, deck.getUrl().toString());
+ ps.setString(2, deck.getPlayer());
+ ps.setString(3, deck.getArchetype());
+ ps.setString(4, deck.getDate().toString());
+ ps.setString(5, builder.toString());
+ ps.executeUpdate();
+ }
+ }
+}
diff --git a/src/main/java/fr/kevincorvisier/mtg/dd/Crawler.java b/src/main/java/fr/kevincorvisier/mtg/dd/Crawler.java
@@ -0,0 +1,84 @@
+package fr.kevincorvisier.mtg.dd;
+
+import java.io.IOException;
+import java.io.InputStream;
+import java.net.URL;
+import java.util.List;
+
+import org.openqa.selenium.By;
+import org.openqa.selenium.WebDriver;
+import org.openqa.selenium.WebElement;
+import org.openqa.selenium.htmlunit.HtmlUnitDriver;
+
+import com.google.common.util.concurrent.RateLimiter;
+
+import lombok.extern.slf4j.Slf4j;
+
+@Slf4j
+public class Crawler
+{
+ private static final double CRAWL_DELAY_SECONDS = 30d;
+
+ private final WebDriver driver = new HtmlUnitDriver();
+ private final RateLimiter rateLimiter;
+
+ public Crawler()
+ {
+ rateLimiter = RateLimiter.create(1d / CRAWL_DELAY_SECONDS);
+ }
+
+ /*
+ * WebDriver operations with rate limitation
+ */
+
+ public void navigateTo(final URL url)
+ {
+ try
+ {
+ rateLimiter.acquire();
+ driver.navigate().to(url);
+
+ log.info("navigateTo: url={}", url);
+ }
+ catch (final Exception e)
+ {
+ log.warn("navigateTo: url={}", url, e);
+ throw e;
+ }
+ }
+
+ /*
+ * Other operations with rate limitation
+ */
+
+ public InputStream openStream(final URL url) throws IOException
+ {
+ try
+ {
+ rateLimiter.acquire();
+ final InputStream result = url.openStream();
+
+ log.info("openStream: url={}", url);
+ return result;
+ }
+ catch (final Exception e)
+ {
+ log.warn("openStream: url={}", url, e);
+ throw e;
+ }
+ }
+
+ /*
+ * WebDriver operations without rate limitation
+ */
+
+ public List<WebElement> findElements(final By by)
+ {
+ return driver.findElements(by);
+ }
+
+ public WebElement findElement(final By by)
+ {
+ return driver.findElement(by);
+ }
+}
diff --git a/src/main/java/fr/kevincorvisier/mtg/dd/DecklistConsumer.java b/src/main/java/fr/kevincorvisier/mtg/dd/DecklistConsumer.java
@@ -0,0 +1,16 @@
+package fr.kevincorvisier.mtg.dd;
+
+import java.io.IOException;
+
+import fr.kevincorvisier.mtg.dd.model.DeckItem;
+
+public interface DecklistConsumer
+{
+ int capacity();
+
+ int capacity(final DeckItem deck);
+
+ void consume(final DeckItem deck) throws IOException;
+
+ void saveToFolder() throws IOException;
+}
diff --git a/src/main/java/fr/kevincorvisier/mtg/dd/DecklistDownloader.java b/src/main/java/fr/kevincorvisier/mtg/dd/DecklistDownloader.java
@@ -0,0 +1,11 @@
+package fr.kevincorvisier.mtg.dd;
+
+import java.io.IOException;
+import java.net.URL;
+
+public interface DecklistDownloader
+{
+ boolean accept(final URL url);
+
+ void download(final URL url, final DecklistConsumer consumer) throws IOException;
+}
diff --git a/src/main/java/fr/kevincorvisier/mtg/dd/DefaultDecklistConsumer.java b/src/main/java/fr/kevincorvisier/mtg/dd/DefaultDecklistConsumer.java
@@ -0,0 +1,159 @@
+package fr.kevincorvisier.mtg.dd;
+
+import java.io.BufferedReader;
+import java.io.BufferedWriter;
+import java.io.File;
+import java.io.FileWriter;
+import java.io.IOException;
+import java.io.InputStreamReader;
+import java.nio.file.Files;
+import java.util.Collections;
+import java.util.HashMap;
+import java.util.HashSet;
+import java.util.Map;
+import java.util.Set;
+import java.util.function.Function;
+import java.util.stream.Collectors;
+
+import org.apache.commons.lang3.StringUtils;
+
+import fr.kevincorvisier.mtg.dd.model.DeckItem;
+import fr.kevincorvisier.mtg.dd.model.DeckItemComparator;
+import fr.kevincorvisier.mtg.dd.mtgo.MagicOnlineFileReader;
+import fr.kevincorvisier.mtg.dd.mtgo.MagicOnlineFileWriter;
+import fr.kevincorvisier.mtg.dd.validation.DeckValidator;
+import lombok.RequiredArgsConstructor;
+import lombok.extern.slf4j.Slf4j;
+
+@Slf4j
+@RequiredArgsConstructor
+public class DefaultDecklistConsumer implements DecklistConsumer
+{
+ private final Map<String, Map<String, DeckItem>> downloadedByDeckNameByPlayerName = new HashMap<>();
+ private final Set<Integer> downloadedContent = new HashSet<>();
+ private final Map<String, Integer> countByArchetype = new HashMap<>();
+
+ private final Cache cache;
+ private final File folder;
+ private final int limit;
+ private final int archetypeLimit;
+ private final DeckValidator validator;
+ private final Crawler crawler;
+ private final String[] ignorePlayers;
+
+ @Override
+ public int capacity()
+ {
+ return limit - downloadedContent.size();
+ }
+
+ @Override
+ public int capacity(final DeckItem deck)
+ {
+ final int globalCapacity = capacity();
+ final int archetypeCapacity = archetypeLimit - countByArchetype.getOrDefault(deck.getArchetype(), 0);
+
+ return Math.min(archetypeCapacity, globalCapacity);
+ }
+
+ private boolean accept(final DeckItem deck)
+ {
+ if (capacity(deck) <= 0)
+ return false;
+
+ final String playerName = deck.getPlayer().toLowerCase();
+ final String deckName = deck.getArchetype().toLowerCase();
+
+ if (StringUtils.containsAnyIgnoreCase(playerName, ignorePlayers))
+ return false; // Ignore this player
+
+ final DeckItem previouslyDownloadedVersion = downloadedByDeckNameByPlayerName.getOrDefault(playerName, Collections.emptyMap()).get(deckName);
+
+ if (previouslyDownloadedVersion != null && previouslyDownloadedVersion.getDate().isAfter(deck.getDate()))
+ {
+ log.info("consume: more recent version of {} {} already downloaded, ignoring", deck.getPlayer(), deck.getArchetype());
+ return false;
+ }
+
+ return true;
+ }
+
+ @Override
+ public void consume(final DeckItem deck) throws IOException
+ {
+ try
+ {
+ if (!accept(deck))
+ return;
+
+ final DeckItem cached = cache.findByUrl(deck.getUrl()).orElse(null);
+ if (cached != null)
+ deck.setMain(cached.getMain());
+ else
+ {
+ try (BufferedReader reader = new BufferedReader(new InputStreamReader(crawler.openStream(deck.getUrl()))))
+ {
+ deck.setMain(MagicOnlineFileReader.read(reader));
+ }
+ cache.save(deck);
+ }
+
+ if (downloadedContent.contains(deck.getMain().hashCode()))
+ {
+ log.warn("consume: ignore deck: same decklist already exists: {}", deck);
+ return;
+ }
+
+ if (!validator.validate(deck))
+ return;
+
+ downloadedByDeckNameByPlayerName.computeIfAbsent(deck.getPlayer().toLowerCase(), k -> new HashMap<>()) //
+ .put(deck.getArchetype().toLowerCase(), deck);
+
+ downloadedContent.add(deck.getMain().hashCode());
+ countByArchetype.merge(deck.getArchetype(), 1, (a, b) -> a + b);
+ }
+ catch (final Exception e)
+ {
+ log.error("Error while downloading deck, skipping {}", deck, e);
+ }
+ }
+
+ @Override
+ public void saveToFolder() throws IOException
+ {
+ if (!folder.exists())
+ folder.mkdirs();
+
+ final Map<String, DeckItem> deckByFileName = downloadedByDeckNameByPlayerName.values().stream() //
+ .flatMap(v -> v.values().stream()) //
+ .collect(Collectors.toMap(DeckItem::getFilename, Function.identity()));
+
+ for (final File file : folder.listFiles())
+ {
+ if (deckByFileName.containsKey(file.getName()))
+ {
+ deckByFileName.remove(file.getName());
+ continue;
+ }
+
+ Files.delete(file.toPath());
+ log.info("Deleted {}", file);
+ }
+
+ final Set<DeckItem> toSave = deckByFileName.values().stream() //
+ .sorted(DeckItemComparator.INSTANCE) //
+ .limit(limit) //
+ .collect(Collectors.toUnmodifiableSet());
+
+ for (final DeckItem deck : toSave)
+ {
+ final File file = new File(folder, deck.getFilename());
+ try (BufferedWriter writer = new BufferedWriter(new FileWriter(file)))
+ {
+ MagicOnlineFileWriter.write(writer, deck);
+ }
+ log.info("Saved {}", file);
+ }
+ }
+}
diff --git a/src/main/java/fr/kevincorvisier/mtg/dd/Main.java b/src/main/java/fr/kevincorvisier/mtg/dd/Main.java
@@ -0,0 +1,111 @@
+package fr.kevincorvisier.mtg.dd;
+
+import java.io.File;
+import java.io.IOException;
+import java.net.URL;
+import java.sql.SQLException;
+import java.util.ArrayList;
+import java.util.Arrays;
+import java.util.Collection;
+import java.util.List;
+import java.util.Map;
+import java.util.Properties;
+import java.util.stream.Collectors;
+
+import org.apache.commons.lang3.StringUtils;
+
+import fr.kevincorvisier.mtg.dd.hareruya.HareruyaDecklistDownloader;
+import fr.kevincorvisier.mtg.dd.model.DeckItemFactory;
+import fr.kevincorvisier.mtg.dd.tcdecks.TcdecksDecklistDownloader;
+import fr.kevincorvisier.mtg.dd.validation.DeckValidator;
+import fr.kevincorvisier.mtg.dd.validation.DeckValidatorFactory;
+import lombok.extern.slf4j.Slf4j;
+
+@Slf4j
+public class Main
+{
+ public static void main(final String[] args) throws IOException, SQLException
+ {
+ final Properties props = new Properties();
+ props.load(Main.class.getClassLoader().getResourceAsStream("application.properties"));
+
+ final List<String> formats = Arrays.stream(props.getProperty("formats", "").split(",")) //
+ .map(String::trim).filter(StringUtils::isNotEmpty) //
+ .distinct().toList();
+
+ final String[] ignorePlayers = Arrays.stream(props.getProperty("ignore.players", "").split(",")) //
+ .map(String::trim).filter(StringUtils::isNotEmpty) //
+ .toArray(String[]::new);
+
+ final Map<String, DeckValidator> validatorByFormat = DeckValidatorFactory.create(formats, props);
+
+ final Map<String, String> replaceArchetypes = Arrays.stream(props.getProperty("replace-archetypes", "").split(",")) //
+ .map(String::trim).filter(StringUtils::isNotEmpty) //
+ .collect(Collectors.toMap(s -> s.split("\\|")[0].trim(), s -> s.split("\\|")[1].trim()));
+
+ final DeckItemFactory deckItemFactory = new DeckItemFactory(replaceArchetypes);
+ final Cache cache = new Cache(deckItemFactory, props);
+ final Crawler crawler = new Crawler();
+
+ final Collection<DecklistDownloader> downloaders = List.of(new HareruyaDecklistDownloader(crawler, deckItemFactory),
+ new TcdecksDecklistDownloader(crawler, deckItemFactory, props));
+
+ for (final String format : formats)
+ {
+ final Collection<URL> urls = new ArrayList<>();
+ int i = 0;
+
+ while (true)
+ {
+ final String url = props.getProperty("formats." + format + ".sources." + i++);
+ if (url == null)
+ break;
+ urls.add(new URL(url));
+ }
+
+ final String outputFolder = props.getProperty("formats." + format + ".output-dir");
+ if (outputFolder == null)
+ {
+ log.warn("main: no output folder for format, skipping: {}", format);
+ continue;
+ }
+
+ final int limit = Integer.parseInt(props.getProperty("formats." + format + ".limit", "0"));
+ if (limit <= 0)
+ {
+ log.warn("main: limit invalid, skipping: {}", limit);
+ continue;
+ }
+
+ final int archetypeLimit = Integer.parseInt(props.getProperty("formats." + format + ".archetype-limit", "0"));
+ if (limit <= 0)
+ {
+ log.warn("main: archetype limit invalid, skipping: {}", limit);
+ continue;
+ }
+
+ final DeckValidator validator = validatorByFormat.get(format);
+ final DecklistConsumer consumer = new DefaultDecklistConsumer(cache, new File(outputFolder), limit, archetypeLimit, validator, crawler,
+ ignorePlayers);
+
+ try
+ {
+ for (final URL url : urls)
+ {
+ final DecklistDownloader downloader = downloaders.stream().filter(d -> d.accept(url)).findAny().orElse(null);
+ if (downloader == null)
+ {
+ log.warn("No downloader for URL: {}", url);
+ continue;
+ }
+
+ downloader.download(url, consumer);
+ }
+ }
+ finally
+ {
+ consumer.saveToFolder();
+ }
+ }
+ }
+}
diff --git a/src/main/java/fr/kevincorvisier/mtg/dd/hareruya/HareruyaDecklistDownloader.java b/src/main/java/fr/kevincorvisier/mtg/dd/hareruya/HareruyaDecklistDownloader.java
@@ -0,0 +1,72 @@
+package fr.kevincorvisier.mtg.dd.hareruya;
+
+import java.io.IOException;
+import java.net.URL;
+import java.time.LocalDate;
+
+import org.openqa.selenium.By;
+import org.openqa.selenium.WebElement;
+
+import fr.kevincorvisier.mtg.dd.Crawler;
+import fr.kevincorvisier.mtg.dd.DecklistConsumer;
+import fr.kevincorvisier.mtg.dd.DecklistDownloader;
+import fr.kevincorvisier.mtg.dd.model.DeckItem;
+import fr.kevincorvisier.mtg.dd.model.DeckItemFactory;
+import lombok.RequiredArgsConstructor;
+
+@RequiredArgsConstructor
+public class HareruyaDecklistDownloader implements DecklistDownloader
+{
+ private final Crawler crawler;
+ private final DeckItemFactory deckItemFactory;
+
+ @Override
+ public boolean accept(final URL url)
+ {
+ return url.getHost().equals("www.hareruyamtg.com");
+ }
+
+ @Override
+ public void download(final URL url, final DecklistConsumer consumer)
+ {
+ crawler.navigateTo(url);
+
+ for (final WebElement element : crawler.findElements(By.className("deckSearch-searchResult__itemWrapper")))
+ {
+ try
+ {
+ final DeckItem deck = processDeckElement(element);
+ consumer.consume(deck);
+ }
+ catch (final IOException e)
+ {
+ e.printStackTrace();
+ }
+ }
+ }
+
+ private DeckItem processDeckElement(final WebElement element) throws IOException
+ {
+ final String playerName = element.findElement(By.className("deckSearch-searchResult__item__playerName__link")).getText();
+ final String deckName = element.findElement(By.xpath(".//li[@class='deckSearch-searchResult__item__deckName']/span[2]")).getText();
+ final String date = parseDate(element, By.className("deckSearch-searchResult__item__tournamentDate"));
+
+ // Retrieve the id from the URL, then build the download URL from it
+ // Format: https://www.hareruyamtg.com/en/deck/361764/show/
+ final String href = element.getAttribute("href");
+ final int beginIndex = "https://www.hareruyamtg.com/en/deck/".length();
+ final int endIndex = href.length() - "/show/".length();
+ final long id = Long.parseLong(href.substring(beginIndex, endIndex));
+ final URL url = new URL("https://www.hareruyamtg.com/en/deck/download/" + id);
+
+ return deckItemFactory.create(url, playerName, deckName, LocalDate.parse(date));
+ }
+
+ private String parseDate(final WebElement element, final By by)
+ {
+ String text = element.findElement(by).getText();
+ if (text.startsWith("Date:"))
+ text = text.substring("Date:".length());
+ return text.replace('/', '-').replace(" ", "");
+ }
+}
diff --git a/src/main/java/fr/kevincorvisier/mtg/dd/model/DeckItem.java b/src/main/java/fr/kevincorvisier/mtg/dd/model/DeckItem.java
@@ -0,0 +1,58 @@
+package fr.kevincorvisier.mtg.dd.model;
+
+import java.net.URL;
+import java.time.LocalDate;
+import java.util.Collections;
+import java.util.Map;
+
+import org.apache.commons.lang3.StringUtils;
+
+import lombok.AccessLevel;
+import lombok.Data;
+import lombok.RequiredArgsConstructor;
+
+@Data
+@RequiredArgsConstructor(access = AccessLevel.PACKAGE)
+public class DeckItem
+{
+ private final URL url;
+ private final String player;
+ private final String archetype;
+ private final LocalDate date;
+
+ private Map<String, Integer> main = Collections.emptyMap();
+
+ public String getFilename()
+ {
+ return date.toString() + "_" + sanitize(player) + "_" + sanitize(archetype) + ".txt";
+ }
+
+ private static String sanitize(String s)
+ {
+ if (s == null)
+ return null;
+
+ // Remove Katakana
+ s = s.replace("ア", "a").replace("イ", "i").replace("ウ", "u").replace("エ", "e").replace("オ", "o") //
+ .replace("キャ", "kya").replace("キュ", "kyu").replace("キョ", "kyo") //
+ .replace("カー", "kaa").replace("キー", "kii").replace("クー", "kuu").replace("ケー", "kee").replace("コー", "koo") //
+ .replace("カ", "ka").replace("キ", "ki").replace("ク", "ku").replace("ケ", "ke").replace("コ", "ko") //
+ .replace("ガ", "ga").replace("ギ", "gi").replace("グ", "gu").replace("ゲ", "ge").replace("ゴ", "go") //
+ .replace("サ", "sa").replace("シ", "shi").replace("ス", "su").replace("セ", "se").replace("ソ", "so") //
+ .replace("タ", "ta").replace("チ", "chi").replace("ツ", "tsu").replace("テ", "te").replace("ト", "to") //
+ .replace("ダ", "da").replace("ヂ", "ji").replace("ヅ", "zu").replace("デ", "de").replace("ド", "do") //
+ .replace("ナ", "na").replace("ニ", "ni").replace("ヌ", "nu").replace("ネ", "ne").replace("ノ", "no") //
+ .replace("ハ", "ha").replace("ヒ", "hi").replace("フ", "fu").replace("ヘ", "he").replace("ホ", "ho") //
+ .replace("バ", "ba").replace("ビ", "bi").replace("ブ", "bu").replace("ベ", "be").replace("ボ", "bo") //
+ .replace("マー", "maa").replace("ミー", "mii").replace("ムー", "muu").replace("メー", "mee").replace("モー", "moo") //
+ .replace("マ", "ma").replace("ミ", "mi").replace("ム", "mu").replace("メ", "me").replace("モ", "mo") //
+ .replace("ヤ", "ya").replace("ユ", "yu").replace("ヨ", "yo")//
+ .replace("ラ", "ra").replace("リ", "ri").replace("ル", "ru").replace("レ", "re").replace("ロ", "ro") //
+ .replace("ヴァ", "va");
+
+ // Remove accents
+ s = StringUtils.stripAccents(s);
+
+ return s.replace(" ", "").replace("[", "").replace("]", "").replace("_", "");
+ }
+}
diff --git a/src/main/java/fr/kevincorvisier/mtg/dd/model/DeckItemComparator.java b/src/main/java/fr/kevincorvisier/mtg/dd/model/DeckItemComparator.java
@@ -0,0 +1,23 @@
+package fr.kevincorvisier.mtg.dd.model;
+
+import java.util.Comparator;
+
+public class DeckItemComparator implements Comparator<DeckItem>
+{
+ public static final DeckItemComparator INSTANCE = new DeckItemComparator();
+
+ @Override
+ public int compare(final DeckItem o1, final DeckItem o2)
+ {
+ // More recent first
+ int res = -o1.getDate().compareTo(o2.getDate());
+ if (res != 0)
+ return res;
+
+ res = o1.getPlayer().compareTo(o2.getPlayer());
+ if (res != 0)
+ return res;
+
+ return o1.getArchetype().compareTo(o2.getArchetype());
+ }
+}
diff --git a/src/main/java/fr/kevincorvisier/mtg/dd/model/DeckItemFactory.java b/src/main/java/fr/kevincorvisier/mtg/dd/model/DeckItemFactory.java
@@ -0,0 +1,25 @@
+package fr.kevincorvisier.mtg.dd.model;
+
+import java.net.URL;
+import java.time.LocalDate;
+import java.util.Map;
+
+import lombok.RequiredArgsConstructor;
+import lombok.extern.slf4j.Slf4j;
+
+@Slf4j
+@RequiredArgsConstructor
+public class DeckItemFactory
+{
+ private final Map<String, String> replaceArchetypes;
+
+ public DeckItem create(final URL url, final String player, final String archetype, final LocalDate date)
+ {
+ final String archetypeReplacement = replaceArchetypes.get(archetype);
+
+ if (archetypeReplacement != null)
+ log.info("Replaced {} by {}", archetype, archetypeReplacement);
+
+ return new DeckItem(url, player, archetypeReplacement != null ? archetypeReplacement : archetype, date);
+ }
+}
diff --git a/src/main/java/fr/kevincorvisier/mtg/dd/mtgo/MagicOnlineFileReader.java b/src/main/java/fr/kevincorvisier/mtg/dd/mtgo/MagicOnlineFileReader.java
@@ -0,0 +1,49 @@
+package fr.kevincorvisier.mtg.dd.mtgo;
+
+import java.io.BufferedReader;
+import java.io.IOException;
+import java.util.HashMap;
+import java.util.Map;
+import java.util.regex.Matcher;
+import java.util.regex.Pattern;
+
+import lombok.AccessLevel;
+import lombok.NoArgsConstructor;
+
+@NoArgsConstructor(access = AccessLevel.PACKAGE) // static class
+public class MagicOnlineFileReader
+{
+ private static final Pattern PATTERN_LINE = Pattern.compile("(?<count>\\d+) (?<name>.+)");
+
+ public static Map<String, Integer> read(final BufferedReader reader) throws NumberFormatException, IOException
+ {
+ final Map<String, Integer> result = new HashMap<>();
+
+ int emptyLineCount = 0;
+ String line;
+ while ((line = reader.readLine()) != null)
+ {
+ line = line.trim().replace(" // ", "/");
+ if (line.isEmpty())
+ {
+ emptyLineCount++;
+ continue;
+ }
+
+ if (line.equals("Sideboard") || emptyLineCount == 2) // Skip the sideboard
+ break;
+
+ emptyLineCount = 0;
+
+ final Matcher matcher = PATTERN_LINE.matcher(line);
+ if (!matcher.matches())
+ throw new RuntimeException("Cannot parse line: " + line);
+
+ final String cardName = matcher.group("name");
+ final int cardCount = Integer.parseInt(matcher.group("count"));
+ result.put(cardName, cardCount);
+ }
+
+ return result;
+ }
+}
diff --git a/src/main/java/fr/kevincorvisier/mtg/dd/mtgo/MagicOnlineFileWriter.java b/src/main/java/fr/kevincorvisier/mtg/dd/mtgo/MagicOnlineFileWriter.java
@@ -0,0 +1,27 @@
+package fr.kevincorvisier.mtg.dd.mtgo;
+
+import java.io.BufferedWriter;
+import java.io.IOException;
+import java.util.Map.Entry;
+
+import fr.kevincorvisier.mtg.dd.model.DeckItem;
+import lombok.AccessLevel;
+import lombok.NoArgsConstructor;
+
+@NoArgsConstructor(access = AccessLevel.PACKAGE) // static class
+public class MagicOnlineFileWriter
+{
+ public static void write(final BufferedWriter writer, final DeckItem deck) throws IOException
+ {
+ for (final Entry<String, Integer> card : deck.getMain().entrySet())
+ {
+ writer.append(Integer.toString(card.getValue())).append(' ').append(sanitizeCardName(card.getKey())).append('\n');
+ }
+ }
+
+ private static String sanitizeCardName(final String cardName)
+ {
+ final int indexOfSlash = cardName.indexOf("/");
+ return indexOfSlash != -1 ? cardName.substring(0, indexOfSlash) : cardName;
+ }
+}
diff --git a/src/main/java/fr/kevincorvisier/mtg/dd/tcdecks/TcdecksDecklistDownloader.java b/src/main/java/fr/kevincorvisier/mtg/dd/tcdecks/TcdecksDecklistDownloader.java
@@ -0,0 +1,214 @@
+package fr.kevincorvisier.mtg.dd.tcdecks;
+
+import java.io.IOException;
+import java.net.MalformedURLException;
+import java.net.URL;
+import java.time.LocalDate;
+import java.time.format.DateTimeFormatter;
+import java.util.ArrayList;
+import java.util.List;
+import java.util.Properties;
+import java.util.regex.Matcher;
+import java.util.regex.Pattern;
+
+import org.openqa.selenium.By;
+import org.openqa.selenium.NoSuchElementException;
+import org.openqa.selenium.WebElement;
+
+import fr.kevincorvisier.mtg.dd.Crawler;
+import fr.kevincorvisier.mtg.dd.DecklistConsumer;
+import fr.kevincorvisier.mtg.dd.DecklistDownloader;
+import fr.kevincorvisier.mtg.dd.model.DeckItemFactory;
+import lombok.RequiredArgsConstructor;
+import lombok.extern.slf4j.Slf4j;
+
+@Slf4j
+@RequiredArgsConstructor
+public class TcdecksDecklistDownloader implements DecklistDownloader
+{
+ private static final Pattern PATTERN_URL_FORMAT = Pattern.compile("https://www\\.tcdecks\\.net/format\\.php\\?format=(?:[A-Za-z])+");
+ private static final Pattern PATTERN_URL_ARCHETYPE = Pattern
+ .compile("https://www\\.tcdecks\\.net/archetype\\.php\\?archetype=(?<archetype>[A-Za-z]+)&format=[A-Za-z]+");
+ private static final Pattern PATTERN_URL_DECK = Pattern.compile("https://www.tcdecks.net/deck\\.php\\?id=(?<id>\\d+)&iddeck=(?<iddeck>\\d+)");
+
+ private static final Pattern PATTERN_POSITION = Pattern.compile("\\d+ of (?<players>\\d+)");
+
+ private static final Pattern pattern = Pattern.compile("Date: (?<dayOfMonth>\\d{2})\\/(?<month>\\d{2})\\/(?<year>\\d{4})");
+ private static final Pattern PATTERN_PLAYERS = Pattern.compile("(?<players>\\d+) Players");
+
+ private static final String FORMAT_DOWNLOAD_URL = "/download.php?ext=txt&id=%1$s&iddeck=%2$s";
+
+ private final Crawler crawler;
+ private final DeckItemFactory deckItemFactory;
+ private final int tournamentMinPlayers;
+
+ public TcdecksDecklistDownloader(final Crawler crawler, final DeckItemFactory deckItemFactory, final Properties props)
+ {
+ this.crawler = crawler;
+ this.deckItemFactory = deckItemFactory;
+ tournamentMinPlayers = Integer.parseInt(props.getProperty("tcdecks.tournament.players.min", "0"));
+ }
+
+ @Override
+ public boolean accept(final URL url)
+ {
+ return url.getHost().equals("www.tcdecks.net");
+ }
+
+ @Override
+ public void download(final URL url, final DecklistConsumer consumer) throws IOException
+ {
+ final String urlStr = url.toString();
+
+ if (PATTERN_URL_FORMAT.matcher(urlStr).matches())
+ {
+ downloadFormat(url, consumer);
+ return;
+ }
+
+ final Matcher matcher = PATTERN_URL_ARCHETYPE.matcher(urlStr);
+ if (matcher.matches())
+ {
+ final String archetype = matcher.group("archetype");
+ downloadArchetype(url, archetype, consumer);
+ }
+ }
+
+ private void downloadFormat(final URL url, final DecklistConsumer consumer) throws IOException
+ {
+ URL next = url;
+
+ do
+ {
+ crawler.navigateTo(next);
+
+ final List<URL> tournaments = new ArrayList<>();
+
+ for (final WebElement tr : crawler.findElements(By.xpath("//table[@class='tourney_list']/tbody/tr")))
+ {
+ if (hasElement(tr, By.xpath("th")))
+ continue; // Header row
+
+ final WebElement tournamentName = tr.findElement(By.xpath("td[@data-th='Tournament Name']/a"));
+ final String players = tr.findElement(By.xpath("td[@data-th='Players']/a")).getText();
+
+ if ("Unknown".equals(players))
+ continue; // Skip tournaments with no player count
+
+ final Matcher matcher = PATTERN_PLAYERS.matcher(players);
+ if (!matcher.matches())
+ {
+ log.error("Cannot parse players column: {}", players);
+ return;
+ }
+
+ final int nbPlayers = Integer.parseInt(matcher.group("players"));
+ if (nbPlayers < tournamentMinPlayers)
+ {
+ log.info("Skip {}: less than {} players ({} players)", tournamentName.getText(), tournamentMinPlayers, nbPlayers);
+ continue; // Skip small tournaments
+ }
+
+ tournaments.add(new URL(tournamentName.getAttribute("href")));
+ }
+
+ next = new URL(crawler.findElement(By.xpath("//a[text()='Next']")).getAttribute("href"));
+
+ for (final URL tournament : tournaments)
+ downloadTournament(tournament, consumer);
+ } while (consumer.capacity() > 0);
+ }
+
+ private void downloadArchetype(final URL url, final String archetype, final DecklistConsumer consumer) throws IOException
+ {
+ URL next = url;
+
+ do
+ {
+ crawler.navigateTo(next);
+
+ next = new URL(crawler.findElement(By.xpath("//a[text()='Next']")).getAttribute("href"));
+
+ for (final WebElement tr : crawler.findElements(By.xpath("//table[@class='tourney_list']/tbody/tr")))
+ {
+ if (hasElement(tr, By.xpath("th")))
+ continue; // Header row
+
+ final WebElement deckName = tr.findElement(By.xpath("td[@data-th='Deck Name']/a"));
+ final WebElement player = tr.findElement(By.xpath("td[@data-th='Player']/a"));
+ final WebElement date = tr.findElement(By.xpath("td[@data-th='Date']/a"));
+ final WebElement position = tr.findElement(By.xpath("td[@data-th='Position']/a"));
+ final URL downloadUrl = toDownloadUrl(deckName.getAttribute("href"));
+
+ final Matcher matcher = PATTERN_POSITION.matcher(position.getText());
+ if (matcher.matches())
+ {
+ final int nbPlayers = Integer.parseInt(matcher.group("players"));
+ if (nbPlayers < tournamentMinPlayers)
+ {
+ log.info("Skip {}: less than {} players ({} players)", deckName.getText(), tournamentMinPlayers, nbPlayers);
+ continue; // Skip small tournaments
+ }
+ }
+
+ consumer.consume(deckItemFactory.create(downloadUrl, player.getText(), archetype,
+ LocalDate.parse(date.getText(), DateTimeFormatter.ofPattern("dd/MM/yyyy"))));
+ }
+ } while (consumer.capacity() > 0);
+ }
+
+ private void downloadTournament(final URL url, final DecklistConsumer consumer) throws IOException
+ {
+ crawler.navigateTo(url);
+
+ final String description = crawler.findElement(By.xpath("//article[@class='span8']/fieldset/legend/h5")).getText();
+
+ log.info("download tournament: {}", description);
+
+ final Matcher matcher = pattern.matcher(description);
+ if (!matcher.find())
+ {
+ log.error("Cannot parse tournament description: {}", description);
+ return;
+ }
+
+ final LocalDate date = LocalDate.of( //
+ Integer.parseInt(matcher.group("year")), //
+ Integer.parseInt(matcher.group("month")), //
+ Integer.parseInt(matcher.group("dayOfMonth")));
+
+ for (final WebElement tr : crawler.findElements(By.xpath("//table[@class='tourney_list']/tbody/tr")))
+ {
+ if (hasElement(tr, By.xpath("th")))
+ continue; // Header row
+
+ final WebElement archetype = tr.findElement(By.xpath("td[@data-th='Archetype']/a"));
+ final WebElement player = tr.findElement(By.xpath("td[@data-th='Player']/a"));
+ final URL downloadUrl = toDownloadUrl(archetype.getAttribute("href"));
+
+ consumer.consume(deckItemFactory.create(downloadUrl, player.getText(), archetype.getText(), date));
+ }
+ }
+
+ private boolean hasElement(final WebElement element, final By by)
+ {
+ try
+ {
+ element.findElement(by);
+ return true;
+ }
+ catch (final NoSuchElementException e)
+ {
+ return false;
+ }
+ }
+
+ private URL toDownloadUrl(final String deckUrl) throws MalformedURLException
+ {
+ final Matcher matcher = PATTERN_URL_DECK.matcher(deckUrl);
+ if (!matcher.matches())
+ throw new RuntimeException("Cannot parse deck url: " + deckUrl);
+
+ return new URL("https", "www.tcdecks.net", String.format(FORMAT_DOWNLOAD_URL, matcher.group("id"), matcher.group("iddeck")));
+ }
+}
diff --git a/src/main/java/fr/kevincorvisier/mtg/dd/validation/DeckValidator.java b/src/main/java/fr/kevincorvisier/mtg/dd/validation/DeckValidator.java
@@ -0,0 +1,40 @@
+package fr.kevincorvisier.mtg.dd.validation;
+
+import java.util.Collection;
+import java.util.Map.Entry;
+
+import fr.kevincorvisier.mtg.dd.model.DeckItem;
+import lombok.RequiredArgsConstructor;
+import lombok.extern.slf4j.Slf4j;
+
+@Slf4j
+@RequiredArgsConstructor
+public class DeckValidator
+{
+ private final Collection<String> banlist;
+
+ public boolean validate(final DeckItem deck)
+ {
+ int cardCount = 0;
+
+ for (final Entry<String, Integer> card : deck.getMain().entrySet())
+ {
+ final String cardName = card.getKey();
+ if (banlist.contains(cardName))
+ {
+ log.debug("validate: invalid deck: {} is banned: {}", cardName, deck);
+ return false;
+ }
+
+ cardCount += card.getValue();
+ }
+
+ if (cardCount < 60)
+ {
+ log.debug("validate: invalid deck: less than 60 cards: {}", deck);
+ return false;
+ }
+
+ return true;
+ }
+}
diff --git a/src/main/java/fr/kevincorvisier/mtg/dd/validation/DeckValidatorFactory.java b/src/main/java/fr/kevincorvisier/mtg/dd/validation/DeckValidatorFactory.java
@@ -0,0 +1,65 @@
+package fr.kevincorvisier.mtg.dd.validation;
+
+import java.io.BufferedReader;
+import java.io.IOException;
+import java.io.InputStreamReader;
+import java.util.Collection;
+import java.util.Collections;
+import java.util.HashMap;
+import java.util.HashSet;
+import java.util.Map;
+import java.util.Properties;
+import java.util.Set;
+
+import lombok.AccessLevel;
+import lombok.NoArgsConstructor;
+
+@NoArgsConstructor(access = AccessLevel.PRIVATE) // static class
+public class DeckValidatorFactory
+{
+ public static Map<String, DeckValidator> create(final Iterable<String> formats, final Properties props) throws IOException
+ {
+ final Map<String, Collection<String>> banlistByName = new HashMap<>();
+ final Map<String, DeckValidator> validatorByFormat = new HashMap<>();
+
+ for (final String format : formats)
+ {
+ final Set<String> formatBanlist = new HashSet<>();
+
+ for (String banlistName : props.getProperty("formats." + format + ".banlists", "").split(","))
+ {
+ banlistName = banlistName.trim();
+ if (banlistName.isEmpty())
+ continue;
+
+ Collection<String> banlist = banlistByName.get(banlistName);
+ if (banlist == null)
+ banlistByName.put(banlistName, banlist = readBanlist(banlistName));
+ formatBanlist.addAll(banlist);
+ }
+
+ validatorByFormat.put(format, new DeckValidator(Collections.unmodifiableSet(formatBanlist)));
+ }
+
+ return validatorByFormat;
+ }
+
+ private static Set<String> readBanlist(final String name) throws IOException
+ {
+ final Set<String> banlist = new HashSet<>();
+
+ try (BufferedReader reader = new BufferedReader(new InputStreamReader(DeckValidatorFactory.class.getClassLoader().getResourceAsStream(name))))
+ {
+ String line;
+ while ((line = reader.readLine()) != null)
+ {
+ line = line.trim();
+ if (line.isEmpty())
+ continue;
+ banlist.add(line);
+ }
+ }
+
+ return Collections.unmodifiableSet(banlist);
+ }
+}
diff --git a/src/main/packaged-resources/bin/decks-downloader.sh b/src/main/packaged-resources/bin/decks-downloader.sh
@@ -0,0 +1,29 @@
+#!/bin/sh
+
+SERVICE_NAME=decks-downloader
+SERVICE_RAM=1024M
+SERVICE_MAIN=fr.kevincorvisier.mtg.dd.Main
+
+
+if screen -ls $SERVICE_NAME | grep -q $SERVICE_NAME
+then
+
+ echo "The service $SERVICE_NAME is already started."
+
+else
+
+ HEAP="-Xms$SERVICE_RAM -Xmx$SERVICE_RAM"
+ ERROR="-XX:+HeapDumpOnOutOfMemoryError -XX:HeapDumpPath=var/log/$SERVICE_NAME-$(date +%Y%m%d-%H%M%S).hprof -XX:ErrorFile=var/log/$SERVICE_NAME-$(date +%Y%m%d-%H%M%S)-error.log"
+
+ GC="-XX:+UseG1GC -XX:MaxGCPauseMillis=50 -XX:GCPauseIntervalMillis=500 -XX:+DisableExplicitGC"
+ GC_LOGS="-Xlog:gc*:var/log/$SERVICE_NAME-$(date +%Y%m%d-%H%M%S)-gc.log"
+
+ CLASSPATH="-cp .:cfg:ext/*:lib/*"
+
+ cd ..
+ screen -dmS $SERVICE_NAME java -server $HEAP $ERROR $GC $GC_LOGS $CLASSPATH $SERVICE_MAIN > var/log/$SERVICE_NAME-$(date +%Y%m%d-%H%M%S).out 2>&1
+ cd -
+
+ echo "The service $SERVICE_NAME has been started."
+
+fi
diff --git a/src/main/packaged-resources/cfg/application.properties b/src/main/packaged-resources/cfg/application.properties
@@ -0,0 +1,52 @@
+
+cache.database=/home/kebi/Documents/mtg/cache.db
+
+
+ignore.players=Moreau,\u30E2\u30ED\u30FC
+replace-archetypes=Mono Red[MiddleSchool]|Burn,Sligh|Burn,Other|Rogue,Goblin[Middle School]|Goblins
+
+# formats=example1-ms-opponents,example1-pm-opponents
+# tcdecks.tournament.players.min=64
+
+# formats=example1-initial-population
+# tcdecks.tournament.players.min=128
+
+formats=pauper-meta,standard-meta
+
+formats.example1-ms-opponents.banlists=banlist_ai.txt,banlist_custom.txt
+formats.example1-ms-opponents.limit=13
+formats.example1-ms-opponents.archetype-limit=1
+formats.example1-ms-opponents.sources.0=https://www.hareruyamtg.com/en/deck/result?pageSize=100&formats[11]=11&eventName=middle&public_status=public&grades=champion
+formats.example1-ms-opponents.sources.1=https://www.hareruyamtg.com/en/deck/result?pageSize=100&formats[11]=11&eventName=middle&public_status=public&grades=top8
+formats.example1-ms-opponents.sources.2=https://www.hareruyamtg.com/en/deck/result?pageSize=100&formats[11]=11&eventName=middle&public_status=public&grades=top8&page=2
+formats.example1-ms-opponents.sources.3=https://www.hareruyamtg.com/en/deck/result?pageSize=100&formats[11]=11&eventName=middle&public_status=public
+formats.example1-ms-opponents.sources.4=https://www.hareruyamtg.com/en/deck/result?pageSize=100&formats[11]=11&eventName=middle&public_status=public&page=2
+formats.example1-ms-opponents.sources.5=https://www.hareruyamtg.com/en/deck/result?pageSize=100&formats[11]=11&eventName=middle&public_status=public&page=3
+formats.example1-ms-opponents.sources.6=https://www.hareruyamtg.com/en/deck/result?pageSize=100&formats[11]=11&eventName=middle&public_status=public&page=4
+formats.example1-ms-opponents.output-dir=/home/kebi/git/repositories/mtg-genetic-deckbuilding/src/main/packaged-resources/cfg/example1/ms-opponents
+
+formats.example1-pm-opponents.banlists=banlist_ai.txt,banlist_custom.txt
+formats.example1-pm-opponents.limit=12
+formats.example1-pm-opponents.archetype-limit=1
+formats.example1-pm-opponents.sources.0=https://www.tcdecks.net/format.php?format=Premodern
+formats.example1-pm-opponents.output-dir=/home/kebi/git/repositories/mtg-genetic-deckbuilding/src/main/packaged-resources/cfg/example1/pm-opponents2
+
+formats.example1-initial-population.banlists=banlist_ai.txt,banlist_ec.txt,banlist_custom.txt
+formats.example1-initial-population.limit=20
+formats.example1-initial-population.archetype-limit=20
+formats.example1-initial-population.sources.0=https://www.hareruyamtg.com/en/deck/result?pageSize=100&formats[11]=11&eventName=middle&public_status=public&grades=top8&archetypeIds=5841,5885,6440
+formats.example1-initial-population.sources.1=https://www.tcdecks.net/archetype.php?archetype=Burn&format=Premodern
+formats.example1-initial-population.output-dir=/home/kebi/git/repositories/mtg-genetic-deckbuilding/src/main/packaged-resources/cfg/example1/initial-population
+
+formats.pauper-meta.banlists=banlist_ai.txt,banlist_custom.txt
+formats.pauper-meta.limit=100
+formats.pauper-meta.archetype-limit=100
+formats.pauper-meta.sources.0=https://www.hareruyamtg.com/en/deck/result?pageSize=100&formats[8]=8&public_status=public
+formats.pauper-meta.output-dir=/home/kebi/Documents/mtg/pauper/meta
+
+formats.standard-meta.banlists=banlist_ai.txt,banlist_custom.txt
+formats.standard-meta.limit=100
+formats.standard-meta.archetype-limit=100
+formats.standard-meta.sources.0=https://www.hareruyamtg.com/en/deck/result?pageSize=100&formats[1]=1&public_status=public
+formats.standard-meta.output-dir=/home/kebi/Documents/mtg/standard/meta
+
diff --git a/src/main/packaged-resources/cfg/banlist_ai.txt b/src/main/packaged-resources/cfg/banlist_ai.txt
@@ -0,0 +1,2544 @@
+A-Alrund, God of the Cosmos
+Abandon Hope
+About Face
+Abundance
+Abundant Harvest
+Abyssal Persecutor
+Acidic Dagger
+Acidic Soil
+Acolyte's Reward
+Acorn Catapult
+Act of Authority
+Act on Impulse
+Adanto Vanguard
+Adarkar Unicorn
+Adarkar Windform
+Ad Nauseam
+Aeon Chronicler
+Aeon Engine
+Aethermage's Touch
+Aetherplasm
+Aether Shockwave
+Aether Snap
+Aethersnatch
+Aether Storm
+Aether Tide
+Aether Tradewinds
+Afterlife Insurance
+Agent of Shauku
+Agent of Stromgald
+Aggression
+Aggressive Mining
+A-Hakka, Whispering Raven
+Airdrop Condor
+Akki Avalanchers
+Akoum Flameseeker
+Akroma's Blessing
+Akul the Unrepentant
+Al-abara's Carpet
+Aladdin's Lamp
+Alchemist's Apprentice
+Alchemist's Gambit
+Alchemist's Refuge
+Alchor's Tomb
+Aleatory
+Alexi, Zephyr Mage
+Alliance of Arms
+Alluring Scent
+Alluring Siren
+Alpha Brawl
+Alpha Kavu
+Alrund, God of the Cosmos
+Altar of Dementia
+Altar of the Wretched
+Alter Reality
+Amber Prison
+Amoeboid Changeling
+Amok
+Amulet of Quoz
+Anaba Ancestor
+Ancestral Knowledge
+Ancient Spring
+Angelic Favor
+Angel of Salvation
+Angel's Trumpet
+An-Havva Township
+Animation Module
+Anthroplasm
+Anurid Brushhopper
+Apex Observatory
+Aphetto Alchemist
+Aphetto Dredging
+Aphetto Grifter
+Aquamoeba
+Aquitect's Will
+Araumi of the Dead Tide
+Arcane Lighthouse
+Arcane Spyglass
+Arcbond
+Archon of Valor's Reach
+Arctic Merfolk
+Arcum's Astrolabe
+Arcum's Sleigh
+Arcum's Whistle
+Argent Mutation
+Armageddon Clock
+Armed and Armored
+Armor of Thorns
+Arsenal Thresher
+Artificer's Intuition
+Artillerize
+Ashling's Prerogative
+Ashling the Pilgrim
+Ashnod's Cylix
+Assault Suit
+Assembly Hall
+Astral Slide
+Astrolabe
+Atalya, Samite Master
+A-Thran Portal
+Aura Barbs
+Aura Finesse
+Aura Graft
+Auriok Siege Sled
+Auriok Transfixer
+Auriok Windwalker
+Aurora Eidolon
+Aurora Griffin
+Autumn's Veil
+Autumn-Tail, Kitsune Sage
+Autumn Willow
+Avacyn, Guardian Angel
+Avarice Totem
+Aven Liberator
+Aven Mimeomancer
+Aven Windreader
+Avizoa
+Awe for the Guilds
+Axis of Mortality
+Aysen Abbey
+Azorius Ploy
+Azorius Signet
+Backdraft
+Backslide
+Bag of Holding
+Baki's Curse
+Balance of Power
+Balancing Act
+Balduvian Shaman
+Balm of Restoration
+Balthor the Defiled
+Bamboozle
+Bamboozling Beeble
+Bane of the Living
+Banishing Knack
+Banshee
+Barbarian Guides
+Barbed Sextant
+Bargaining Table
+Barrage of Expendables
+Barrage Tyrant
+Barrel Down Sokenzan
+Barrenton Medic
+Barrin
+Barrin, Master Wizard
+Barter in Blood
+Basal Sliver
+Basal Thrull
+Bathe in Light
+Battle Cry
+Batwing Brume
+Bazaar Trademage
+Bazaar Trader
+Beacon of Destiny
+Beast Within
+Behold the Beyond
+Belbe's Armor
+Benalish Missionary
+Bend or Break
+Benevolent Offering
+Betrayal of Flesh
+Betrothed of Fire
+Bifurcate
+Biomass Mutation
+Biorhythm
+Bioshift
+Birchlore Rangers
+Bite of the Black Rose
+Black Carriage
+Black Sun's Zenith
+Blasting Station
+Blast Zone
+Blaze of Glory
+Blazing Shoal
+Blessed Reincarnation
+Blighted Burgeoning
+Blind Fury
+Blinding Beam
+Blinding Fog
+Blind Seer
+Blinkmoth Infusion
+Blinkmoth Well
+Blood Celebrant
+Bloodfire Infusion
+Bloodflow Connoisseur
+Blood Oath
+Blood of the Martyr
+Blood Rites
+Bloodscent
+Bloodshot Cyclops
+Bloodthirsty Adversary
+Blood Vassal
+Bloom Tender
+Bogardan Dragonheart
+Bog Elemental
+Boggart Forager
+Bog Initiate
+Bog Naughty
+Bog Witch
+Bola Warrior
+Bolt Bend
+Boltbender
+Bond of Agony
+Bonds of Mortality
+Bone Flute
+Bone Shaman
+Boros Battleshaper
+Boros Fury-Shield
+Boros Signet
+Borrowing the East Wind
+Bosh, Iron Golem
+Bosh, Iron Golem Avatar
+Bösium Strip
+Bottomless Vault
+Bounty of the Hunt
+Brace for Impact
+Brain Pry
+Brain Weevil
+Brand
+Brand of Ill Omen
+Brass Squire
+Brass-Talon Chimera
+Brave-Kin Duo
+Brave the Elements
+Brawl
+Breaking Wave
+Breakthrough
+Breath of Fury
+Brightflame
+Brilliant Spectrum
+Brine Seer
+Brine Shaman
+Bringer of the Black Dawn
+Bring to Light
+Bronze Tablet
+Browse
+Brutal Deceiver
+Brutalizer Exarch
+Bubbling Muck
+Bulwark
+Burden of Guilt
+Burn at the Stake
+Burning Cloak
+Burning-Tree Bloodscale
+Burnt Offering
+Burr Grafter
+Burst of Speed
+Butcher Orgg
+Cabal Coffers
+Cabal Interrogator
+Cabal Patriarch
+Cabal Therapist
+Cabal Therapy
+Cache Raiders
+Cadaverous Bloom
+Cagemail
+Calciform Pools
+Calcite Snapper
+Caldera Hellion
+Call for Aid
+Call for Blood
+Callous Deceiver
+Callous Oppressor
+Call the Coppercoats
+Calming Licid
+Calming Verse
+Camouflage
+Candelabra of Tawnos
+Cannibalize
+Canopy Claws
+Canyon Drake
+Captain of the Mists
+Captain's Maneuver
+Caregiver
+Carnage Altar
+Carom
+Carpet of Flowers
+Carrion
+Carrion Rats
+Cartel Aristocrat
+Cascade Bluffs
+Castle Sengir
+Cataclysm
+Cataclysmic Gearhulk
+Cauldron of Souls
+Cave of Temptation
+Celestial Convergence
+Celestial Kirin
+Celestial Prism
+Cemetery Puca
+Cephalid Broker
+Cephalid Illusionist
+Cephalid Snitch
+Cerebral Vortex
+Cerulean Wisps
+Ceta Disciple
+Chainer, Nightmare Adept
+Chain Stasis
+Chamber of Manipulation
+Champion of Stray Souls
+Chandra Ablaze
+Chandra, Pyromaster
+Channel
+Channel the Suns
+Chaos Harlequin
+Chaoslace
+Chaos Lord
+Chaos Moon
+Charge Across the Araba
+Charge of the Forever-Beast
+Chariot of the Sun
+Charm Peddler
+Cheering Fanatic
+Chill Haunting
+Chimeric Coils
+Chimeric Idol
+Chimeric Staff
+Choice of Damnations
+ChooseName:DB$ NameCard | ValidCards$ Card.nonLand | Defined$ You | AtRandom$ True | SubAbility$ CreateAbility
+Chromatic Armor
+Chromatic Sphere
+Chromatic Star
+Chromeshell Crab
+Chronatog
+Chronatog Avatar
+Cinderhaze Wretch
+Cinder Seer
+Circle of Despair
+Circling Vultures
+Citanul Flute
+City in a Bottle
+City of Shadows
+City of Traitors
+Civic Guildmage
+Civilized Scholar
+Clairvoyance
+Claws of Gix
+Cleansing
+Cleansing Beam
+CleanupName:DB$ Cleanup | ClearNamedCard$ True
+Cliffside Market
+Cloak of Confusion
+Clock of Omens
+Coalhauler Swine
+Coastal Wizard
+Coffin Puppets
+Cold Storage
+Colfenor's Plans
+Collective Voyage
+Command Beacon
+Commandeer
+Commander's Sphere
+Command the Dreadhorde
+Commune with Lava
+Complex Automaton
+Composite Golem
+Compulsion
+Confiscation Coup
+Conjured Currency
+Conjurer's Closet
+Consign to Dream
+Conspiracy
+Consuming Ferocity
+Contagion
+Contamination
+Contested Cliffs
+Contingency Plan
+Contraband Livestock
+Contract from Below
+Convincing Mirage
+Convulsing Licid
+Cooperate
+Cooperation
+Coordinated Barrage
+Copperhoof Vorrac
+Copper-Leaf Angel
+Copy Enchantment
+Coral Fighters
+Coral Helm
+Coral Reef
+Coral Trickster
+Coretapper
+Corpse Augur
+Corpse Blockade
+Corpse Lunge
+Corpse Traders
+Corpseweft
+Corrupted Grafstone
+Corrupting Licid
+Cosmic Larva
+Cosmium Catalyst
+Courtly Provocateur
+Covetous Elegy
+Covetous Urge
+Crag Puca
+Cranial Archive
+Crater Elemental
+Crazed Armodon
+Cream of the Crop
+Credit Voucher
+Creeping Renaissance
+Crested Craghorn
+Crookclaw Transmuter
+Crosis's Attendant
+Crossbow Ambush
+Crovax the Cursed
+Crown of Ascension
+Crown of Awe
+Crown of Convergence
+Crown of Doom
+Crown of Fury
+Crown of Suspicion
+Crown of the Ages
+Crown of Vigor
+Crucible of the Spirit Dragon
+Cruel Deceiver
+Cruel Entertainment
+Cruel Fate
+Cruel Sadist
+Crush of Tentacles
+Crypt Creeper
+Cryptic Gateway
+Crypt of Agadeem
+Crystal Quarry
+Crystal Shard
+Crystal Spray
+Crystal Vein
+Culling Dais
+Culling Mark
+Culling Scales
+Culling the Weak
+Cultural Exchange
+Cunning Giant
+Cuombajj Witches
+Curfew
+Curse of Chaos
+Curse of Echoes
+Curse of the Swine
+Customs Depot
+Cyclone
+Cyclopean Tomb
+Cytoplast Manipulator
+Cytoshape
+Daghatar the Adamant
+Dakkon Blackblade Avatar
+Dakmor Salvage
+Dakra Mystic
+Damping Engine
+Dance of the Manse
+Dance of the Skywise
+Dangerous Wager
+Daretti, Scrap Savant
+Darigaaz's Attendant
+Daring Thief
+Dark Deal
+Dark Heart of the Wood
+Darkheart Sliver
+Dark Maze
+Darkpact
+Dark Privilege
+Darksteel Mutation
+Dark Supplicant
+Dark Triumph
+Darkwater Catacombs
+Darkwater Egg
+Daughter of Autumn
+Dauntless Escort
+Dawnfluke
+Dawnglare Invoker
+Dawn's Reflection
+Day's Undoing
+Dazzling Reflection
+Dead-Iron Sledge
+Deadly Tempest
+Deadly Wanderings
+Dead Reckoning
+Dead Ringers
+Deadshot
+Death Bomb
+Death Cloud
+Deathknell Kami
+Deathlace
+Deathmark Prelate
+Death-Mask Duplicant
+Death Pit Offering
+Debt of Loyalty
+Decaying Soil
+Deceiver of Form
+Decree of Annihilation
+Decree of Pain
+Deep Water
+Deepwood Elder
+Defensive Maneuvers
+Defiant Stand
+Deflection
+Deftblade Elite
+Delif's Cone
+Delif's Cube
+Dementia Sliver
+Demonic Appetite
+Demonic Attorney
+Demonic Collusion
+Demonic Consultation
+Demonic Pact
+Demonmail Hauberk
+Demoralize
+Descendant of Masumaro
+Descent into Madness
+Descent of the Dragons
+Desecration Elemental
+Deserter's Quarters
+Desolate Mire
+Desolation
+Desolation Giant
+Desperate Gambit
+Desperate Research
+Detainment Spell
+Detection Tower
+Detritivore
+Devastating Dreams
+Devastating Summons
+Devoted Caretaker
+Devoted Druid
+Devouring Greed
+Devouring Hellion
+Devouring Rage
+Devouring Strossus
+Devout Decree
+Diabolic Intent
+Diabolic Revelation
+Diamond Lion
+Diminish
+Diminishing Returns
+Dimir Charm
+Dimir Doppelganger
+Dimir Machinations
+Dimir Signet
+Dinosaur Headdress
+Dire Wolves
+Disallow
+Disappearing Act
+Disarm
+Disaster Radius
+Disciple of Bolas
+Discontinuity
+Discord, Lord of Disharmony
+Disharmony
+Dispatch
+Dispersal Shield
+Dispersing Orb
+Displace
+Dispossess
+Disrupting Shoal
+Distorting Lens
+Divebomber Griffin
+Divergent Growth
+Diversionary Tactics
+Divert
+Divine Deflection
+Divine Intervention
+Divine Light
+Divine Reckoning
+Diviner's Lockbox
+Divining Witch
+Dizzy Spell
+Djinn Illuminatus
+Djinn of Infinite Deceits
+Djinn of Wishes
+Dominate
+Dominating Licid
+Domineering Will
+Doomsday
+Doomsday Confluence
+Doubling Cube
+Dovin, Hand of Control
+Drafna's Restoration
+Dragon Mask
+Dragonrage
+Dragonshift
+Dragon Throne of Tarkir
+Dralnu, Lich Lord
+Drawn from Dreams
+Dread Charge
+Dreadship Reef
+Dreamcatcher
+Dream Salvage
+Dream's Grip
+Dream Stalker
+Dream Thrush
+Dreamwinder
+Dregs of Sorrow
+Dromar's Attendant
+Dromar, the Banisher
+Droning Bureaucrats
+Drop of Honey
+Drowned Rusalka
+Drown in Filth
+Dryad's Caress
+Dualcaster Mage
+Duct Crawler
+Dulcet Sirens
+Duplicity
+Dust of Moments
+Dwarven Armorer
+Dwarven Armory
+Dwarven Hold
+Dwarven Recruiter
+Dwarven Ruins
+Dwarven Sea Clan
+Dwarven Song
+Dwarven Thaumaturgist
+Dwell on the Past
+Early Harvest
+Earthbrawn
+Earthcraft
+Eater of Hope
+Ebonblade Reaper
+Ebon Stronghold
+Ebony Charm
+Ebony Horse
+Echoing Calm
+Echoing Decay
+Echoing Ruin
+Edge of Autumn
+Eight-and-a-Half-Tails
+Eladamri
+Elder Druid
+Elfhame Sanctuary
+Elite Arcanist
+Elsewhere Flask
+Elturel Survivors
+Elusive Tormentor
+Elven Palisade
+Elvish Scout
+Embalmed Brawler
+Embercleave
+Ember Gale
+Emberwilde Caliph
+Embolden
+Emerald Charm
+Emergence Zone
+Empty City Ruse
+Emrakul, the Promised End
+Endemic Plague
+Endless Horizons
+Endless Whispers
+Endling
+Endure
+Enduring Renewal
+Energy Arc
+Energy Chamber
+Energy Tap
+Energy Vortex
+Enervate
+Engineered Explosives
+Enigma Eidolon
+Enraging Licid
+Ensnare
+Entrancing Lyre
+Entrancing Melody
+Epic Experiment
+Epiphany Storm
+Equal Treatment
+Errand of Duty
+Erratic Mutation
+Error
+Ersatz Gnomes
+Ertai's Meddling
+Ertai, the Corrupted
+Esix, Fractal Bloom
+Esper Sojourners
+Essence Bottle
+Essence Flare
+Ethereal Champion
+Etherwrought Page
+Eunuchs' Intrigues
+Evershrike
+Everythingamajig
+Evolutionary Leap
+Evolution Charm
+Excavator
+Exert Influence
+Experiment Kraj
+Exponential Growth
+Extract
+Extravagant Spirit
+Eyekite
+Eye of Doom
+Eye of Ojer Taq
+Eye of Ramos
+Eye of Singularity
+Eye of the Storm
+Eyes of the Watcher
+Eye Spy
+Fabrication Foundry
+Faceless One
+Fade Away
+Faeburrow Elder
+Faith Healer
+Faithless Looting
+Faith's Shield
+Falkenrath Torturer
+False Dawn
+False Orders
+False Peace
+Falter
+Familiar's Ruse
+Famished Ghoul
+Fanatical Devotion
+Faramir, Prince of Ithilien
+Farrelite Priest
+Fascination
+Fasting
+Fatal Attraction
+Fatal Lore
+Fatal Mutation
+Fatestitcher
+Fate Transfer
+Fatigue
+Fault Riders
+Feed the Pack
+Feint
+Fendeep Summoner
+Fend Off
+Feral Contest
+Feral Deceiver
+Ferrous Lake
+Fertile Ground
+Fertile Imagination
+Festival
+Festival of the Guildpact
+Fetid Heath
+Fettergeist
+Field of Dreams
+Fiend Artisan
+Fiery Bombardment
+Fiery Gambit
+Fighting Chance
+Final Flare
+Final Fortune
+Final Revels
+Final Strike
+Fire and Brimstone
+Fireblade Artist
+Firecat Blitz
+Fire Covenant
+Firedrinker Satyr
+Firefright Mage
+Fire-Lit Thicket
+Firemind Vessel
+Firespout
+Fire Sprites
+Firestorm
+Flailing Manticore
+Flailing Ogre
+Flailing Soldier
+Flame Fusillade
+Flame-Kin War Scout
+Flameshot
+Flaring Pain
+Flash
+Flash of Defiance
+Flash of Insight
+Fleeting Spirit
+Flesh Reaver
+Flicker
+Flickerform
+Flickering Ward
+Flicker of Fate
+Floating Shield
+Floodbringer
+Floodchaser
+Flooded Grove
+Flooded Shoreline
+Floodtide Serpent
+Floodwater Dam
+Flowstone Slide
+Flowstone Surge
+Flurry of Wings
+Flux
+Fluxcharger
+Fog Patch
+Folio of Fancies
+Food Chain
+Footbottom Feast
+Forbid
+Forbidden Crypt
+Forbidden Ritual
+Forced March
+Force of Virtue
+Foreshadow
+Foresight
+Forfend
+Forge Armor
+Forgotten Lore
+Forsaken City
+Fortified Area
+Fortitude
+Fossil Find
+Foul-Tongue Shriek
+Foxfire
+Frantic Salvage
+Freed from the Real
+Frenetic Ogre
+Frenetic Sliver
+Fresh Meat
+Freyalise Supplicant
+Friendly Fire
+Funeral Pyre
+Fungal Reaches
+Fungus Elemental
+Furnace Brood
+Fury Charm
+Fylamarid
+Gabriel Angelfire
+Gaea's Blessing
+Gaea's Liege
+Galepowder Mage
+Gallowbraid
+Game of Chaos
+Gargantuan Gorilla
+Gargoyle Sentinel
+Garth One-Eye
+Gather Specimens
+Gauntlets of Chaos
+Gaze of Granite
+Gaze of Pain
+Gemstone Caverns
+General Jarkeld
+General's Regalia
+Generator Servant
+Geothermal Crevice
+Ghastly Haunting
+Ghave, Guru of Spores
+Ghostly Flicker
+Ghostly Possession
+Ghostly Touch
+Ghost Tactician
+Ghostway
+Ghoulcaller Gisa
+Ghoulcaller's Chant
+Giant Caterpillar
+Giant Oyster
+Giant Slug
+Giant Trap Door Spider
+Gibbering Descent
+Gideon, Champion of Justice
+Gideon Jura
+Gift of Doom
+Gift of Tusks
+Gilded Light
+Gitaxian Probe
+Give
+Glacial Chasm
+Glamerdye
+Glarecaster
+Glare of Subdual
+Glarewielder
+Glasses of Urza
+Glen Elendra
+Glen Elendra Pranksters
+Gliding Licid
+Glimmervoid Basin
+Glissa Sunseeker
+Glorious End
+Glyph of Destruction
+Glyph of Doom
+Glyph of Life
+Gnathosaur
+Goblin Archaeologist
+Goblin Artisans
+Goblin Bangchuckers
+Goblin Bombardment
+Goblin Cadets
+Goblin Cannon
+Goblin Clearcutter
+Goblin Diplomats
+Goblin Dynamo
+Goblin Flectomancer
+Goblin Game
+Goblin Legionnaire
+Goblin Lyre
+Goblin Machinist
+Goblin Recruiter
+Goblin Sappers
+Goblin Ski Patrol
+Goblin Test Pilot
+Goblin War Cry
+Goblin Welder
+God-Eternal Bontu
+Godtoucher
+Golgari Signet
+Golgothian Sylex
+Gore Vassal
+Gossamer Chains
+Grab the Reins
+Graceful Antelope
+Gravebind
+Grave Consequences
+Graven Cairns
+Gravepurge
+Grave Servitude
+Grazing Kelpie
+Great Defender
+Greater Good
+Greel, Mind Raker
+Greenseeker
+Grell Philosopher
+Gremlin Mine
+Grenzo, Dungeon Warden
+Grid Monitor
+Grim Contest
+Grimoire of the Dead
+Grimoire Thief
+Grim Reminder
+Grinning Ignus
+Grinning Totem
+Grixis Charm
+Grixis Illusionist
+Gruul Signet
+Guard Dogs
+Guardian Angel
+Guiding Spirit
+Guild Globe
+Gurzigost
+Gustha's Scepter
+Hail Storm
+Hakka, Whispering Raven
+Hallow
+Hallowed Ground
+Halls of Mist
+Hammerheim
+Hankyu
+Hapless Researcher
+Hard Cover
+Harmonic Convergence
+Harmony of Nature
+Harm's Way
+Harsh Deceiver
+Harsh Justice
+Harsh Mercy
+Harvest Mage
+Harvest Pyre
+Hatred
+Haunted Crossroads
+Haunting Misery
+Havengul Lich
+Havenwood Battleground
+Hazduhr the Abbot
+Head Games
+Healing Grace
+Heap Doll
+Heart of Ramos
+Heart Warden
+Heartwarming Redemption
+Heartwood Shard
+Heat Stroke
+Heat Wave
+Heaven's Gate
+Hecatomb
+Helionaut
+HELIOS One
+Hell-Bent Raider
+Hellish Rebuke
+Hell's Caretaker Avatar
+Helvault
+Henge of Ramos
+Heretic's Punishment
+Heritage Druid
+Heroic Defiance
+Heroism
+Hesitation
+Hew the Entwood
+Hex Parasite
+Hidden Stag
+Hidden Strings
+High Tide
+Hisoka, Minamo Sensei
+Hisoka's Guard
+Hobbit's Sting
+Holistic Wisdom
+Hollowhenge Spirit
+Hollow Trees
+Holy Justiciar
+Homarid Spawning Bed
+Homicidal Brute
+Homing Sliver
+Horn of Ramos
+Horror of Horrors
+Hour of Eternity
+Hour of Need
+Hua Tuo, Honored Physician
+Hubris
+Hull Breach
+Hundred-Talon Strike
+Hunt Down
+Hunter's Ambush
+Hunter's Insight
+Hurr Jackal
+Hydromorph Guardian
+Hydromorph Gull
+Hyperion Blacksmith
+Hypochondria
+Ib Halfheart, Goblin Tactician
+Icatian Store
+Iceberg
+Ice Cauldron
+Ice Floe
+Ichor Explosion
+Ideas Unbound
+Idol of Endurance
+Ignition Team
+Ignorant Bliss
+Iizuka the Ruthless
+Ill-Gotten Gains
+Illuminated Folio
+Illuminated Wings
+Illusionary Mask
+Illusionary Terrain
+Illusionist's Gambit
+Illusionist's Stratagem
+Illusion of Choice
+Illusory Demon
+Imagecrafter
+Immovable Rod
+Impelled Giant
+Implements of Sacrifice
+Imprison
+Improbable Alliance
+Impromptu Raid
+Improvised Club
+Imp's Mischief
+Imps' Taunt
+Iname, Death Aspect
+Incandescent Soulstoke
+Incite
+Incite Hysteria
+Incite Rebellion
+Incite War
+Indestructible Aura
+Induce Despair
+Infernal Darkness
+Infernal Harvest
+Infernal Offering
+Infernal Plunge
+Infernal Tribute
+Infernal Tutor
+Inferno Trap
+Infinite Obliteration
+Infused Arrows
+Infuse with the Elements
+Initiates of the Ebon Hand
+Inkfathom Witch
+Inner Sanctum
+Inner Struggle
+Innocent Blood
+Inquisitor's Snare
+Inside Out
+Insidious Dreams
+Insidious Mist
+Insist
+Inspired Sprite
+Instigator
+Intellectual Offering
+Interdict
+Interplanar Beacon
+Intervention Pact
+Intimidation Bolt
+Into the Fray
+Invert the Skies
+Ion Storm
+Irencrag Feat
+Iron-Heart Chimera
+Irresistible Prey
+Irrigation Ditch
+Island of Wak-Wak
+Island Sanctuary
+Isochron Scepter
+Ith, High Arcanist
+Ivory Charm
+Ivory Gargoyle
+Ivy Seer
+Izzet Chemister
+Izzet Signet
+Jace's Archivist
+Jace, the Living Guildpact
+Jackalope Herd
+Jade Monolith
+Jalira, Master Polymorphist
+Jamuraan Lion
+Jandor's Ring
+Jandor's Saddlebags
+Jangling Automaton
+Jar of Eyeballs
+Jasmine Seer
+Jester's Cap
+Jester's Mask
+Jester's Scepter
+Jetfire, Air Guardian
+Jetfire, Ingenious Scientist
+Jeweled Amulet
+Jeweled Bird
+Jeweled Spirit
+Jhoira of the Ghitu
+Jinx
+Jinxed Choker
+Jinxed Idol
+Jinxed Ring
+Johan
+Jolrael, Mwonvuli Recluse
+Jolt
+Jolting Merfolk
+Journey of Discovery
+Judge Unworthy
+Juju Bubble
+Jund Charm
+Junk Golem
+Junkyo Bell
+Juxtapose
+Kaboom!
+Kaervek's Spite
+Kagemaro, First to Suffer
+Kagemaro's Clutch
+Kaho, Minamo Historian
+Kaleidostone
+Kamahl's Summons
+Kami of Ancient Law
+Karn, Silver Golem
+Karn's Touch
+Karona's Zealot
+Karplusan Minotaur
+Kavu Recluse
+Kaya, Ghost Assassin
+Kazuul's Toll Collector
+Keeper of Progenitus
+Keldon Battlewagon
+Keldon Necropolis
+Kenrith's Transformation
+Kheru Spellsnatcher
+Kiku, Night's Flower
+Kiku's Shadow
+Killing Glare
+Killing Wave
+Kill-Suit Cultist
+Kill Switch
+Kindle the Carnage
+Kiora's Follower
+Kithkin Armor
+Kitsune Mystic
+Kitsune Palliator
+Kjeldoran Javelineer
+Kjeldoran Pride
+Knacksaw Clique
+Knight of the Mists
+Knollspine Dragon
+Knotvine Mystic
+Knowledge Exploitation
+Knowledge Vault
+Kor Chant
+Kor Dirge
+Kor Outfitter
+Koskun Keep
+Krark-Clan Engineers
+Krark-Clan Ironworks
+Krark-Clan Ogre
+Krark-Clan Shaman
+Krark-Clan Stoker
+Krasis Incubation
+Kris Mage
+Krosan Archer
+Krosan Reclamation
+Krosan Restorer
+Krovikan Plague
+Krovikan Sorcerer
+Krovikan Whispers
+Kry Shield
+Kukemssa Serpent
+Kuldotha Flamefiend
+Kylox, Visionary Inventor
+Labyrinth of Skophos
+Lady Evangela
+Lady Sun
+Lake of the Dead
+Lammastide Weave
+Lancers en-Kor
+Land's Edge
+Landslide
+Lantern of Insight
+Laquatus's Creativity
+Last Chance
+Last-Ditch Effort
+Latulla, Keldon Overseer
+Launch Party
+Lavabrink Floodgates
+Lead-Belly Chimera
+Leaden Fists
+Leave No Trace
+Leech Bonder
+Leeches
+Leeching Licid
+Legerdemain
+Lens of Clarity
+Leviathan
+Liar's Pendulum
+Lieutenant Kirtar
+Lifecraft Awakening
+Lifelace
+Life Matrix
+Life's Legacy
+Lightning Dart
+Lightning Storm
+Lightning Volley
+Liliana's Indignation
+Lim-Dûl's Paladin
+Lim-Dûl's Vault
+Limestone Golem
+Limited Resources
+Lion's Eye Diamond
+Liquid Fire
+Lithatog
+Lithomantic Barrage
+Living Destiny
+Llanowar Druid
+Loathsome Catoblepas
+Lobelia Sackville-Baggins
+Locus of Enlightenment
+Long-Term Plans
+Lost Legacy
+Lotus Blossom
+Loxodon Lifechanter
+Loxodon Peacekeeper
+Lumengrid Augur
+Luminesce
+Lyzolda, the Blood Witch
+Madblind Mountain
+Maddening Imp
+Mad Prophet
+Mageta the Lion
+Magewright's Stone
+Magical Hack
+Magma Burst
+Magma Giant
+Magmasaur
+Magmatic Channeler
+Magmatic Chasm
+Magmatic Insight
+Magma Vein
+Magmaw
+Magnetic Theft
+Magosi, the Waterveil
+Magus of the Bazaar
+Magus of the Candelabra
+Magus of the Jar
+Magus of the Unseen
+Magus of the Wheel
+Malakir Soothsayer
+Malevolent Awakening
+Malicious Advice
+Manabond
+Mana Cache
+Mana Clash
+Mana Cylix
+Mana Maze
+Manamorphose
+Mana Prism
+Manascape Refractor
+Mana Screw
+Mana Seism
+Mana Severance
+Mana Short
+Mana Vapors
+Mandate of Peace
+Mangara's Tome
+Manifold Key
+Manipulate Fate
+Mannichi, the Fevered Dream
+Manor Gargoyle
+Maraleaf Rider
+Maralen of the Mornsong Avatar
+Marath, Will of the Wild
+Marauding Raptor
+Maraxus of Keld
+March of the Drowned
+Mardu Blazebringer
+Marker Beetles
+Market Festival
+Mark for Death
+Maro
+Maro Avatar
+Marshaling the Troops
+Marshal's Anthem
+Marsh Flitter
+Marsh Lurker
+Martyred Rusalka
+Martyr of Ashes
+Martyr of Bones
+Martyr of Frost
+Martyr of Sands
+Martyr of Spores
+Martyr's Cause
+Martyrs' Tomb
+Mask of Immolation
+Mask of the Mimic
+Mass Polymorph
+Mastercraft Raptor
+Masterful Replication
+Master of the Veil
+Master Warcraft
+Masticore
+Masumaro, First to Live
+Measure of Wickedness
+Meddle
+Medicine Bag
+Megatherium
+Melee
+Meltdown
+Memoricide
+Memory Jar
+Memory Plunder
+Memory's Journey
+Mental Discipline
+Mercadian Lift
+Mercadia's Downfall
+Merchant Scroll
+Merciless Resolve
+Merfolk Thaumaturgist
+Merrow Grimeblotter
+Merrow Wavebreakers
+Mesmeric Sliver
+Mesmeric Trance
+Metalworker
+Metamorphose
+Metamorphosis
+Metathran Transport
+Meteor Crater
+Meteor Shower
+Midnight Ritual
+Midsummer Revel
+Might of the Nephilim
+Minamo Sightbender
+Mind Bend
+Mindblaze
+Mind Bomb
+Mindbreak Trap
+Mind Extraction
+Mind Games
+Mindlash Sliver
+Mindlink Mech
+Mind Over Matter
+Mindreaver
+Minds Aglow
+Mind Slash
+Mindslaver
+Mind Swords
+Minion of Leshrac
+Minion of the Wastes
+Minions' Murmurs
+Mirage Mirror
+Mire Shade
+Mirozel
+Mirri
+Mirror Entity
+Mirror of Fate
+Mirror of the Forebears
+Mirror Strike
+Mirrorwood Treefolk
+Mischievous Quanar
+Misdirection
+Mishra's Bauble
+Mishra's Helix
+Misinformation
+Misleading Signpost
+Mission Briefing
+Mistbreath Elder
+Mistform Dreamer
+Mistform Mask
+Mistform Mutant
+Mistform Seaswift
+Mistform Shrieker
+Mistform Skyreaver
+Mistform Sliver
+Mistform Stalker
+Mistform Wakecaster
+Mistform Wall
+Mistform Warchief
+Mitotic Manipulation
+Mizzium Transreliquat
+Mizzix's Mastery
+Mnemonic Nexus
+Mogg Cannon
+Mogg Raider
+Mogg Squad
+Mogis's Warhound
+Molten Firebird
+Molten Slagheap
+Moment of Silence
+Momentous Fall
+Monstrous Emergence
+Moonbow Illusionist
+Moonlace
+Moonlight Bargain
+Moonlight Geist
+Moonmist
+Moonring Island
+Moonring Mirror
+Morality Shift
+Moratorium Stone
+Morgue Thrull
+Morgue Toad
+Morinfen
+Morinfen Avatar
+Moriok Replica
+Mossfire Egg
+Mossfire Valley
+Mountain Titan
+Mourning
+Murderous Betrayal
+Myojin of Life's Web
+Myojin of Seeing Winds
+Myr Landshaper
+Myr Quadropod
+Mystic Barrier
+Mystic Compass
+Mystic Confluence
+Mystic Gate
+Mystic Reflection
+Mystic Veil
+Mythos of Snapdax
+Nacatl Hunt-Pride
+Nahiri's Lithoforming
+Nahiri's Wrath
+Nahiri, the Lithomancer
+Naked Singularity
+Nameless Race
+Nantuko Cultivator
+Nantuko Mentor
+Narcissism
+Natural Affinity
+Nature's Chosen
+Nebuchadnezzar
+Necrodominance
+Necromancer's Stockpile
+Necropotence Avatar
+Need for Speed
+Nefarious Lich
+Nemata, Grove Guardian
+Nesting Grounds
+Netherborn Altar
+Nethergoyf
+Nettling Imp
+Neurok Replica
+Neverending Torment
+New Frontiers
+Niblis of the Breath
+Nightbird's Clutches
+Night Dealings
+Nightmare Unmaking
+Night Out in Vegas
+Nightscape Apprentice
+Nightshade Assassin
+Nightshade Seer
+Night Soil
+Nihilistic Glee
+Nim Replica
+Nim Shambler
+Nissa's Judgment
+Niveous Wisps
+Nivix, Aerie of the Firemind
+Nivmagus Elemental
+Nomadic Elf
+Nomads en-Kor
+Nomad Stadium
+Norritt
+North Star
+Nostalgic Dreams
+Nourishing Shoal
+Nova Pentacle
+Noxious Vapors
+Nullmage Advocate
+Nullmage Shepherd
+Nullstone Gargoyle
+Nurturing Licid
+Nyx Weaver
+Oath of Lim-Dûl
+Oath of Scholars
+Obeka, Brute Chronologist
+Oblivion Crown
+Oblivion Stone
+Ob Nixilis of the Black Oath
+Oboro Breezecaller
+Oboro Envoy
+Obscuring Aether
+Odric, Master Tactician
+Odunos River Trawler
+Offalsnout
+Ogre Battlecaster
+Ogre Recluse
+Omnath, Locus of Mana
+Omnibian
+One with Death
+Ooze Flux
+Opal Acrolith
+Opal-Eye, Konda's Yojimbo
+Ophidian
+Opposition
+Oppressive Will
+Oracle
+Oracle's Attendants
+Orcish Bloodpainter
+Orcish Farmer
+Orcish Librarian
+Orcish Lumberjack
+Orcish Mechanics
+Orcish Settlers
+Order of Succession
+Oread of Mountain's Blaze
+Ore-Rich Stalactite
+Oreskos Explorer
+Oriss, Samite Guardian
+Ormos, Archive Keeper
+Ornate Kanzashi
+Orochi Leafcaller
+Orzhov Charm
+Orzhov Pontiff
+Orzhov Signet
+Otherworld Atlas
+Oubliette
+Outbreak
+Outmaneuver
+Outrider en-Kor
+Overblaze
+Overcharged Amalgam
+Overeager Apprentice
+Overflowing Basin
+Overgrown Estate
+Overlaid Terrain
+Overmaster
+Overtaker
+Overwhelm
+Ovinomancer
+Oxidda Daredevil
+Pack Hunt
+Pack's Disdain
+Pact of Negation
+Pact of the Serpent
+Pact of the Titan
+Painbringer
+Painted Bluffs
+Pale Moon
+Paleontologist's Pick-Axe
+Pale Wayfarer
+Panacea
+Pangosaur
+Panoptic Mirror
+Paradigm Shift
+Parallax Dementia
+Parallax Tide
+Parallax Wave
+Parallectric Feedback
+Parallel Thoughts
+Pardic Lancer
+Pardic Miner
+Pardic Swordsmith
+Paroxysm
+Past in Flames
+Patriarch's Bidding
+Patriarch's Desire
+Patron of the Akki
+Patron of the Kitsune
+Patron of the Moon
+Patron of the Nezumi
+Patron of the Orochi
+Peacekeeper
+Peat Bog
+Peek
+Peel from Reality
+Peer Pressure
+Peregrine Mask
+Perpetual Timepiece
+Perplexing Chimera
+Personal Incarnation
+Petals of Insight
+Petrahydrox
+Phantasmal Form
+Phantasmal Mount
+Phantasmal Sphere
+Phantasmal Terrain
+Phantatog
+Phelddagrif
+Phosphorescent Feast
+Phyrexian Colossus
+Phyrexian Delver
+Phyrexian Devourer
+Phyrexian Etchings
+Phyrexian Gremlins
+Phyrexian Grimoire
+Phyrexian Negator
+Phyrexian Plaguelord
+Phyrexian Portal
+Phyrexian Processor
+Phyrexian Purge
+Phyrexian Reclamation
+Phyrexian Revoker
+Phyrexian Scrapyard
+Phyrexian Soulgorger
+Phyrexian Splicer
+Phyrexian Totem
+Phyrexian Tower
+Phyrexian Vault
+Phyrexia's Core
+Piety
+Pili-Pala
+Pillar Tombs of Aku
+Pitchstone Wall
+Plagiarize
+Plague Boiler
+Plaguemaw Beast
+Plague Reaver
+Planar Guide
+Planar Overlay
+Planeswalker's Fury
+Plow Through Reito
+Plunge into Darkness
+Political Trickery
+Pollen Remedy
+Polymorph
+Polymorphist's Jest
+Polymorphous Rush
+Portal Mage
+Portal of Sanctuary
+Postmortem Lunge
+Powder Keg
+Praetor's Grasp
+Precognition
+Predict
+Price of Glory
+Priest of Yawgmoth
+Primal Adversary
+Primal Cocoon
+Primal Plasma
+Primordial Mist
+Primordial Ooze
+Prism Array
+Prismatic Circle
+Prismatic Ending
+Prismatic Lace
+Prismatic Lens
+Prismatic Strands
+Prismite
+Prismwake Merrow
+Proclamation of Rebirth
+Profane Command
+Profaner of the Dead
+Profane Transfusion
+Prohibit
+Promise of Power
+Prophetic Prism
+Prophetic Ravings
+Protective Sphere
+Proteus Staff
+Provoke
+Psionic Entity
+Psionic Ritual
+Psychatog
+Psychic Intrusion
+Psychic Possession
+Psychic Puppetry
+Psychic Surgery
+Psychic Theft
+Psychic Trance
+Psychic Transfer
+Psychic Vortex
+Psychosis Crawler
+Psychotic Episode
+Pteron Ghost
+Puca's Mischief
+Pulsemage Advocate
+Pulse of Llanowar
+Puppeteer
+Puppet Strings
+Puppet's Verdict
+Pure Intentions
+Purelace
+Puresight Merrow
+Pursuit of Knowledge
+Putrid Cyclops
+Putrid Imp
+Putrid Leech
+Putrid Warrior
+Pygmy Hippo
+Pyric Salamander
+Pyromancy
+Pyromania
+Pyxis of Pandemonium
+Quagmire Druid
+Quarum Trench Gnomes
+Quest for Pure Flame
+Questing Phelddagrif
+Quickchange
+Quicken
+Quickening Licid
+Quicksilver Dragon
+Quicksilver Elemental
+Quiet Speculation
+Quirion Ranger
+Radiant Flames
+Radiant Kavu
+Ragamuffyn
+Ragnar
+Rainbow Crow
+Rain of Daggers
+Rain of Filth
+Rain of Rust
+Rakalite
+Rakdos Augermage
+Rakdos Charm
+Rakdos Riteknife
+Rakdos Signet
+Rally the Ancestors
+Rally the Righteous
+Rally the Troops
+Ramosian Rally
+Ransack
+Rapid Decay
+Rapid Fire
+Rath's Edge
+Rats' Feast
+Ravenous Vampire
+Raze
+Razia, Boros Archangel
+Razia's Purification
+Razormane Masticore
+Razor Pendulum
+Read the Runes
+Reality Ripple
+Reality Spasm
+Reality Twist
+Realm Razer
+Reaping the Rewards
+Rebound
+Recall
+Recantation
+Reckless Abandon
+Reckless Assault
+Reckless Barbarian
+Reclusive Wight
+Reconnaissance
+Recurring Insight
+Redcap Melee
+Redeem
+Redirect
+Reef Shaman
+Reflect Damage
+Reflecting Mirror
+Refraction Trap
+Refuse
+Reign of Terror
+Reins of Power
+Reins of the Vinesteed
+Reject Imperfection
+Relic Bind
+Relic Ward
+Remedy
+Renounce
+Repel Intruders
+Repel the Abominable
+Repentance
+Replicate
+Reprocess
+Repudiate
+Reroute
+Rescue from the Underworld
+Reset
+Reshape
+Resilient Wanderer
+Resistance Fighter
+Resounding Roar
+Resounding Scream
+Resounding Silence
+Restless Dreams
+Restore Balance
+Resuscitate
+Retraced Image
+Retraction Helix
+Retribution of the Ancients
+Revelation
+Reverberation
+Reverent Mantra
+Reverse the Sands
+Reweave
+Rhystic Cave
+Rhystic Circle
+Rhystic Deluge
+Rhystic Lightning
+Rhystic Shield
+Ria Ivor, Bane of Bladehold
+Riddle of Lightning
+Ride the Avalanche
+Rift Elemental
+Righteous Aura
+Righteousness
+Rimehorn Aurochs
+Rimewind Taskmage
+Ring of Gix
+Ring of Ma'rûf
+Riot Control
+Riptide Chronologist
+Riptide Mangler
+Riptide Shapeshifter
+Rishadan Port
+Rite of Ruin
+Rite of Undoing
+Rites of Initiation
+Rites of Refusal
+Rites of Spring
+Rith's Attendant
+Ritual of Subdual
+Ritual of the Machine
+River's Grasp
+Roar of Challenge
+Roar of Jukai
+Roar of the Crowd
+Roar of the Kha
+Rocket Launcher
+Rock Hydra
+Rofellos's Gift
+Rogue Skycaptain
+Roiling Horror
+Roilmage's Trick
+Role Reversal
+Rollick of Abandon
+Root Greevil
+Rootrunner
+Rootwater Mystic
+Rotting Giant
+Rouse
+Rude Awakening
+Rugged Prairie
+Rug of Smothering
+Ruins of Trokair
+Rumbling Crescendo
+Rummaging Wizard
+Run Away Together
+Runed Halo
+Rune of Protection: Artifacts
+Rune of Protection: Black
+Rune of Protection: Blue
+Rune of Protection: Green
+Rune of Protection: Lands
+Rune of Protection: Red
+Rune of Protection: White
+Rupture
+Ruric Thar, the Unbowed
+Rushwood Herbalist
+Rust
+Ruthless Disposal
+Ruthless Invasion
+Sacellum Godspeaker
+Sacred Mesa
+Sacred Rites
+Sacrifice
+Safe Haven
+Saffi Eriksdotter
+Sage of Ancient Lore
+Sage of Hours
+Saheeli's Lattice
+Saltcrusted Steppe
+Samite Censer-Bearer
+Samite Elder
+Sanctum Guardian
+Sanctum of Eternity
+Sanctum Prelate
+Sand Silos
+Sandsower
+Sand Squid
+Sandstone Deadfall
+Sandstone Needle
+Sandstorm Eidolon
+Sanguimancy
+Sanguine Praetor
+Sapphire Charm
+Saprazzan Outrigger
+Saprazzan Skerry
+Saproling Burst
+Saproling Cluster
+Sarulf, Realm Eater
+Satyr Piper
+Savage Beating
+Savage Firecat
+Savage Summoning
+Sawtooth Loon
+Scab-Clan Giant
+Scapegoat
+Scapeshift
+Scarab of the Unseen
+Scarecrow
+Scarwood Bandits
+Scent of Brine
+Scent of Cinder
+Scent of Ivy
+Scent of Jasmine
+Scent of Nightshade
+Scheming Symmetry
+School of the Unseen
+Scourge of Skola Vale
+Scouting Trek
+Scout's Warning
+Scrambleverse
+Scrap Mastery
+Scroll Rack
+Scrounging Bandar
+Scryb Ranger
+Scrying Glass
+Scrying Sheets
+Scuttling Death
+Sea Kings' Blessing
+Sealed Fate
+Séance
+Search for Survivors
+Search Warrant
+Searing Rays
+Searing Spear Askari
+Sea Scryer
+Sea Snidd
+Second Wind
+See Beyond
+Seedling Charm
+Seeds of Innocence
+Seedtime
+Segmented Wurm
+Seismic Stomp
+Selective Obliteration
+Selesnya Eulogist
+Selesnya Signet
+Selfless Cathar
+Selfless Exorcist
+Selvala, Explorer Returned
+Sengir Nosferatu
+Sentinel
+Serendib Djinn
+Serene Master
+Serene Sunset
+Serra's Hymn
+Serum Powder
+Setessan Tactics
+Sewerdreg
+Sewers of Estark
+Shade's Breath
+Shadowblood Egg
+Shadowblood Ridge
+Shadow of Doubt
+Shahrazad
+Shaman en-Kor
+Shaman's Trance
+Shambling Swarm
+Shaper Parasite
+Shapesharer
+Shapeshifter
+Shared Fate
+Shared Trauma
+Shattered Crypt
+Shattered Perception
+Shauku, Endbringer
+Shell of the Last Kappa
+Sheltering Ancient
+Shield Dancer
+Shielded by Faith
+Shielded Passage
+Shieldmage Advocate
+Shieldmage Elder
+Shields of Velis Vel
+Shield Wall
+Shifting Borders
+Shifting Loyalties
+Shifty Doppelganger
+Shimatsu the Bloodcloaked
+Shimmer
+Shimmering Grotto
+Shimmering Mirage
+Shinen of Life's Roar
+Shining Shoal
+Shisato, Whispering Hunter
+Shoving Match
+Showstopper
+Shred Memory
+Shrewd Negotiation
+Shrine of Boundless Growth
+Shrine of Limitless Power
+Shrine of Piercing Vision
+Shriveling Rot
+Shrouded Lore
+Shunt
+Sickening Dreams
+Sickening Shoal
+Sideswipe
+Signal the Clans
+Signpost Scarecrow
+Silent Assassin
+Silumgar Sorcerer
+Silverglade Pathfinder
+Silver Wyvern
+Simic Basilisk
+Simic Guildmage
+Simic Manipulator
+Simic Signet
+Simulacrum
+Singing Tree
+Single Combat
+Sink into Takenuma
+Sins of the Past
+Siren's Call
+Siren Song Lyre
+Siren's Ruse
+Sisay
+Sisay's Ingenuity
+Sivvi's Ruse
+Sivvi's Valor
+Skeletal Scrying
+Skinshifter
+Skirge Familiar
+Skirk Alarmist
+Skirk Volcanist
+Skirsdag Flayer
+Skittering Horror
+Skittering Monstrosity
+Skittering Skirge
+Skulltap
+Skycloud Egg
+Skycloud Expanse
+Skyscribing
+Skyship Plunderer
+Skyshroud Blessing
+Skyshroud Elf
+Slate of Ancestry
+Slaughter Games
+Slaughter Pact
+Slaughter-Priest of Mogis
+Slaughter the Strong
+Sleight of Mind
+Slimy Kavu
+Slingbow Trap
+Slinking Skirge
+Slobad, Goblin Tinkerer
+Slumbering Tora
+Smite
+Snakeform
+Snowfall
+Sokenzan Renegade
+Sokrates, Athenian Teacher
+Solar Blaze
+Soldevi Adnate
+Soldevi Digger
+Soldevi Excavations
+Soldevi Golem
+Soldevi Sage
+Soldier Replica
+Solidarity
+Solitary Confinement
+Soltari Guerrillas
+Song of Blood
+Soothsaying
+Sophic Centaur
+Soramaro, First to Dream
+Soratami Cloud Chariot
+Soratami Cloudskater
+Soratami Seer
+Sorcerer's Broom
+Sorcerous Spyglass
+Sorrow's Path
+Soulblast
+Soulbright Flamekin
+Soul Channeling
+Soul Conduit
+Soulgorger Orgg
+Soul Kiss
+Soul Sculptor
+Soul Seizer
+Soul's Grace
+Soul's Might
+Soul Strings
+Space Beleren
+Spare from Evil
+Spark of Creativity
+Spawnbinder Mage
+Spawnbroker
+Spawning Pit
+Specter's Shriek
+Spectral Adversary
+Spectral Searchlight
+Spectral Shift
+Spellbinder
+Spell Contortion
+Spellshift
+Spellshock
+Spelltwine
+Spellweaver Helix
+Spellweaver Volute
+Sphinx's Decree
+Spike Rogue
+Spincrusher
+Spinning Darkness
+Spirit en-Kor
+Spiritual Asylum
+Spiritualize
+Spiteflame Witch
+Spitting Slug
+Spitting Spider
+Splintering Wind
+Split Decision
+Spoils of Evil
+Spoils of the Vault
+Spoils of War
+Springjack Pasture
+Springleaf Drum
+Spurred Wolverine
+Spy Network
+Squallmonger
+Squandered Resources
+Square Up
+Squealing Devil
+Squee
+Squee's Revenge
+Stain the Mind
+Stalking Yeti
+Standardize
+Standstill
+Starke of Rath
+Stasis Cell
+Static Orb
+Steadfastness
+Steal Enchantment
+Steel Golem
+Steeling Stance
+Stifle
+Stinging Licid
+Stir the Pride
+Stitcher's Apprentice
+Stonewise Fortifier
+Stonybrook Angler
+Storage Matrix
+Storm King's Thunder
+Stormwatch Eagle
+Strands of Night
+Strange Inversion
+Strategic Planning
+Stream of Consciousness
+Street Sweeper
+Strionic Resonator
+Strip Bare
+Stromgald Spy
+Strongarm Tactics
+Stronghold Assassin
+Stronghold Gambit
+Stunning Reversal
+Subdue
+Sudden Demise
+Sudden Disappearance
+Sudden Spoiling
+Sudden Substitution
+Suffer the Past
+Suicidal Charge
+Sulfur Vent
+Sultai Ascendancy
+Summary Dismissal
+Summoner's Egg
+Summoner's Pact
+Sunbird Effigy
+Sunbird Standard
+Sundial of the Infinite
+Sunglasses of Urza
+Sungrass Egg
+Sungrass Prairie
+Sunken Ruins
+Sunscape Apprentice
+Sunscorched Divide
+Suppress
+Surge of Strength
+Surprise Deployment
+Surreal Memoir
+Surveyor's Scope
+Survivor of the Unseen
+Svogthos, the Restless Tomb
+Svyelunite Temple
+Swan Song
+Swashbuckler Extraordinaire
+Sway of Illusion
+Sway of the Stars
+Swerve
+Swift Silence
+Sword of the Ages
+Sword of the Paruns
+Sworn Defender
+Sylvan Awakening
+Sylvan Library
+Sylvan Offering
+Sylvan Paradise
+Sylvan Safekeeper
+Sylvan Yeti
+Synod Artificer
+Synod Sanctum
+Synthesis Pod
+Synthetic Destiny
+Syr Elenora, the Discerning
+Tahngarth, First Mate
+Tahngarth's Rage
+Taigam, Sidisi's Hand
+Taigam's Scheming
+Tainted Adversary
+Tainted Aether
+Takara
+Take
+Takklemaggot
+Talon of Pain
+Tamiyo, Collector of Tales
+Tariff
+Taunt
+Taunting Challenge
+Tawnos's Coffin
+Teardrop Kami
+Tears of Rage
+Tectonic Break
+Teferi's Care
+Teferi's Veil
+Telekinetic Bonds
+Telepathy
+Telim'Tor's Edict
+Tel-Jilad Stylus
+Temporal Aperture
+Temporal Cascade
+Temporary Truce
+Tempting Licid
+Tendrils of Despair
+Tergrid's Shadow
+Terraformer
+Terrarion
+Terrifying Presence
+Testament of Faith
+Thalakos Mistfolk
+Thassa's Ire
+Thaumatog
+The Chain Veil
+The Enigma Jewel
+The Grand Tour
+Thelonite Monk
+The Prismatic Piper
+Thermal Flux
+Thermopod
+The Royal Scions
+Thespian's Stage
+Thieves' Auction
+Thing from the Deep
+Think Tank
+Thought Courier
+Thought Dissector
+Thought Gorger
+Thought Hemorrhage
+Thoughtlace
+Thought Lash
+Thoughtpicker Witch
+Thought Prison
+Thoughts of Ruin
+Thran Forge
+Thran Portal
+Thran Weaponry
+Three Wishes
+Throes of Chaos
+Through the Breach
+Throwing Knife
+Thrull Wizard
+Thunderheads
+Thundering Djinn
+Thwart
+Thwart the Enemy
+Tidal Bore
+Tidal Control
+Tidal Flats
+Tidal Visionary
+Tidal Warrior
+Tidal Wave
+Tideforce Elemental
+Tideshaper Mystic
+Tidewater Minion
+Time and Tide
+Timebender
+Timecrafting
+Time Elemental
+Timely Interference
+Timely Ward
+Time Stop
+Timid Drake
+Tinder Farm
+Tinder Wall
+Tin Street Market
+Tin-Wing Chimera
+Titans' Nest
+Titan's Presence
+Toils of Night and Day
+Tolarian Winds
+Tolaria West
+Tomb of Urami
+Tomb Robber
+Tomorrow, Azami's Familiar
+Tooth of Ramos
+Torchling
+Tornado
+Torpid Moloch
+Tortoise Formation
+Torture Chamber
+Tortured Existence
+Total War
+Touch of Darkness
+Touch of Vitae
+Tower Defense
+Toxic Deluge
+Trace of Abundance
+Trade Routes
+Trading Post
+Tragic Arrogance
+Trait Doctoring
+Tranquil Frillback
+Transluminant
+Transmogrifying Licid
+Transmutation
+Trash for Treasure
+Treacherous Link
+Treacherous Terrain
+Treacherous Vampire
+Treetop Defense
+Trench Gorger
+Trenching Steed
+Treva's Attendant
+Treva's Charm
+Triad of Fates
+Trial
+Triangle of War
+Triassic Egg
+Tribal Unity
+Trickbind
+Trickery Charm
+Trickster Mage
+Triton Tactics
+Tropical Storm
+Troubled Healer
+Truce
+Trusted Advisor
+Tsabo's Decree
+Tundra Kavu
+Tunnel Vision
+Turbulent Dreams
+Turnabout
+Turn to Frog
+Turtleshell Changeling
+Twiddle
+Twilight Mire
+Twinning Glass
+Twist Allegiance
+Twisted Image
+Twitch
+Ulvenwald Tracker
+Unbender Tine
+Uncage the Menagerie
+Undergrowth
+Undying Evil
+Undying Flames
+Unearthly Blizzard
+Unerring Sling
+Unfulfilled Desires
+Unlikely Alliance
+Unmask
+Unnatural Selection
+Unnerving Assault
+Unspeakable Symbol
+Unstable Footing
+Unstable Frontier
+Urborg
+Urborg Panther
+Urza's Avenger
+Urza's Bauble
+Utopia Sprawl
+Valleymaker
+Valor Made Real
+Vampire Lacerator
+Vampire Warlord
+Vampiric Tutor
+Vampirism
+Vanishing
+Vanish into Memory
+Varchild's Crusader
+Vassal's Duty
+Vault 21: House Gambit
+Vedalken Plotter
+Veil of Secrecy
+Venarian Glimmer
+Vengeful Archon
+Vengeful Dreams
+Venomous Breath
+Venser, the Sojourner
+Ventifact Bottle
+Verdant Eidolon
+Verdant Haven
+Verdant Rebirth
+Veteran's Voice
+Vexing Arcanix
+Vexing Shusher
+Viashino Sandswimmer
+Viashino Skeleton
+Vicious Betrayal
+Vigean Intuition
+Vigilant Martyr
+Vine Snare
+Viridescent Bog
+Viridian Acolyte
+Viscera Seer
+Vish Kal, Blood Arbiter
+Vision Charm
+Visions
+Visions of Duplicity
+Vitalize
+Vivisection
+Vizier of Tumbling Sands
+Vodalian Illusionist
+Vodalian Mystic
+Void
+Voidmage Apprentice
+Voidmage Prodigy
+Voidslime
+Volcano Hellion
+Volrath's Gardens
+Volrath's Stronghold
+Vona's Hunger
+Voodoo Doll
+Vortex Elemental
+Voyager Staff
+Waiting in the Weeds
+Wake of Destruction
+Waker of Waves
+Wake the Dead
+Wake to Slaughter
+Walking Desecration
+Walking Sponge
+Walk the Aeons
+Wall of Limbs
+Wall of Shadows
+Wall of Vapor
+Wall of Vipers
+Wandering Eye
+Wand of Denial
+War Barge
+Warbreak Trumpeter
+Ward of Lights
+Ward of Piety
+Warren Weirding
+Warrior en-Kor
+Warriors' Lesson
+Warrior's Oath
+Warrior's Stand
+War Tax
+Waterfront Bouncer
+Waterspout Elemental
+Wave Elemental
+Wave of Indifference
+Wave of Reckoning
+Wave of Terror
+Weapon Rack
+Weaver of Lies
+Weight of Spires
+Weird Harvest
+Welcome to the Fold
+Werewolf of Ancient Hunger
+Wheel of Potential
+Whetwheel
+Whim of Volrath
+Whims of the Fates
+Whipgrass Entangler
+Whipkeeper
+Whip Vine
+Whirlpool Warrior
+Whirlpool Whelm
+Whispering Madness
+Whiteout
+Wicked Reward
+Wild Dogs
+Wild Growth
+Wild Guess
+Wild Magic Surge
+Wild Ricochet
+Willbender
+Windfall
+Winding Canyons
+Winding Way
+Windshaper Planetar
+Winds of Change
+Wings of Hubris
+Wings of Velis Vel
+Winnow
+Winter's Chill
+Winter Sky
+Winter's Night
+Wirewood Channeler
+Wirewood Symbiote
+Wisedrafter's Will
+Wishmonger
+Wistful Thinking
+Witch Engine
+Wizard Mentor
+Wizard Replica
+Wizard's Rockets
+Wizards' School
+Wojek Apothecary
+Wojek Embermage
+Wojek Siren
+Wooded Bastion
+Wood Sage
+Word of Command
+Words of War
+Words of Waste
+Words of Wilding
+Words of Wind
+Words of Worship
+Worldgorger Dragon
+Worldpurge
+World Queller
+Wormfang Behemoth
+Wormfang Crab
+Worms of the Earth
+Worst Fears
+Worthy Cause
+Wrack with Madness
+Wrath of the Skies
+Wretched Bonemass
+Xantcha
+Xathrid Slyblade
+Xenagos, the Reveler
+Xenic Poltergeist
+Xenograft
+Yare
+Yurlok of Scorch Thrash
+Zealous Inquisitor
+Zedruu the Greathearted
+Zephyr Scribe
+Zhalfirin Crusader
+Zombie Boa
+Zombie Infestation
+Zombie Trailblazer
+Zur's Weirding
diff --git a/src/main/packaged-resources/cfg/banlist_custom.txt b/src/main/packaged-resources/cfg/banlist_custom.txt
@@ -0,0 +1,2 @@
+Engineered Plague
+Shared Triumph
+\ No newline at end of file
diff --git a/src/main/packaged-resources/cfg/banlist_ec.txt b/src/main/packaged-resources/cfg/banlist_ec.txt
@@ -0,0 +1,25 @@
+Amulet of Quoz
+Balance
+Brainstorm
+Bronze Tablet
+Channel
+Dark Ritual
+Demonic Consultation
+Flash
+Goblin Recruiter
+Imperial Seal
+Jeweled Bird
+Mana Crypt
+Mana Vault
+Memory Jar
+Mind’s Desire
+Mind Twist
+Rebirth
+Strip Mine
+Tempest Efreet
+Timmerian Fiends
+Tolarian Academy
+Vampiric Tutor
+Windfall
+Yawgmoth’s Bargain
+Yawgmoth’s Will
+\ No newline at end of file
diff --git a/src/main/packaged-resources/cfg/logback.xml b/src/main/packaged-resources/cfg/logback.xml
@@ -0,0 +1,38 @@
+<?xml version="1.0" encoding="UTF-8"?>
+<configuration>
+
+ <property name="LOG_PATTERN" value="%d{yyyy-MM-dd HH:mm:ss.SSS} %5p ${PID:- } --- [%t] %-40.40logger{39} : %m%n" />
+ <property name="LOG_CHARSET" value="${file.encoding:-UTF-8}" />
+
+ <property name="LOG_FILE" value="var/log/decks-downloader-app.log" />
+ <property name="LOGBACK_ROLLINGPOLICY_CLEAN_HISTORY_ON_START" value="true" />
+ <property name="LOGBACK_ROLLINGPOLICY_MAX_FILE_SIZE" value="50MB" />
+ <property name="LOGBACK_ROLLINGPOLICY_MAX_HISTORY" value="14" />
+
+ <appender name="CONSOLE" class="ch.qos.logback.core.ConsoleAppender">
+ <encoder>
+ <pattern>${LOG_PATTERN}</pattern>
+ <charset>${LOG_CHARSET}</charset>
+ </encoder>
+ </appender>
+
+ <appender name="FILE" class="ch.qos.logback.core.rolling.RollingFileAppender">
+ <encoder>
+ <pattern>${LOG_PATTERN}</pattern>
+ <charset>${LOG_CHARSET}</charset>
+ </encoder>
+ <file>${LOG_FILE}</file>
+ <rollingPolicy class="ch.qos.logback.core.rolling.SizeAndTimeBasedRollingPolicy">
+ <fileNamePattern>${LOGBACK_ROLLINGPOLICY_FILE_NAME_PATTERN:-${LOG_FILE}.%d{yyyy-MM-dd}.%i.gz}</fileNamePattern>
+ <cleanHistoryOnStart>${LOGBACK_ROLLINGPOLICY_CLEAN_HISTORY_ON_START:-false}</cleanHistoryOnStart>
+ <maxFileSize>${LOGBACK_ROLLINGPOLICY_MAX_FILE_SIZE:-10MB}</maxFileSize>
+ <totalSizeCap>${LOGBACK_ROLLINGPOLICY_TOTAL_SIZE_CAP:-0}</totalSizeCap>
+ <maxHistory>${LOGBACK_ROLLINGPOLICY_MAX_HISTORY:-7}</maxHistory>
+ </rollingPolicy>
+ </appender>
+
+ <root level="INFO">
+ <appender-ref ref="CONSOLE" />
+ <appender-ref ref="FILE" />
+ </root>
+</configuration>
diff --git a/src/test/java/fr/kevincorvisier/mtg/dd/model/DeckItemTest.java b/src/test/java/fr/kevincorvisier/mtg/dd/model/DeckItemTest.java
@@ -0,0 +1,32 @@
+package fr.kevincorvisier.mtg.dd.model;
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
+
+import java.time.LocalDate;
+
+import org.junit.jupiter.api.Test;
+
+class DeckItemTest
+{
+ @Test
+ void playerNameSanitized()
+ {
+ assertEquals("2024-05-04_LukasKovarik_Burn.txt", deck("2024-05-04", "Lukáš Kovařík", "Burn").getFilename());
+ assertEquals("2024-06-08_MichalRadecki_Goblins.txt", deck("2024-06-08", "Michał Radecki", "Goblins").getFilename());
+ assertEquals("2023-02-25_vagunemaakukaaru_SnakeTongueMiddleSchool.txt", deck("2023-02-25", "ヴァグネマーク カール", "Snake Tongue[Middle School]").getFilename());
+ assertEquals("2024-05-09_mukaitakashi_JeskaiBlink.txt", deck("2024-05-09", "ムカイ タカシ", "Jeskai Blink").getFilename());
+ assertEquals("2024-05-23_ishiyamakyouichi_KuldothaRed.txt", deck("2024-05-23", "イシヤマ キョウイチ", "Kuldotha Red").getFilename());
+ assertEquals("2024-05-19_kiharamanabu_MonoRed.txt", deck("2024-05-19", "キハラ マナブ", "Mono Red").getFilename());
+ }
+
+ @Test
+ void archetypeSanitized()
+ {
+ assertEquals("2024-05-13_TomoyaIshibashi_OathMiddleSchool.txt", deck("2024-05-13", "Tomoya Ishibashi", "Oath[Middle School]").getFilename());
+ }
+
+ private static DeckItem deck(final String date, final String player, final String archetype)
+ {
+ return new DeckItem(null, player, archetype, LocalDate.parse(date));
+ }
+}