mtg-decks-downloader

Tool to download Magic: The Gathering decklists from the Internet
git clone https://kevincorvisier.fr/git/mtg-decks-downloader.git
Log | Files | Refs | README

commit ff1629b47ba0f831cb52f34b146633ec961a474f
Author: Kevin Corvisier <git@kevincorvisier.fr>
Date:   Sat, 12 Oct 2024 18:11:40 +0900

Version 0.1.0
Diffstat:
A.gitignore | 92+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
AREADME.md | 7+++++++
Apom.xml | 97+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Asrc/assembly/assembly.xml | 44++++++++++++++++++++++++++++++++++++++++++++
Asrc/main/java/fr/kevincorvisier/mtg/dd/Cache.java | 90+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Asrc/main/java/fr/kevincorvisier/mtg/dd/Crawler.java | 84+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Asrc/main/java/fr/kevincorvisier/mtg/dd/DecklistConsumer.java | 16++++++++++++++++
Asrc/main/java/fr/kevincorvisier/mtg/dd/DecklistDownloader.java | 11+++++++++++
Asrc/main/java/fr/kevincorvisier/mtg/dd/DefaultDecklistConsumer.java | 159+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Asrc/main/java/fr/kevincorvisier/mtg/dd/Main.java | 111+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Asrc/main/java/fr/kevincorvisier/mtg/dd/hareruya/HareruyaDecklistDownloader.java | 72++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Asrc/main/java/fr/kevincorvisier/mtg/dd/model/DeckItem.java | 58++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Asrc/main/java/fr/kevincorvisier/mtg/dd/model/DeckItemComparator.java | 23+++++++++++++++++++++++
Asrc/main/java/fr/kevincorvisier/mtg/dd/model/DeckItemFactory.java | 25+++++++++++++++++++++++++
Asrc/main/java/fr/kevincorvisier/mtg/dd/mtgo/MagicOnlineFileReader.java | 49+++++++++++++++++++++++++++++++++++++++++++++++++
Asrc/main/java/fr/kevincorvisier/mtg/dd/mtgo/MagicOnlineFileWriter.java | 27+++++++++++++++++++++++++++
Asrc/main/java/fr/kevincorvisier/mtg/dd/tcdecks/TcdecksDecklistDownloader.java | 214+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Asrc/main/java/fr/kevincorvisier/mtg/dd/validation/DeckValidator.java | 40++++++++++++++++++++++++++++++++++++++++
Asrc/main/java/fr/kevincorvisier/mtg/dd/validation/DeckValidatorFactory.java | 65+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Asrc/main/packaged-resources/bin/decks-downloader.sh | 29+++++++++++++++++++++++++++++
Asrc/main/packaged-resources/cfg/application.properties | 52++++++++++++++++++++++++++++++++++++++++++++++++++++
Asrc/main/packaged-resources/cfg/banlist_ai.txt | 2544+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Asrc/main/packaged-resources/cfg/banlist_custom.txt | 3+++
Asrc/main/packaged-resources/cfg/banlist_ec.txt | 26++++++++++++++++++++++++++
Asrc/main/packaged-resources/cfg/logback.xml | 38++++++++++++++++++++++++++++++++++++++
Asrc/test/java/fr/kevincorvisier/mtg/dd/model/DeckItemTest.java | 32++++++++++++++++++++++++++++++++
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)); + } +}