mtg-genetic-deckbuilding

Generating and improving Magic: The Gathering decks using a genetic algorithm
git clone https://kevincorvisier.fr/git/mtg-genetic-deckbuilding.git
Log | Files | Refs | LICENSE

commit 30fe12cee5b3876669f18a8ae9d6d63dc4955670
parent 722c8b51522c76e1a3e007b0ec0c458f719c6ff3
Author: Kevin Corvisier <git@kevincorvisier.fr>
Date:   Thu, 24 Oct 2024 20:56:19 +0900

Card pool: allow limiting the number of available copies of cards
Diffstat:
Msrc/main/java/fr/kevincorvisier/mtg/gdb/ai/CardPoolService.java | 57+++++++++++++++++++++++++++++++++++++++++++++++----------
Msrc/main/java/fr/kevincorvisier/mtg/gdb/ai/GeneticAlgorithm.java | 1-
Msrc/main/java/fr/kevincorvisier/mtg/gdb/population/NextGenerationService.java | 6+++---
Msrc/main/java/fr/kevincorvisier/mtg/gdb/population/PopulationFactory.java | 6+++---
Asrc/main/java/fr/kevincorvisier/mtg/gdb/utils/RandomDeckGenerator.java | 122+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Msrc/main/java/fr/kevincorvisier/mtg/gdb/validation/IndividualValidationService.java | 5+++++
6 files changed, 180 insertions(+), 17 deletions(-)

