commit 5ae01a25fc677a474b3b9bf708e1b22ae7d76931 parent f52631ce6b4276258e253f870067c248d7f0955b Author: Kevin Corvisier <git@kevincorvisier.fr> Date: Sun, 6 Oct 2024 20:43:24 +0900 Extract next generation logic from GeneticAlgorithm, add possibility to introduce newly generated decks from the card pool at each generation Diffstat:
26 files changed, 597 insertions(+), 624 deletions(-)
diff --git a/src/main/java/fr/kevincorvisier/mtg/gdb/ApplicationConfig.java b/src/main/java/fr/kevincorvisier/mtg/gdb/ApplicationConfig.java @@ -1,9 +1,21 @@ package fr.kevincorvisier.mtg.gdb; +import java.util.Date; +import java.util.GregorianCalendar; +import java.util.List; +import java.util.Set; + +import org.springframework.beans.factory.annotation.Value; +import org.springframework.context.annotation.Bean; import org.springframework.context.annotation.ComponentScan; import org.springframework.context.annotation.Configuration; import org.springframework.context.annotation.PropertySource; +import forge.game.GameFormat; +import forge.game.GameFormat.FormatSubType; +import forge.game.GameFormat.FormatType; +import forge.model.FModel; + @Configuration @PropertySource("application.properties") @PropertySource("crossover.properties") @@ -13,4 +25,24 @@ import org.springframework.context.annotation.PropertySource; @ComponentScan("fr.kevincorvisier.mtg.gdb") public class ApplicationConfig { + private static final Date MIDDLE_SCHOOL_EFFECTIVE_DATE = Date.from(new GregorianCalendar(2023, 5, 1).toInstant()); + private static final Iterable<String> MIDDLE_SCHOOL_SETS = Set.of("4ED", "ICE", "CHR", "HML", "ALL", "MIR", "VIS", "5ED", "WTH", "POR", "TMP", "STH", "EXO", + "P02", "USG", "ULG", "6ED", "UDS", "PTK", "S99", "MMQ", "NMS", "PCY", "S00", "INV", "PLS", "7ED", "APC", "ODY", "TOR", "JUD", "ONS", "LGN", "SCG", + "ATH", "BRB", "BTD", "DKM"); + private static final List<String> MIDDLE_SCHOOL_BANNED_CARDS = List.of("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"); + private static final List<String> MIDDLE_SCHOOL_ADDITIONAL_CARDS = List.of("Arena", "Sewers of Estark", "Nalathni Dragon", "Giant Badger", + "Windseeker Centaur"); + + @Bean + public GameFormat format(@Value("${format}") final String formatName) + { + if ("MiddleSchool".equals(formatName)) + return new GameFormat("MiddleSchool", MIDDLE_SCHOOL_EFFECTIVE_DATE, MIDDLE_SCHOOL_SETS, MIDDLE_SCHOOL_BANNED_CARDS, null, false, + MIDDLE_SCHOOL_ADDITIONAL_CARDS, null, 0, FormatType.CUSTOM, FormatSubType.CUSTOM); + else + return FModel.getFormats().get(formatName); + } } diff --git a/src/main/java/fr/kevincorvisier/mtg/gdb/Main.java b/src/main/java/fr/kevincorvisier/mtg/gdb/Main.java @@ -7,11 +7,6 @@ import java.io.IOException; import java.net.URISyntaxException; import java.net.URL; import java.nio.file.Files; -import java.util.Date; -import java.util.GregorianCalendar; -import java.util.List; -import java.util.Properties; -import java.util.Set; import org.apache.commons.compress.archivers.tar.TarArchiveEntry; import org.apache.commons.compress.archivers.tar.TarArchiveInputStream; @@ -22,34 +17,17 @@ import org.springframework.context.annotation.AnnotationConfigApplicationContext import com.google.common.io.ByteStreams; import dev.dirs.ProjectDirectories; -import forge.game.GameFormat; -import forge.game.GameFormat.FormatSubType; -import forge.game.GameFormat.FormatType; import forge.gui.GuiBase; import forge.localinstance.properties.ForgeConstants; import forge.localinstance.properties.ForgePreferences; import forge.model.FModel; import forge.util.FileUtil; import fr.kevincorvisier.mtg.gdb.ai.GeneticAlgorithm; -import fr.kevincorvisier.mtg.gdb.population.PopulationFactory; -import fr.kevincorvisier.mtg.gdb.utils.ForgeUtils; -import fr.kevincorvisier.mtg.gdb.utils.PropertiesUtils; import lombok.extern.slf4j.Slf4j; @Slf4j public class Main { - private static final Date MIDDLE_SCHOOL_EFFECTIVE_DATE = Date.from(new GregorianCalendar(2023, 5, 1).toInstant()); - private static final Iterable<String> MIDDLE_SCHOOL_SETS = Set.of("4ED", "ICE", "CHR", "HML", "ALL", "MIR", "VIS", "5ED", "WTH", "POR", "TMP", "STH", "EXO", - "P02", "USG", "ULG", "6ED", "UDS", "PTK", "S99", "MMQ", "NMS", "PCY", "S00", "INV", "PLS", "7ED", "APC", "ODY", "TOR", "JUD", "ONS", "LGN", "SCG", - "ATH", "BRB", "BTD", "DKM"); - private static final List<String> MIDDLE_SCHOOL_BANNED_CARDS = List.of("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"); - private static final List<String> MIDDLE_SCHOOL_ADDITIONAL_CARDS = List.of("Arena", "Sewers of Estark", "Nalathni Dragon", "Giant Badger", - "Windseeker Centaur"); - private static final GuiFake GUI_INTERFACE = new GuiFake() { @Override @@ -118,17 +96,6 @@ public class Main return null; }); - final Properties properties = new Properties(); - properties.load(PopulationFactory.class.getClassLoader().getResourceAsStream("application.properties")); - - final String formatName = PropertiesUtils.getString(properties, "format"); - - if ("MiddleSchool".equals(formatName)) - ForgeUtils.setGameFormat(new GameFormat("MiddleSchool", MIDDLE_SCHOOL_EFFECTIVE_DATE, MIDDLE_SCHOOL_SETS, MIDDLE_SCHOOL_BANNED_CARDS, null, - false, MIDDLE_SCHOOL_ADDITIONAL_CARDS, null, 0, FormatType.CUSTOM, FormatSubType.CUSTOM)); - else - ForgeUtils.setGameFormat(FModel.getFormats().get(formatName)); - try (final AnnotationConfigApplicationContext context = new AnnotationConfigApplicationContext(ApplicationConfig.class)) { final GeneticAlgorithm ga = context.getBean(GeneticAlgorithm.class); // 2 diff --git a/src/main/java/fr/kevincorvisier/mtg/gdb/Reloadable.java b/src/main/java/fr/kevincorvisier/mtg/gdb/Reloadable.java @@ -1,6 +1,8 @@ package fr.kevincorvisier.mtg.gdb; +import java.io.IOException; + public interface Reloadable { - void reload(); + void reload() throws IOException; } diff --git a/src/main/java/fr/kevincorvisier/mtg/gdb/ai/CardPoolService.java b/src/main/java/fr/kevincorvisier/mtg/gdb/ai/CardPoolService.java @@ -0,0 +1,78 @@ +package fr.kevincorvisier.mtg.gdb.ai; + +import java.io.BufferedReader; +import java.io.File; +import java.io.FileReader; +import java.io.IOException; +import java.util.Collection; +import java.util.HashSet; + +import org.springframework.beans.factory.annotation.Value; +import org.springframework.stereotype.Service; + +import forge.deck.Deck; +import forge.deck.DeckgenUtil; +import forge.game.GameFormat; +import forge.item.PaperCard; +import forge.model.FModel; +import forge.util.Aggregates; +import fr.kevincorvisier.mtg.gdb.Reloadable; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; + +@Slf4j +@Service +@RequiredArgsConstructor +public class CardPoolService implements Reloadable +{ + @Value("${card-pool}") + private final File cardPool; + + private final GameFormat format; + + private final Collection<String> cardNames = new HashSet<>(); + + public String getRandomCard() + { + return Aggregates.random(cardNames); + } + + public Deck getRandomDeck() + { + return DeckgenUtil.getRandomColorDeck(card -> cardNames.contains(card.getName()), true); + } + + @Override + public void reload() throws IOException + { + cardNames.clear(); + + try (BufferedReader reader = new BufferedReader(new FileReader(cardPool))) + { + String line; + while ((line = reader.readLine()) != null) + { + line = line.trim(); + + final PaperCard card = FModel.getMagicDb().getCommonCards().getCard(line); + if (card == null) + { + log.warn("Unknown card {}, maybe a typo ?", line); + continue; + } + + if (card.getRules().getAiHints().getRemAIDecks()) + { + log.warn("{} is marked as non-playable by AI", card.getName()); + } + if (!format.getFilterRules().apply(card)) + { + log.warn("{} is not legal in the format, skipping...", card.getName()); + continue; + } + + cardNames.add(card.getName()); + } + } + } +} diff --git a/src/main/java/fr/kevincorvisier/mtg/gdb/ai/ChildValidator.java b/src/main/java/fr/kevincorvisier/mtg/gdb/ai/ChildValidator.java @@ -1,93 +0,0 @@ -package fr.kevincorvisier.mtg.gdb.ai; - -import java.util.Collection; -import java.util.HashSet; - -import javax.annotation.Nullable; - -import org.apache.commons.lang3.StringUtils; -import org.springframework.beans.factory.annotation.Value; -import org.springframework.stereotype.Service; - -import forge.deck.Deck; -import fr.kevincorvisier.mtg.gdb.utils.ForgeUtils; -import lombok.Data; -import lombok.RequiredArgsConstructor; - -@Service -@RequiredArgsConstructor -public class ChildValidator -{ - private final Collection<Condition> conditions = new HashSet<>(); - @Value("${validation.child.max-unique-cards}") - private final int maxUniqueCards; - - @Value("${validation.child.conditions}") - public void setConditions(final String conditions) - { - if (conditions != null) - { - for (final String condition : StringUtils.split(conditions, ';')) - { - final String[] words = StringUtils.split(condition.trim(), ' '); - - if (words.length < 3) - throw new RuntimeException("Invalid validation condition: " + condition); - - final String cardName = StringUtils.join(words, ' ', 0, words.length - 2); - final String op = words[words.length - 2]; - final int count = Integer.parseInt(words[words.length - 1]); - - if (!StringUtils.equalsAny(op, "=", "<=")) - throw new RuntimeException("Invalid validation condition: " + condition); - - this.conditions.add(new Condition(cardName, op, count)); - } - } - } - - @Nullable - public String validate(final Deck deck) - { - String conformanceProblem = ForgeUtils.getDeckConformanceProblem(deck); - if (conformanceProblem != null) - return "not valid in the choosen format: " + conformanceProblem; - - final int uniqueCardsCount = deck.getMain().countDistinct(); - if (uniqueCardsCount > maxUniqueCards) - return uniqueCardsCount + " unique cards, more than " + maxUniqueCards; - - for (final Condition condition : conditions) - { - conformanceProblem = condition.validate(deck); - if (conformanceProblem != null) - return conformanceProblem; - } - - return null; - } - - @Data - private class Condition - { - private final String cardName; - private final String op; - private final int count; - - @Nullable - public String validate(final Deck deck) - { - final int actualCount = deck.getMain().countByName(cardName); - - switch (op) - { - case "=": - return actualCount == count ? null : (cardName + ": expected: = " + count + ", actual: " + actualCount); - case "<=": - return actualCount <= count ? null : (cardName + ": expected: <= " + count + ", actual: " + actualCount); - default: - throw new RuntimeException("Invalid operator: " + op); - } - } - } -} diff --git a/src/main/java/fr/kevincorvisier/mtg/gdb/ai/GeneticAlgorithm.java b/src/main/java/fr/kevincorvisier/mtg/gdb/ai/GeneticAlgorithm.java @@ -2,8 +2,6 @@ package fr.kevincorvisier.mtg.gdb.ai; import java.io.File; import java.io.IOException; -import java.util.Collection; -import java.util.HashSet; import java.util.List; import java.util.Random; @@ -15,10 +13,8 @@ import fr.kevincorvisier.mtg.gdb.Reloadable; import fr.kevincorvisier.mtg.gdb.evaluation.Evaluation; import fr.kevincorvisier.mtg.gdb.evaluation.stop.EvaluationStopCondition; import fr.kevincorvisier.mtg.gdb.operators.crossover.Crossover; -import fr.kevincorvisier.mtg.gdb.operators.mutation.Mutation; -import fr.kevincorvisier.mtg.gdb.operators.selection.HybridSelection; -import fr.kevincorvisier.mtg.gdb.operators.selection.Selection; import fr.kevincorvisier.mtg.gdb.population.Individual; +import fr.kevincorvisier.mtg.gdb.population.NextGenerationService; import fr.kevincorvisier.mtg.gdb.population.Population; import fr.kevincorvisier.mtg.gdb.population.PopulationFactory; import lombok.RequiredArgsConstructor; @@ -29,18 +25,16 @@ import lombok.extern.slf4j.Slf4j; @RequiredArgsConstructor public class GeneticAlgorithm implements Reloadable { - private final Collection<Long> alreadyGenerated = new HashSet<>(); protected final Random rnd = new Random(); // Operators private final Crossover crossover; - private final Mutation mutation; - private final Selection selection = new HybridSelection(); + private final CardPoolService cardPool; private final List<Evaluation> evaluators; - private final ChildValidator childValidator; private final EvaluationStopCondition evaluationStoppingCondition; private final PopulationFactory populationFactory; + private final NextGenerationService nextGenerationService; private Population population = null; @@ -96,7 +90,7 @@ public class GeneticAlgorithm implements Reloadable } } - public void run() + public void run() throws IOException { do { @@ -106,7 +100,7 @@ public class GeneticAlgorithm implements Reloadable if (generationCount == 0) population = populationFactory.create(); else - toNextGeneration(); + population = nextGenerationService.toNextGeneration(population); evaluationStoppingCondition.reset(); while (evaluationStoppingCondition.keepEvaluating()) @@ -134,73 +128,16 @@ public class GeneticAlgorithm implements Reloadable } while (shouldContinue()); } - private void toNextGeneration() - { - final Population next = population.createNextGeneration(); - - // Avoid being in an infinite loop if no new decks can be found - int loopCount = 0; - final int loopSoftLimit = 100 * next.getLimit(); - final int loopHardLimit = 1000 * next.getLimit(); - - while (next.getSize() < next.getLimit()) - { - loopCount++; - if (loopCount == loopSoftLimit) - { - log.error("toNextGeneration: loop soft limit reached, reseting alreadyGenerated"); - alreadyGenerated.clear(); - population.stream().forEach(i -> alreadyGenerated.add(i.getFingerprint())); - next.stream().forEach(i -> alreadyGenerated.add(i.getFingerprint())); - } - else if (loopCount == loopHardLimit) - { - log.error("toNextGeneration: loop hard limit reached, exiting"); - System.exit(0); - } - - // Selection - final List<Individual> parents = selection.select(population); - - // Crossover - for (final Individual child : crossover.crossover(parents.get(0), parents.get(1))) - { - if (next.getSize() >= next.getLimit()) - continue; // Ensure second child will not breach the population limit - - // Mutation - mutation.mutate(child); - - final String conformanceProblem = childValidator.validate(child.getDeck()); - if (conformanceProblem != null) - { - log.warn("Ignoring child: {}", conformanceProblem); - continue; - } - - final long fingerprint = child.getFingerprint(); - if (alreadyGenerated.contains(fingerprint)) - { - log.warn("Ignoring child: already generated"); - continue; - } - - alreadyGenerated.add(fingerprint); - next.addIndividual(child); - } - } - - population = next; - } - /** * Reload decks/card pool files to account for changes + * + * @throws IOException */ @Override - public void reload() + public void reload() throws IOException { for (final Evaluation evaluator : evaluators) evaluator.reload(); - mutation.reload(); + cardPool.reload(); } } diff --git a/src/main/java/fr/kevincorvisier/mtg/gdb/evaluation/GameSimulationResult.java b/src/main/java/fr/kevincorvisier/mtg/gdb/evaluation/GameSimulationResult.java @@ -9,7 +9,7 @@ public class GameSimulationResult private final int turnNumber; private final long durationMs; - public static enum GameSimulationResultValue + public enum GameSimulationResultValue { WIN, LOSS, diff --git a/src/main/java/fr/kevincorvisier/mtg/gdb/evaluation/GameSimulator.java b/src/main/java/fr/kevincorvisier/mtg/gdb/evaluation/GameSimulator.java @@ -57,9 +57,7 @@ public class GameSimulator try { - TimeLimitedCodeBlock.runWithTimeout(() -> { - match.startGame(game); - }, gameTimeoutSeconds, TimeUnit.SECONDS); + TimeLimitedCodeBlock.runWithTimeout(() -> match.startGame(game), gameTimeoutSeconds, TimeUnit.SECONDS); } catch (final Exception | StackOverflowError e) { diff --git a/src/main/java/fr/kevincorvisier/mtg/gdb/evaluation/WinRatioEvaluation.java b/src/main/java/fr/kevincorvisier/mtg/gdb/evaluation/WinRatioEvaluation.java @@ -17,7 +17,7 @@ import forge.game.player.RegisteredPlayer; import forge.player.GamePlayerUtil; import fr.kevincorvisier.mtg.gdb.population.Individual; import fr.kevincorvisier.mtg.gdb.population.Population; -import fr.kevincorvisier.mtg.gdb.utils.ForgeUtils; +import fr.kevincorvisier.mtg.gdb.utils.MagicOnlineDeckLoader; import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; @@ -29,6 +29,8 @@ public class WinRatioEvaluation implements Evaluation { private Collection<Deck> opponents = Collections.emptySet(); + private final MagicOnlineDeckLoader forgeUtils; + @Value("${evaluation.win-ratio.opponents-directory}") private final File opponentsDirectory; @Value("${evaluation.win-ratio.initial.min-games}") @@ -49,7 +51,7 @@ public class WinRatioEvaluation implements Evaluation { try { - this.opponents = ForgeUtils.loadDecks(opponentsDirectory); + this.opponents = forgeUtils.loadDecks(opponentsDirectory); } catch (final Exception e) { diff --git a/src/main/java/fr/kevincorvisier/mtg/gdb/operators/mutation/Mutation.java b/src/main/java/fr/kevincorvisier/mtg/gdb/operators/mutation/Mutation.java @@ -1,9 +1,8 @@ package fr.kevincorvisier.mtg.gdb.operators.mutation; -import fr.kevincorvisier.mtg.gdb.Reloadable; import fr.kevincorvisier.mtg.gdb.population.Individual; -public interface Mutation extends Reloadable +public interface Mutation { void mutate(final Individual individual); } diff --git a/src/main/java/fr/kevincorvisier/mtg/gdb/operators/mutation/RandomCardMutation.java b/src/main/java/fr/kevincorvisier/mtg/gdb/operators/mutation/RandomCardMutation.java @@ -1,21 +1,14 @@ package fr.kevincorvisier.mtg.gdb.operators.mutation; -import java.io.File; -import java.util.Collection; -import java.util.Collections; -import java.util.HashSet; import java.util.Random; -import java.util.Set; -import org.springframework.beans.factory.annotation.Value; import org.springframework.context.annotation.Conditional; import org.springframework.stereotype.Service; import forge.deck.CardPool; -import forge.item.PaperCard; import forge.util.Aggregates; +import fr.kevincorvisier.mtg.gdb.ai.CardPoolService; import fr.kevincorvisier.mtg.gdb.population.Individual; -import fr.kevincorvisier.mtg.gdb.utils.ForgeUtils; import lombok.RequiredArgsConstructor; @Service @@ -25,19 +18,7 @@ import lombok.RequiredArgsConstructor; { private final Random rnd = new Random(); - @Value("${mutation.random.card-pool}") - private final File cardPoolFile; - - private Collection<String> cards; - - @Override - public void reload() - { - final Set<String> newCards = new HashSet<>(); - for (final PaperCard card : ForgeUtils.loadCardsList(cardPoolFile)) - newCards.add(card.getName()); - this.cards = Collections.unmodifiableSet(newCards); - } + private final CardPoolService cardPool; @Override public void mutate(final Individual individual) @@ -51,7 +32,7 @@ import lombok.RequiredArgsConstructor; while ((toAddCount = 60 - main.countAll()) > 0) { - final String card = Aggregates.random(cards); + final String card = cardPool.getRandomCard(); if (isBasicLand(card)) { diff --git a/src/main/java/fr/kevincorvisier/mtg/gdb/operators/mutation/SwapSideboardMutation.java b/src/main/java/fr/kevincorvisier/mtg/gdb/operators/mutation/SwapSideboardMutation.java @@ -21,12 +21,6 @@ import lombok.RequiredArgsConstructor; private final Random rnd = new Random(); @Override - public void reload() - { - // Nothing to be done - } - - @Override public void mutate(final Individual individual) { int nbCardsToSwap = rnd.nextInt(8) + 1; diff --git a/src/main/java/fr/kevincorvisier/mtg/gdb/population/DefaultPopulation.java b/src/main/java/fr/kevincorvisier/mtg/gdb/population/DefaultPopulation.java @@ -0,0 +1,47 @@ +package fr.kevincorvisier.mtg.gdb.population; + +import java.util.ArrayList; +import java.util.List; +import java.util.stream.Stream; + +/* package */ class DefaultPopulation implements Population +{ + private final List<Individual> individuals = new ArrayList<>(); + + public void addIndividual(final Individual indidual) + { + individuals.add(indidual); + } + + @Override + public Individual get(final int i) + { + return individuals.get(i); + } + + @Override + public int getSize() + { + return individuals.size(); + } + + @Override + public void onFitnessUpdated() + { + // Sort individuals to have the highest fitness first + individuals.sort((a, b) -> -Double.compare(a.getFitness(), b.getFitness())); + } + + @Override + public Stream<Individual> parallelStream() + { + return individuals.parallelStream(); + } + + @Override + public Stream<Individual> stream() + { + return individuals.stream(); + } + +} diff --git a/src/main/java/fr/kevincorvisier/mtg/gdb/population/ElitistPopulation.java b/src/main/java/fr/kevincorvisier/mtg/gdb/population/ElitistPopulation.java @@ -1,83 +0,0 @@ -package fr.kevincorvisier.mtg.gdb.population; - -import java.util.ArrayList; -import java.util.List; -import java.util.Random; -import java.util.stream.Stream; - -import lombok.AccessLevel; -import lombok.Data; -import lombok.RequiredArgsConstructor; - -@Data -@RequiredArgsConstructor(access = AccessLevel.PACKAGE) -public class ElitistPopulation implements Population -{ - private final Random rnd = new Random(); - private final List<Individual> individuals = new ArrayList<>(); - - private final double elitismRate; - private final int limit; - - @Override - public void onFitnessUpdated() - { - // Sort individuals to have the highest fitness first - individuals.sort((a, b) -> -Double.compare(a.getFitness(), b.getFitness())); - } - - @Override - public Population createNextGeneration() - { - final Population nextGeneration = new ElitistPopulation(elitismRate, limit); - - // Elitism: copy the best individuals to the next generation - - final int eliteCount = Math.min(individuals.size(), (int) Math.ceil(limit * elitismRate)); - - for (int i = 0; i != eliteCount; i++) - nextGeneration.addIndividual(individuals.get(i)); - - return nextGeneration; - } - - @Override - public int getSize() - { - return individuals.size(); - } - - /** - * For initialization of first generation from disk, bypass limit check - */ - /* package */ void forceIndividual(final Individual indidual) - { - individuals.add(indidual); - } - - @Override - public void addIndividual(final Individual indidual) - { - if (individuals.size() >= limit) - throw new RuntimeException("Cannot add individual to population: limit reached"); - individuals.add(indidual); - } - - @Override - public Stream<Individual> stream() - { - return individuals.stream(); - } - - @Override - public Individual get(final int i) - { - return individuals.get(i); - } - - @Override - public Stream<Individual> parallelStream() - { - return individuals.parallelStream(); - } -} diff --git a/src/main/java/fr/kevincorvisier/mtg/gdb/population/Individual.java b/src/main/java/fr/kevincorvisier/mtg/gdb/population/Individual.java @@ -34,7 +34,7 @@ public class Individual public long getFingerprint() { - return deck.getMain().toString().hashCode() << 32 | deck.getOrCreate(DeckSection.Sideboard).toString().hashCode(); + return ((long) deck.getMain().toString().hashCode()) << 32 | deck.getOrCreate(DeckSection.Sideboard).toString().hashCode(); } /** diff --git a/src/main/java/fr/kevincorvisier/mtg/gdb/population/NextGenerationService.java b/src/main/java/fr/kevincorvisier/mtg/gdb/population/NextGenerationService.java @@ -0,0 +1,98 @@ +package fr.kevincorvisier.mtg.gdb.population; + +import java.util.Collection; +import java.util.HashSet; +import java.util.List; + +import org.springframework.beans.factory.annotation.Value; +import org.springframework.stereotype.Service; + +import forge.deck.Deck; +import fr.kevincorvisier.mtg.gdb.ai.CardPoolService; +import fr.kevincorvisier.mtg.gdb.operators.crossover.Crossover; +import fr.kevincorvisier.mtg.gdb.operators.mutation.Mutation; +import fr.kevincorvisier.mtg.gdb.operators.selection.HybridSelection; +import fr.kevincorvisier.mtg.gdb.operators.selection.Selection; +import fr.kevincorvisier.mtg.gdb.validation.IndividualValidationService; +import lombok.RequiredArgsConstructor; + +/** + * From a population (after evaluation), create a new population for the next generation + */ +@Service +@RequiredArgsConstructor +public class NextGenerationService +{ + @Value("${population.elitism-rate}") + private final float elitismRate; + @Value("${population.new-rate}") + private final float newRate; + @Value("${population.maximum-size}") + private final int maximumSize; + + private final Crossover crossover; + private final Mutation mutation; + + private final Selection selection = new HybridSelection(); + private final CardPoolService cardPool; + private final IndividualValidationService validationService; + + public Population toNextGeneration(final Population population) + { + final DefaultPopulation next = new DefaultPopulation(); + addElitismShare(population, next); + addNewShare(next); + addCrossoverMutationShare(population, next); + return next; + } + + /** + * Elitism: copy the best individuals to the next generation + */ + private void addElitismShare(final Population population, final DefaultPopulation next) + { + final int eliteCount = Math.min(population.getSize(), Math.round(maximumSize * elitismRate)); + + for (int i = 0; i != eliteCount; i++) + next.addIndividual(population.get(i)); + } + + private void addNewShare(final DefaultPopulation next) + { + final int newCount = Math.round(maximumSize * newRate); + final int targetSize = Math.min(maximumSize, next.getSize() + newCount); + + while (next.getSize() < targetSize) + { + final Deck deck = cardPool.getRandomDeck(); + final Individual child = new Individual("g0_new_" + next.getSize(), deck); + + if (validationService.validate(child, next)) + next.addIndividual(child); + } + } + + private void addCrossoverMutationShare(final Population population, final DefaultPopulation next) + { + final Collection<Individual> result = new HashSet<>(); + + while (next.getSize() < maximumSize) + { + // Selection + final List<Individual> parents = selection.select(population); + + // Crossover + for (final Individual child : crossover.crossover(parents.get(0), parents.get(1))) + { + if (result.size() >= maximumSize) + continue; // Ensure second child will not breach the population limit + + // Mutation + mutation.mutate(child); + + if (validationService.validate(child, next)) + next.addIndividual(child); + } + } + } +} diff --git a/src/main/java/fr/kevincorvisier/mtg/gdb/population/Population.java b/src/main/java/fr/kevincorvisier/mtg/gdb/population/Population.java @@ -2,34 +2,21 @@ package fr.kevincorvisier.mtg.gdb.population; import java.util.stream.Stream; +/** + * A population is a unmodifiable list of individuals that can be reordered based on their fitness + */ public interface Population { + Individual get(int i); + /** * Current population size */ int getSize(); - /** - * Maximum population size - */ - int getLimit(); - void onFitnessUpdated(); - void addIndividual(final Individual indidual); - - /** - * Create a new population for the next generation - */ - Population createNextGeneration(); - - /* - * Remove ? - */ + Stream<Individual> parallelStream(); Stream<Individual> stream(); - - Individual get(int i); - - Stream<Individual> parallelStream(); } diff --git a/src/main/java/fr/kevincorvisier/mtg/gdb/population/PopulationFactory.java b/src/main/java/fr/kevincorvisier/mtg/gdb/population/PopulationFactory.java @@ -1,18 +1,14 @@ package fr.kevincorvisier.mtg.gdb.population; import java.io.File; -import java.util.Collection; -import java.util.HashMap; -import java.util.HashSet; -import java.util.Map; import org.springframework.beans.factory.annotation.Value; import org.springframework.stereotype.Service; import forge.deck.Deck; -import forge.deck.DeckgenUtil; -import forge.item.PaperCard; -import fr.kevincorvisier.mtg.gdb.utils.ForgeUtils; +import fr.kevincorvisier.mtg.gdb.ai.CardPoolService; +import fr.kevincorvisier.mtg.gdb.utils.MagicOnlineDeckLoader; +import fr.kevincorvisier.mtg.gdb.validation.IndividualValidationService; import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; @@ -21,36 +17,30 @@ import lombok.extern.slf4j.Slf4j; @RequiredArgsConstructor public class PopulationFactory { - @Value("${population.elitism-rate}") - private final double elitismRate; - @Value("${population.maximum-size}") - private final int maximumSize; + private final MagicOnlineDeckLoader forgeUtils; + private final CardPoolService cardPool; + private final IndividualValidationService validationService; + @Value("${population.initialization.disk.directories}") private final File[] directories; @Value("${population.initialization.type}") private final String initializationType; - @Value("${population.initialization.random.card-pool}") - private final File cardPool; + @Value("${population.maximum-size}") + private final int maximumSize; public Population create() { try { - final ElitistPopulation population = new ElitistPopulation(elitismRate, maximumSize); - switch (initializationType) { case "DISK": - loadFromDisk(population); - break; + return loadFromDisk(); case "RANDOM": - initializeWithRandomIndividuals(population); - break; + return initializeWithRandomIndividuals(); default: throw new RuntimeException("Invalid initialization type: " + initializationType + ", expected: DISK, RANDOM"); } - - return population; } catch (final Exception e) { @@ -63,36 +53,36 @@ public class PopulationFactory /** * Initialize the population by loading decks from disk */ - private void loadFromDisk(final ElitistPopulation population) + private Population loadFromDisk() { - final Map<Long, String> loaded = new HashMap<>(); + final DefaultPopulation population = new DefaultPopulation(); for (final File dir : directories) { - for (final Deck deck : ForgeUtils.loadDecks(dir)) + for (final Deck deck : forgeUtils.loadDecks(dir)) { final Individual individual = new Individual(deck.getName(), deck); - final String duplicate = loaded.putIfAbsent(individual.getFingerprint(), individual.getName()); - - if (duplicate != null) - log.warn("Duplicate in initial population: {} and {}", individual.getName(), duplicate); + if (validationService.validate(individual, population)) + population.addIndividual(individual); else - population.forceIndividual(individual); + log.warn("Deck from initial population failed validation: ", deck.getName(), deck); } } + + return population; } - private void initializeWithRandomIndividuals(final Population population) + private Population initializeWithRandomIndividuals() { - final Collection<String> cardNames = new HashSet<>(); - for (final PaperCard card : ForgeUtils.loadCardsList(cardPool)) - cardNames.add(card.getName()); + final DefaultPopulation population = new DefaultPopulation(); - int i = 0; - while (population.getSize() < population.getLimit()) + while (population.getSize() < maximumSize) { - final Deck deck = DeckgenUtil.getRandomColorDeck(card -> cardNames.contains(card.getName()), true); - population.addIndividual(new Individual("g0_" + i++, deck)); + final Individual individual = new Individual("g0_" + population.getSize(), cardPool.getRandomDeck()); + if (validationService.validate(individual, population)) + population.addIndividual(individual); } + + return population; } } diff --git a/src/main/java/fr/kevincorvisier/mtg/gdb/utils/DeckUtils.java b/src/main/java/fr/kevincorvisier/mtg/gdb/utils/DeckUtils.java @@ -1,33 +0,0 @@ -package fr.kevincorvisier.mtg.gdb.utils; - -import forge.card.MagicColor; -import forge.deck.Deck; -import lombok.AccessLevel; -import lombok.NoArgsConstructor; - -@NoArgsConstructor(access = AccessLevel.PRIVATE) // static class -public class DeckUtils -{ - public static boolean sameColors(final Deck deck1, final Deck deck2) - { - return getColorString(deck1) == getColorString(deck2); - } - - private static byte getColorString(final Deck deck) - { - byte colors = 0; - - if (deck.getMain().countByName("Plains") != 0) - colors |= MagicColor.WHITE; - if (deck.getMain().countByName("Island") != 0) - colors |= MagicColor.BLUE; - if (deck.getMain().countByName("Swamp") != 0) - colors |= MagicColor.BLACK; - if (deck.getMain().countByName("Mountain") != 0) - colors |= MagicColor.RED; - if (deck.getMain().countByName("Forest") != 0) - colors |= MagicColor.GREEN; - - return colors; - } -} diff --git a/src/main/java/fr/kevincorvisier/mtg/gdb/utils/ForgeUtils.java b/src/main/java/fr/kevincorvisier/mtg/gdb/utils/ForgeUtils.java @@ -1,188 +0,0 @@ -package fr.kevincorvisier.mtg.gdb.utils; - -import java.io.BufferedReader; -import java.io.File; -import java.io.FileReader; -import java.util.ArrayList; -import java.util.Arrays; -import java.util.Collection; -import java.util.Collections; -import java.util.HashSet; -import java.util.stream.Collectors; - -import org.apache.commons.lang3.StringUtils; - -import forge.deck.CardPool; -import forge.deck.Deck; -import forge.deck.Deck.UnplayableAICards; -import forge.deck.DeckFormat; -import forge.deck.DeckSection; -import forge.game.GameFormat; -import forge.item.PaperCard; -import forge.model.FModel; -import lombok.AccessLevel; -import lombok.NoArgsConstructor; -import lombok.extern.slf4j.Slf4j; - -@Slf4j -@NoArgsConstructor(access = AccessLevel.PRIVATE) // static class -public class ForgeUtils -{ - private static GameFormat format; - private static final DeckFormat DECK_FORMAT = DeckFormat.Constructed; - - public static void setGameFormat(final GameFormat format) - { - ForgeUtils.format = format; - } - - /* - * Decks - */ - - public static Collection<Deck> loadDecks(final File from) - { - if (!from.exists()) - { - log.error("{}: no file or directory", from); - System.exit(0); - } - - if (from.isDirectory()) - return loadDecksFromDirectory(from); - else if (from.isFile()) - { - final Deck deck = loadDeckFromFile(from); - return deck != null ? Arrays.asList(deck) : Collections.emptyList(); - } - else - { - log.error("{}: not a normal file or directory", from); - System.exit(0); - return null; // To please the compiler - } - } - - private static Collection<Deck> loadDecksFromDirectory(final File directory) - { - final File[] deckFiles = directory.listFiles((final File dir, final String name) -> { - return StringUtils.endsWith(name, ".txt"); - }); - - final Collection<Deck> decks = new ArrayList<>(); - for (final File file : deckFiles) - { - final Deck deck = loadDeckFromFile(file); - if (deck != null) - decks.add(deck); - } - return decks; - } - - private static Deck loadDeckFromFile(final File file) - { - if (!file.getName().endsWith(".txt")) - { - log.warn("Not a valid deck file: {}", file); - return null; - } - final Deck deck = new Deck(file.getName().substring(0, file.getName().length() - 4)); - CardPool currentSection = deck.getMain(); - - try (BufferedReader reader = new BufferedReader(new FileReader(file))) - { - String line; - while ((line = reader.readLine()) != null) - { - line = line.trim(); - if (line.isEmpty()) - continue; - if ("Sideboard".equals(line)) - { - currentSection = deck.getOrCreate(DeckSection.Sideboard); - continue; - } - - final int idx = line.indexOf(' '); - final int amount = Integer.parseInt(line.substring(0, idx)); - String card = line.substring(idx + 1); - - final int alternateIdx = card.indexOf('/'); - if (alternateIdx != -1) - card = card.substring(0, alternateIdx); - - currentSection.add(card, amount); - } - } - catch (final Exception e) - { - log.warn("Unable to parse file: {}", file, e); - return null; - } - - final String conformanceProblem = getDeckConformanceProblem(deck); - if (conformanceProblem != null) - { - log.warn("Deck {} is not valid in the choosen format: {}", deck.getName(), conformanceProblem); - return null; - } - - log.info("Loaded deck {}", deck.getName()); - return deck; - } - - /* - * Cards list - */ - - public static Collection<PaperCard> loadCardsList(final File from) - { - final Collection<PaperCard> result = new HashSet<>(); - try (BufferedReader reader = new BufferedReader(new FileReader(from))) - { - String line; - while ((line = reader.readLine()) != null) - { - line = line.trim(); - - final PaperCard card = FModel.getMagicDb().getCommonCards().getCard(line); - if (card == null) - { - log.warn("Unknown card {}, maybe a typo ?", line); - continue; - } - - if (card.getRules().getAiHints().getRemAIDecks()) - { - log.warn("{} is marked as non-playable by AI", card.getName()); - } - if (!format.getFilterRules().apply(card)) - { - log.warn("{} is not legal in the format, skipping...", card.getName()); - continue; - } - - result.add(card); - } - } - catch (final Exception e) - { - log.warn("Unable to parse file: {}", from, e); - return null; - } - return result; - } - - public static String getDeckConformanceProblem(final Deck deck) - { - final UnplayableAICards unplayableCards = deck.getUnplayableAICards(); - if (unplayableCards.inMainDeck != 0) - { - return "contains cards marked as non-playable by AI: " - + unplayableCards.unplayable.get(DeckSection.Main).stream().map(PaperCard::getName).collect(Collectors.joining(", ")); - } - - final String conformanceProblem = format.getDeckConformanceProblem(deck); - return conformanceProblem != null ? conformanceProblem : DECK_FORMAT.getDeckConformanceProblem(deck); - } -} diff --git a/src/main/java/fr/kevincorvisier/mtg/gdb/utils/MagicOnlineDeckLoader.java b/src/main/java/fr/kevincorvisier/mtg/gdb/utils/MagicOnlineDeckLoader.java @@ -0,0 +1,113 @@ +package fr.kevincorvisier.mtg.gdb.utils; + +import java.io.BufferedReader; +import java.io.File; +import java.io.FileReader; +import java.util.ArrayList; +import java.util.Arrays; +import java.util.Collection; +import java.util.Collections; + +import javax.annotation.Nullable; + +import org.apache.commons.lang3.StringUtils; +import org.springframework.stereotype.Service; + +import forge.deck.CardPool; +import forge.deck.Deck; +import forge.deck.DeckSection; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; + +@Slf4j +@Service +@RequiredArgsConstructor +public class MagicOnlineDeckLoader +{ + /* + * Decks + */ + + public Collection<Deck> loadDecks(final File from) + { + if (!from.exists()) + { + log.error("{}: no file or directory", from); + System.exit(0); + } + + if (from.isDirectory()) + return loadDecksFromDirectory(from); + else if (from.isFile()) + { + final Deck deck = loadDeckFromFile(from); + return deck != null ? Arrays.asList(deck) : Collections.emptyList(); + } + else + { + log.error("{}: not a normal file or directory", from); + System.exit(0); + return Collections.emptyList(); // To please the compiler + } + } + + private Collection<Deck> loadDecksFromDirectory(final File directory) + { + final File[] deckFiles = directory.listFiles((dir, name) -> StringUtils.endsWith(name, ".txt")); + + final Collection<Deck> decks = new ArrayList<>(); + for (final File file : deckFiles) + { + final Deck deck = loadDeckFromFile(file); + if (deck != null) + decks.add(deck); + } + return decks; + } + + @Nullable + private Deck loadDeckFromFile(final File file) + { + if (!file.getName().endsWith(".txt")) + { + log.warn("Not a valid deck file: {}", file); + return null; + } + final Deck deck = new Deck(file.getName().substring(0, file.getName().length() - 4)); + CardPool currentSection = deck.getMain(); + + try (BufferedReader reader = new BufferedReader(new FileReader(file))) + { + String line; + while ((line = reader.readLine()) != null) + { + line = line.trim(); + if (line.isEmpty()) + continue; + if ("Sideboard".equals(line)) + { + currentSection = deck.getOrCreate(DeckSection.Sideboard); + continue; + } + + final int idx = line.indexOf(' '); + final int amount = Integer.parseInt(line.substring(0, idx)); + String card = line.substring(idx + 1); + + final int alternateIdx = card.indexOf('/'); + if (alternateIdx != -1) + card = card.substring(0, alternateIdx); + + currentSection.add(card, amount); + } + } + catch (final Exception e) + { + log.warn("Unable to parse file: {}", file, e); + return null; + } + + log.info("Loaded deck {}", deck.getName()); + return deck; + } +} diff --git a/src/main/java/fr/kevincorvisier/mtg/gdb/utils/PropertiesUtils.java b/src/main/java/fr/kevincorvisier/mtg/gdb/utils/PropertiesUtils.java @@ -1,20 +0,0 @@ -package fr.kevincorvisier.mtg.gdb.utils; - -import java.util.Properties; - -import org.apache.commons.lang3.StringUtils; - -import lombok.AccessLevel; -import lombok.NoArgsConstructor; - -@NoArgsConstructor(access = AccessLevel.PRIVATE) // static class -public class PropertiesUtils -{ - public static String getString(final Properties properties, final String key) - { - final String value = properties.getProperty(key); - if (StringUtils.isBlank(value)) - throw new RuntimeException("Missing or empty property: " + key); - return value; - } -} diff --git a/src/main/java/fr/kevincorvisier/mtg/gdb/utils/TimeLimitedCodeBlock.java b/src/main/java/fr/kevincorvisier/mtg/gdb/utils/TimeLimitedCodeBlock.java @@ -45,13 +45,13 @@ public class TimeLimitedCodeBlock { // unwrap the root cause final Throwable t = e.getCause(); - if (t instanceof Error) + if (t instanceof final Error error) { - throw (Error) t; + throw error; } - else if (t instanceof Exception) + else if (t instanceof final Exception exception) { - throw (Exception) t; + throw exception; } else { diff --git a/src/main/java/fr/kevincorvisier/mtg/gdb/validation/IndividualValidationService.java b/src/main/java/fr/kevincorvisier/mtg/gdb/validation/IndividualValidationService.java @@ -0,0 +1,164 @@ +package fr.kevincorvisier.mtg.gdb.validation; + +import java.util.Collection; +import java.util.HashSet; +import java.util.stream.Collectors; + +import javax.annotation.Nullable; + +import org.apache.commons.lang3.StringUtils; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.stereotype.Service; + +import forge.deck.Deck; +import forge.deck.Deck.UnplayableAICards; +import forge.deck.DeckFormat; +import forge.deck.DeckSection; +import forge.game.GameFormat; +import forge.item.PaperCard; +import fr.kevincorvisier.mtg.gdb.population.Individual; +import fr.kevincorvisier.mtg.gdb.population.Population; +import lombok.Data; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; + +@Slf4j +@Service +@RequiredArgsConstructor +public class IndividualValidationService +{ + private static final DeckFormat DECK_FORMAT = DeckFormat.Constructed; + + private final Collection<Condition> conditions = new HashSet<>(); + @Value("${validation.child.max-unique-cards}") + private final int maxUniqueCards; + + @Value("${population.maximum-size}") + private final int maximumSize; + + private final Collection<Long> alreadyGenerated = new HashSet<>(); + + private final GameFormat format; + + /** + * Avoid being in an infinite loop if no new decks can be validated + */ + private long consecutiveFailures = 0; + + @Value("${validation.child.conditions}") + public void setConditions(final String conditions) + { + if (conditions != null) + { + for (final String condition : StringUtils.split(conditions, ';')) + { + final String[] words = StringUtils.split(condition.trim(), ' '); + + if (words.length < 3) + throw new RuntimeException("Invalid validation condition: " + condition); + + final String cardName = StringUtils.join(words, ' ', 0, words.length - 2); + final String op = words[words.length - 2]; + final int count = Integer.parseInt(words[words.length - 1]); + + if (!StringUtils.equalsAny(op, "=", "<=")) + throw new RuntimeException("Invalid validation condition: " + condition); + + this.conditions.add(new Condition(cardName, op, count)); + } + } + } + + public boolean validate(final Individual child, final Population population) + { + if (consecutiveFailures == maximumSize * 100) + { + log.error("toNextGeneration: loop soft limit reached, reseting alreadyGenerated"); + alreadyGenerated.clear(); + population.stream().forEach(i -> alreadyGenerated.add(i.getFingerprint())); + } + else if (consecutiveFailures == maximumSize * 1000) + { + log.error("toNextGeneration: loop hard limit reached, exiting"); + System.exit(0); + } + + final String conformanceProblem = validate(child.getDeck()); + if (conformanceProblem != null) + { + log.warn("Ignoring child: {}", conformanceProblem); + consecutiveFailures++; + return false; + } + + final long fingerprint = child.getFingerprint(); + if (alreadyGenerated.contains(fingerprint)) + { + log.warn("Ignoring child: already generated"); + consecutiveFailures++; + return false; + } + + consecutiveFailures = 0; + alreadyGenerated.add(fingerprint); + return true; + } + + @Nullable + public String validate(final Deck deck) + { + String conformanceProblem = getDeckConformanceProblem(deck); + if (conformanceProblem != null) + return "not valid in the choosen format: " + conformanceProblem; + + final int uniqueCardsCount = deck.getMain().countDistinct(); + if (uniqueCardsCount > maxUniqueCards) + return uniqueCardsCount + " unique cards, more than " + maxUniqueCards; + + for (final Condition condition : conditions) + { + conformanceProblem = condition.validate(deck); + if (conformanceProblem != null) + return conformanceProblem; + } + + return null; + } + + public String getDeckConformanceProblem(final Deck deck) + { + final UnplayableAICards unplayableCards = deck.getUnplayableAICards(); + if (unplayableCards.inMainDeck != 0) + { + return "contains cards marked as non-playable by AI: " + + unplayableCards.unplayable.get(DeckSection.Main).stream().map(PaperCard::getName).collect(Collectors.joining(", ")); + } + + final String conformanceProblem = format.getDeckConformanceProblem(deck); + return conformanceProblem != null ? conformanceProblem : DECK_FORMAT.getDeckConformanceProblem(deck); + } + + @Data + private class Condition + { + private final String cardName; + private final String op; + private final int count; + + @Nullable + public String validate(final Deck deck) + { + final int actualCount = deck.getMain().countByName(cardName); + + switch (op) + { + case "=": + return actualCount == count ? null : (cardName + ": expected: = " + count + ", actual: " + actualCount); + case "<=": + return actualCount <= count ? null : (cardName + ": expected: <= " + count + ", actual: " + actualCount); + default: + throw new RuntimeException("Invalid operator: " + op); + } + } + } +} diff --git a/src/main/packaged-resources/cfg/application.properties b/src/main/packaged-resources/cfg/application.properties @@ -4,6 +4,7 @@ max.generations=1000 max.no-improvement-count=20 format=MiddleSchool +card-pool=example1/card-pool.txt validation.child.conditions= validation.child.max-unique-cards=24 diff --git a/src/main/packaged-resources/cfg/population.properties b/src/main/packaged-resources/cfg/population.properties @@ -1,6 +1,9 @@ -# Percentage of individuals copied to the next generation +# Percentage of a new generation copied from the previous generation's best individuals population.elitism-rate=0.2 +# Percentage of a new generation created from the card pool +population.new-rate=0.2 + # Maximum population size population.maximum-size=300 @@ -9,6 +12,3 @@ population.initialization.type=DISK # For population.initialization.type=DISK, the directory the individuals will be loaded from population.initialization.disk.directories=example1/initial-population - -# For population.initialization.type=RANDOM, file containing the card pool to pick from -population.initialization.random.card-pool=example1/card-pool.txt