diff --git a/src/main/java/fr/kevincorvisier/mtg/gdb/ai/CardPoolService.java b/src/main/java/fr/kevincorvisier/mtg/gdb/ai/CardPoolService.java @@ -4,14 +4,16 @@ 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 java.util.HashMap; +import java.util.Map; +import java.util.Map.Entry; +import java.util.regex.Matcher; +import java.util.regex.Pattern; 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; @@ -25,27 +27,52 @@ import lombok.extern.slf4j.Slf4j; @RequiredArgsConstructor public class CardPoolService implements Reloadable { + private static final Pattern PATTERN_LINE = Pattern.compile("(?:(?<count>\\d+) )?(?<name>.+)"); + @Value("${card-pool}") private final File cardPool; private final GameFormat format; - private final Collection<String> cardNames = new HashSet<>(); + private final Map<String, Integer> cards = new HashMap<>(); public String getRandomCard() { - return Aggregates.random(cardNames); + return Aggregates.random(cards.keySet()); + } + + public boolean contains(final PaperCard card) + { + return cards.keySet().contains(card.getName()); } - public Deck getRandomDeck() + public int getMaxCount(final String cardName) { - return DeckgenUtil.getRandomColorDeck(card -> cardNames.contains(card.getName()), true); + return cards.getOrDefault(cardName, 0); + } + + public boolean canBeBuilt(final Deck deck) + { + final Map<String, Integer> deckCards = new HashMap<>(); + + for (final Entry<PaperCard, Integer> card : deck.getAllCardsInASinglePool()) + { + Integer count = deckCards.getOrDefault(card.getKey().getName(), 0); + count += card.getValue(); + deckCards.put(card.getKey().getName(), count); + } + + for (final Entry<String, Integer> deckCard : deckCards.entrySet()) + if (deckCard.getValue() > cards.getOrDefault(deckCard.getKey(), 0)) + return false; + + return true; } @Override public void reload() throws IOException { - cardNames.clear(); + cards.clear(); try (BufferedReader reader = new BufferedReader(new FileReader(cardPool))) { @@ -54,7 +81,16 @@ public class CardPoolService implements Reloadable { line = line.trim(); - final PaperCard card = FModel.getMagicDb().getCommonCards().getCard(line); + final Matcher matcher = PATTERN_LINE.matcher(line); + if (!matcher.matches()) + throw new RuntimeException("Cannot parse line: " + line); + + final String countStr = matcher.group("count"); + final int count = countStr != null ? Integer.parseInt(countStr) : Integer.MAX_VALUE; + + final String name = matcher.group("name"); + + final PaperCard card = FModel.getMagicDb().getCommonCards().getCard(name); if (card == null) { log.warn("Unknown card {}, maybe a typo ?", line); @@ -64,6 +100,7 @@ public class CardPoolService implements Reloadable if (card.getRules().getAiHints().getRemAIDecks()) { log.warn("{} is marked as non-playable by AI", card.getName()); + continue; } if (!format.getFilterRules().apply(card)) { @@ -71,7 +108,7 @@ public class CardPoolService implements Reloadable continue; } - cardNames.add(card.getName()); + cards.put(card.getName(), count); } } } diff --git a/src/main/java/fr/kevincorvisier/mtg/gdb/ai/GeneticAlgorithm.java b/src/main/java/fr/kevincorvisier/mtg/gdb/ai/GeneticAlgorithm.java @@ -25,7 +25,6 @@ import lombok.extern.slf4j.Slf4j; @RequiredArgsConstructor public class GeneticAlgorithm implements Reloadable { - protected final Random rnd = new Random(); // Operators diff --git a/src/main/java/fr/kevincorvisier/mtg/gdb/population/NextGenerationService.java b/src/main/java/fr/kevincorvisier/mtg/gdb/population/NextGenerationService.java @@ -8,11 +8,11 @@ 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.utils.RandomDeckGenerator; import fr.kevincorvisier.mtg.gdb.validation.IndividualValidationService; import lombok.RequiredArgsConstructor; @@ -34,7 +34,7 @@ public class NextGenerationService private final Mutation mutation; private final Selection selection = new HybridSelection(); - private final CardPoolService cardPool; + private final RandomDeckGenerator deckGenerator; private final IndividualValidationService validationService; public Population toNextGeneration(final Population population) @@ -64,7 +64,7 @@ public class NextGenerationService while (next.getSize() < targetSize) { - final Deck deck = cardPool.getRandomDeck(); + final Deck deck = deckGenerator.getRandomDeck(); final Individual child = new Individual("g0_new_" + next.getSize(), deck); if (validationService.validate(child, next)) diff --git a/src/main/java/fr/kevincorvisier/mtg/gdb/population/PopulationFactory.java b/src/main/java/fr/kevincorvisier/mtg/gdb/population/PopulationFactory.java @@ -6,8 +6,8 @@ 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.utils.MagicOnlineDeckLoader; +import fr.kevincorvisier.mtg.gdb.utils.RandomDeckGenerator; import fr.kevincorvisier.mtg.gdb.validation.IndividualValidationService; import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; @@ -18,7 +18,7 @@ import lombok.extern.slf4j.Slf4j; public class PopulationFactory { private final MagicOnlineDeckLoader forgeUtils; - private final CardPoolService cardPool; + private final RandomDeckGenerator deckGenerator; private final IndividualValidationService validationService; @Value("${population.initialization.disk.directories}") @@ -78,7 +78,7 @@ public class PopulationFactory while (population.getSize() < maximumSize) { - final Individual individual = new Individual("g0_" + population.getSize(), cardPool.getRandomDeck()); + final Individual individual = new Individual("g0_" + population.getSize(), deckGenerator.getRandomDeck()); if (validationService.validate(individual, population)) population.addIndividual(individual); } diff --git a/src/main/java/fr/kevincorvisier/mtg/gdb/utils/RandomDeckGenerator.java b/src/main/java/fr/kevincorvisier/mtg/gdb/utils/RandomDeckGenerator.java @@ -0,0 +1,122 @@ +package fr.kevincorvisier.mtg.gdb.utils; + +import java.util.HashMap; +import java.util.Map; +import java.util.Map.Entry; +import java.util.stream.Collectors; + +import org.springframework.stereotype.Service; + +import forge.deck.Deck; +import forge.deck.DeckgenUtil; +import forge.item.PaperCard; +import fr.kevincorvisier.mtg.gdb.ai.CardPoolService; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; + +@Slf4j +@Service +@RequiredArgsConstructor +public class RandomDeckGenerator +{ + private final CardPoolService cardPool; + + public Deck getRandomDeck() + { + final Deck deck = DeckgenUtil.getRandomColorDeck(cardPool::contains, true); + if (cardPool.canBeBuilt(deck)) + return deck; + + final Map<String, Integer> deckCards = new HashMap<>(); + + for (final Entry<PaperCard, Integer> card : deck.getAllCardsInASinglePool()) + { + Integer count = deckCards.getOrDefault(card.getKey().getName(), 0); + count += card.getValue(); + deckCards.put(card.getKey().getName(), count); + } + + capCardCountToPool(deckCards); + increaseCardCountToPool(deckCards); + increaseBasicLands(deckCards); + + log.info("fixed: {}", deckCards); + + final Deck fixedDeck = new Deck(); + deckCards.entrySet().forEach(card -> fixedDeck.getMain().add(card.getKey(), card.getValue())); + return fixedDeck; + } + + /** + * Reduce count for cards that exceeds their limit set in the pool + */ + private void capCardCountToPool(final Map<String, Integer> deckCards) + { + for (final Entry<String, Integer> card : deckCards.entrySet()) + { + final String cardName = card.getKey(); + final int currentCount = card.getValue(); + + final int maxCount = cardPool.getMaxCount(cardName); + + if (maxCount == 0) + deckCards.remove(cardName); + else if (currentCount > maxCount) + deckCards.put(cardName, maxCount); + } + } + + private void increaseCardCountToPool(final Map<String, Integer> deckCards) + { + for (final String cardName : deckCards.keySet()) + { + if (isBasicLand(cardName)) + continue; + + final int deckCount = count(deckCards); + if (deckCount >= 60) + return; + + final int maxCount = cardPool.getMaxCount(cardName); + final int currentCount = deckCards.get(cardName); + + if (maxCount > currentCount) + { + final int newCount = Math.min(maxCount, currentCount + (60 - deckCount)); + deckCards.put(cardName, newCount); + log.info("{} set to {} (current: {}, max: {})", cardName, newCount, currentCount, maxCount); + } + } + } + + private void increaseBasicLands(final Map<String, Integer> deckCards) + { + final Map<String, Integer> basics = deckCards.entrySet().stream().filter(card -> isBasicLand(card.getKey())) + .collect(Collectors.toUnmodifiableMap(card -> card.getKey(), card -> card.getValue())); + + final int totalBasics = count(basics); + final int newTotalBasics = totalBasics + (60 - count(deckCards)); + + for (final Entry<String, Integer> basic : basics.entrySet()) + { + final int currentCount = basic.getValue(); + final float currentRatio = ((float) currentCount) / totalBasics; + final int newCount = Math.round(currentRatio * newTotalBasics); + + deckCards.put(basic.getKey(), newCount); + log.info("{} set to {} (current: {})", basic.getKey(), newCount, currentCount); + } + } + + private static int count(final Map<String, Integer> cards) + { + return cards.values().stream().reduce(0, Math::addExact); + } + + private static boolean isBasicLand(final String card) + { + return card.equals("Swamp") || card.equals("Plains") || card.equals("Island") || card.equals("Forest") || card.equals("Mountain") + || card.equals("Snow-Covered Swamp") || card.equals("Snow-Covered Plains") || card.equals("Snow-Covered Island") + || card.equals("Snow-Covered Forest") || card.equals("Snow-Covered Mountain"); + } +} diff --git a/src/main/java/fr/kevincorvisier/mtg/gdb/validation/IndividualValidationService.java b/src/main/java/fr/kevincorvisier/mtg/gdb/validation/IndividualValidationService.java @@ -16,6 +16,7 @@ import forge.deck.DeckFormat; import forge.deck.DeckSection; import forge.game.GameFormat; import forge.item.PaperCard; +import fr.kevincorvisier.mtg.gdb.ai.CardPoolService; import fr.kevincorvisier.mtg.gdb.population.Individual; import fr.kevincorvisier.mtg.gdb.population.Population; import lombok.Data; @@ -39,6 +40,7 @@ public class IndividualValidationService private final Collection<Long> alreadyGenerated = new HashSet<>(); private final GameFormat format; + private final CardPoolService cardPool; /** * Avoid being in an infinite loop if no new decks can be validated @@ -115,6 +117,9 @@ public class IndividualValidationService if (uniqueCardsCount > maxUniqueCards) return uniqueCardsCount + " unique cards, more than " + maxUniqueCards; + if (!cardPool.canBeBuilt(deck)) + return "cannot be built with the current card pool"; + for (final Condition condition : conditions) { conformanceProblem = condition.validate(deck);