diff --git a/build.gradle.kts b/build.gradle.kts index f9f9d66..0f8d1d2 100644 --- a/build.gradle.kts +++ b/build.gradle.kts @@ -1,19 +1,71 @@ -plugins { - id("java") +buildscript { + repositories { + mavenCentral() + } + + dependencies { + classpath("org.owasp:dependency-check-gradle:6.5.0.1") + } } -group = "eu.jonahbauer" -version = "1.0-SNAPSHOT" +subprojects { + apply(plugin = "java-library") + apply(plugin = "org.owasp.dependencycheck") -repositories { - mavenCentral() -} + group = "eu.jonahbauer" + version = "1.0-SNAPSHOT" -dependencies { - testImplementation("org.junit.jupiter:junit-jupiter-api:5.8.1") - testRuntimeOnly("org.junit.jupiter:junit-jupiter-engine:5.8.1") -} + val implementation by configurations + val testImplementation by configurations + val compileOnly by configurations + val annotationProcessor by configurations + val runtimeOnly by configurations + + repositories { + mavenCentral() + } + + dependencies { + implementation("org.jetbrains:annotations:23.0.0") + + implementation("org.apache.logging.log4j:log4j-api:2.19.0") + runtimeOnly("org.apache.logging.log4j:log4j-core:2.19.0") + runtimeOnly("org.apache.logging.log4j:log4j-slf4j-impl:2.19.0") + + testImplementation("org.junit.jupiter:junit-jupiter:5.8.1") + testImplementation("org.junit.jupiter:junit-jupiter-engine:5.8.1") + testImplementation("org.junit.jupiter:junit-jupiter-params:5.8.1") + + compileOnly("org.projectlombok:lombok:1.18.24") + annotationProcessor("org.projectlombok:lombok:1.18.24") + } + + tasks { + withType { + options.encoding = "UTF-8" + } + + withType { + onlyIf { + !project.the()["main"].allSource.isEmpty + } + } + + named("test") { + useJUnitPlatform() + } + + named("check") { + dependsOn("dependencyCheckAnalyze") + } + } + + configure { + sourceCompatibility = JavaVersion.VERSION_17 + targetCompatibility = JavaVersion.VERSION_17 + } -tasks.getByName("test") { - useJUnitPlatform() + configure { + format = org.owasp.dependencycheck.reporting.ReportGenerator.Format.JUNIT + } } \ No newline at end of file diff --git a/settings.gradle.kts b/settings.gradle.kts index 4aaf205..67b14c4 100644 --- a/settings.gradle.kts +++ b/settings.gradle.kts @@ -1,2 +1,3 @@ rootProject.name = "tichu" +include(":tichu-common") \ No newline at end of file diff --git a/src/main/java/eu/jonahbauer/Main.java b/src/main/java/eu/jonahbauer/Main.java deleted file mode 100644 index cfdcddc..0000000 --- a/src/main/java/eu/jonahbauer/Main.java +++ /dev/null @@ -1,7 +0,0 @@ -package eu.jonahbauer; - -public class Main { - public static void main(String[] args) { - System.out.println("Hello world!"); - } -} \ No newline at end of file diff --git a/tichu-common/build.gradle.kts b/tichu-common/build.gradle.kts new file mode 100644 index 0000000..e69de29 diff --git a/tichu-common/src/main/java/eu/jonahbauer/tichu/common/model/Card.java b/tichu-common/src/main/java/eu/jonahbauer/tichu/common/model/Card.java new file mode 100644 index 0000000..7646e6c --- /dev/null +++ b/tichu-common/src/main/java/eu/jonahbauer/tichu/common/model/Card.java @@ -0,0 +1,117 @@ +package eu.jonahbauer.tichu.common.model; + +import lombok.RequiredArgsConstructor; +import org.jetbrains.annotations.NotNull; +import org.jetbrains.annotations.Range; +import org.jetbrains.annotations.Unmodifiable; + +import java.util.*; +import java.util.stream.Collectors; + +@RequiredArgsConstructor +public enum Card { + HOUND(0, null), + MAHJONG(1, null), + PHOENIX(null, null), + DRAGON(15, null), + GREEN_2(2, Suit.GREEN), RED_2(2, Suit.RED), BLACK_2(2, Suit.BLACK), BLUE_2(2, Suit.BLUE), + GREEN_3(3, Suit.GREEN), RED_3(3, Suit.RED), BLACK_3(3, Suit.BLACK), BLUE_3(3, Suit.BLUE), + GREEN_4(4, Suit.GREEN), RED_4(4, Suit.RED), BLACK_4(4, Suit.BLACK), BLUE_4(4, Suit.BLUE), + GREEN_5(5, Suit.GREEN), RED_5(5, Suit.RED), BLACK_5(5, Suit.BLACK), BLUE_5(5, Suit.BLUE), + GREEN_6(6, Suit.GREEN), RED_6(6, Suit.RED), BLACK_6(6, Suit.BLACK), BLUE_6(6, Suit.BLUE), + GREEN_7(7, Suit.GREEN), RED_7(7, Suit.RED), BLACK_7(7, Suit.BLACK), BLUE_7(7, Suit.BLUE), + GREEN_8(8, Suit.GREEN), RED_8(8, Suit.RED), BLACK_8(8, Suit.BLACK), BLUE_8(8, Suit.BLUE), + GREEN_9(9, Suit.GREEN), RED_9(9, Suit.RED), BLACK_9(9, Suit.BLACK), BLUE_9(9, Suit.BLUE), + GREEN_10(10, Suit.GREEN), RED_10(10, Suit.RED), BLACK_10(10, Suit.BLACK), BLUE_10(10, Suit.BLUE), + GREEN_JACK(11, Suit.GREEN), RED_JACK(11, Suit.RED), BLACK_JACK(11, Suit.BLACK), BLUE_JACK(11, Suit.BLUE), + GREEN_QUEEN(12, Suit.GREEN), RED_QUEEN(12, Suit.RED), BLACK_QUEEN(12, Suit.BLACK), BLUE_QUEEN(12, Suit.BLUE), + GREEN_KING(13, Suit.GREEN), RED_KING(13, Suit.RED), BLACK_KING(13, Suit.BLACK), BLUE_KING(13, Suit.BLUE), + GREEN_ACE(14, Suit.GREEN), RED_ACE(14, Suit.RED), BLACK_ACE(14, Suit.BLACK), BLUE_ACE(14, Suit.BLUE); + + private static final Set NORMAL_CARDS = Arrays.stream(values()) + .filter(Card::isNormal) + .collect(Collectors.toUnmodifiableSet()); + private static final Set SPECIAL_CARDS = Arrays.stream(values()) + .filter(Card::isSpecial) + .collect(Collectors.toUnmodifiableSet()); + private static final Map> CARDS_BY_VALUE = Arrays.stream(values()) + .filter(Card::isNormal) + .collect(Collectors.collectingAndThen( + Collectors.groupingBy(Card::getValue, Collectors.toUnmodifiableSet()), + Collections::unmodifiableMap + )); + + + private final Integer value; + private final Suit suit; + + /** + * Checks whether this card is a special card, i.e. one of {@link #MAHJONG}, {@link #PHOENIX}, {@link #DRAGON} or + * {@link #HOUND}. + * @return {@code true} iff this card is a special card. + * @see #isNormal() + */ + public boolean isSpecial() { + return suit == null; + } + + /** + * Checks whether this card is a normal card, i.e. none of {@link #MAHJONG}, {@link #PHOENIX}, {@link #DRAGON} or + * {@link #HOUND}. + * @return {@code true} iff this card is a normal card. + * @see #isSpecial() + */ + public boolean isNormal() { + return suit != null; + } + + /** + * Returns the value of this card. The value is + *
    + *
  • {@code 0} for {@link #HOUND}
  • + *
  • {@code 1} for {@link #MAHJONG}
  • + *
  • {@code null} for {@link #PHOENIX}
  • + *
  • {@code 15} for {@link #DRAGON}
  • + *
  • obvious for any {@linkplain #isNormal() normal card}
  • + *
+ * @return the value of this card + */ + public @Range(from = 1, to = 15) Integer getValue() { + return value; + } + + /** + * Returns the suit of this {@linkplain #isNormal() normal card} or {@code null} for + * {@linkplain #isSpecial() special cards}. + * @return the suit of this card + */ + public Suit getSuit() { + return suit; + } + + /** + * Returns an unmodifiable set of all {@linkplain #isNormal() normal cards}. + * @return a set of cards + */ + public static @Unmodifiable @NotNull Set<@NotNull Card> getNormalCards() { + return NORMAL_CARDS; + } + + /** + * Returns an unmodifiable set of all {@linkplain #isSpecial() special cards}. + * @return a set of cards + */ + public static @Unmodifiable @NotNull Set<@NotNull Card> getSpecialCards() { + return SPECIAL_CARDS; + } + + /** + * Returns an unmodifiable set of all {@linkplain #isNormal() normal cards} with the specified + * {@linkplain #getValue() value}. + * @param value the card value between {@code 2} and {@code 14} (inclusive) + * @return a set of cards + */ + public static @Unmodifiable @NotNull Set<@NotNull Card> getCardsByValue(@Range(from = 2, to = 14) int value) { + return CARDS_BY_VALUE.getOrDefault(value, Collections.emptySet()); + } +} diff --git a/tichu-common/src/main/java/eu/jonahbauer/tichu/common/model/Combination.java b/tichu-common/src/main/java/eu/jonahbauer/tichu/common/model/Combination.java new file mode 100644 index 0000000..f380490 --- /dev/null +++ b/tichu-common/src/main/java/eu/jonahbauer/tichu/common/model/Combination.java @@ -0,0 +1,190 @@ +package eu.jonahbauer.tichu.common.model; + +import eu.jonahbauer.tichu.common.model.exceptions.InvalidCombinationException; +import org.jetbrains.annotations.Contract; +import org.jetbrains.annotations.NotNull; +import org.jetbrains.annotations.Unmodifiable; + +import java.util.List; +import java.util.Objects; + +/** + * A combination of cards. + * @param type the type of the combination + * @param cards the cards composing this combination + */ +@SuppressWarnings("unused") +public record Combination( + @NotNull CombinationType type, + @NotNull @Unmodifiable List<@NotNull Card> cards +) { + /** + * Creates a new combination. + * @param type the type of the new combination + * @param cards the cards composing the new combination + * @throws InvalidCombinationException if the cards do not compose a valid combination of the given type + * @throws NullPointerException if {@code type}, {@code cards} or any element of {@code cards} is {@code null} + */ + public Combination(@NotNull CombinationType type, @NotNull List<@NotNull Card> cards) { + this.type = Objects.requireNonNull(type, "type must not be null"); + this.cards = List.copyOf(Objects.requireNonNull(cards, "cards must not be null")); // List#copyOf performs null-checks + if (!type.check(this.cards)) { + throw new InvalidCombinationException(this.type, this.cards); + } + } + + /** + * Returns the card at the specified position. This is equivalent to {@code cards().get(index)} + * @param index index of the card to return + * @return the card at the specified position + * @see #cards() + */ + public @NotNull Card card(int index) { + return cards.get(index); + } + + @Override + public @NotNull String toString() { + return type + cards.toString(); + } + + // + /** + * Creates a new {@link CombinationType#SINGLE}-Combination. + * @param card the card + * @return a new combination + * @see CombinationType#SINGLE + */ + @Contract("_ -> new") + public static @NotNull Combination single(@NotNull Card card) { + return new Combination(CombinationType.SINGLE, List.of(card)); + } + + /** + * Creates a new {@link CombinationType#PAIR}-Combination. + * @param card0 the first card + * @param card1 the second card + * @return a new combination + * @see CombinationType#PAIR + */ + @Contract("_, _ -> new") + public static @NotNull Combination pair(@NotNull Card card0, @NotNull Card card1) { + return new Combination(CombinationType.PAIR, List.of(card0, card1)); + } + + /** + * Creates a new {@link CombinationType#TRIPLE}-Combination. + * @param card0 the first card + * @param card1 the second card + * @param card2 the third card + * @return a new combination + * @see CombinationType#TRIPLE + */ + @Contract("_, _, _ -> new") + public static @NotNull Combination triple(@NotNull Card card0, @NotNull Card card1, @NotNull Card card2) { + return new Combination(CombinationType.TRIPLE, List.of(card0, card1, card2)); + } + + /** + * Creates a new {@link CombinationType#FULL_HOUSE}-Combination. + * @param tripleCard0 the first card of the triple + * @param tripleCard1 the second card of the triple + * @param tripleCard2 the third card of the triple + * @param pairCard0 the first card of the pair + * @param pairCard1 the second card of the pair + * @return a new combination + * @see CombinationType#FULL_HOUSE + */ + @Contract("_, _, _, _, _ -> new") + public static @NotNull Combination fullHouse( + @NotNull Card tripleCard0, @NotNull Card tripleCard1, @NotNull Card tripleCard2, + @NotNull Card pairCard0, @NotNull Card pairCard1 + ) { + return new Combination(CombinationType.FULL_HOUSE, List.of( + tripleCard0, tripleCard1, tripleCard2, pairCard0, pairCard1 + )); + } + + /** + * Creates a new {@link CombinationType#SEQUENCE}-Combination. + * @param cards the sequence of cards (in correct order) + * @return a new combination + * @see CombinationType#SEQUENCE + */ + @Contract("_ -> new") + public static @NotNull Combination sequence(@NotNull Card @NotNull... cards) { + return new Combination(CombinationType.SEQUENCE, List.of(cards)); + } + + /** + * Creates a new {@link CombinationType#SEQUENCE}-Combination. + * @param cards the sequence of cards (in correct order) + * @return a new combination + * @see CombinationType#SEQUENCE + */ + @Contract("_ -> new") + public static @NotNull Combination sequence(@NotNull List<@NotNull Card> cards) { + return new Combination(CombinationType.SEQUENCE, cards); + } + + /** + * Creates a new {@link CombinationType#PAIR_SEQUENCE}-Combination. + * @param cards the sequence of cards (in correct order) + * @return a new combination + * @see CombinationType#PAIR_SEQUENCE + */ + @Contract("_ -> new") + public static @NotNull Combination pairSequence(@NotNull Card @NotNull... cards) { + return new Combination(CombinationType.PAIR_SEQUENCE, List.of(cards)); + } + + /** + * Creates a new {@link CombinationType#PAIR_SEQUENCE}-Combination. + * @param cards the sequence of cards (in correct order) + * @return a new combination + * @see CombinationType#PAIR_SEQUENCE + */ + @Contract("_ -> new") + public static @NotNull Combination pairSequence(@NotNull List<@NotNull Card> cards) { + return new Combination(CombinationType.PAIR_SEQUENCE, cards); + } + + /** + * Creates a new {@link CombinationType#BOMB}-Combination. + * @param card0 the first card + * @param card1 the second card + * @param card2 the third card + * @param card3 the fourth card + * @return a new combination + * @see CombinationType#BOMB + */ + @Contract("_, _, _, _ -> new") + public static @NotNull Combination bomb( + @NotNull Card card0, @NotNull Card card1, @NotNull Card card2, @NotNull Card card3 + ) { + return new Combination(CombinationType.BOMB, List.of(card0, card1, card2, card3)); + } + + /** + * Creates a new {@link CombinationType#BOMB_SEQUENCE}-Combination. + * @param cards the sequence of cards (in correct order) + * @return a new combination + * @see CombinationType#BOMB_SEQUENCE + */ + @Contract("_ -> new") + public static @NotNull Combination bombSequence(@NotNull Card @NotNull... cards) { + return new Combination(CombinationType.BOMB_SEQUENCE, List.of(cards)); + } + + /** + * Creates a new {@link CombinationType#BOMB_SEQUENCE}-Combination. + * @param cards the sequence of cards (in correct order) + * @return a new combination + * @see CombinationType#BOMB_SEQUENCE + */ + @Contract("_ -> new") + public static @NotNull Combination bombSequence(@NotNull List<@NotNull Card> cards) { + return new Combination(CombinationType.BOMB_SEQUENCE, cards); + } + // +} diff --git a/tichu-common/src/main/java/eu/jonahbauer/tichu/common/model/CombinationType.java b/tichu-common/src/main/java/eu/jonahbauer/tichu/common/model/CombinationType.java new file mode 100644 index 0000000..f38014e --- /dev/null +++ b/tichu-common/src/main/java/eu/jonahbauer/tichu/common/model/CombinationType.java @@ -0,0 +1,407 @@ +package eu.jonahbauer.tichu.common.model; + +import org.jetbrains.annotations.NotNull; +import org.jetbrains.annotations.Range; + +import java.util.*; + +/** + * A type of combination. + */ +public enum CombinationType { + /** + * A single card, {@linkplain Card#isNormal() normal} or {@linkplain Card#isSpecial() special}. This is the + * correct {@code CombinationType} for playing the {@link Card#HOUND} or {@link Card#DRAGON}. + */ + SINGLE { + private static final Set SPECIAL_CARDS = EnumSet.of(Card.PHOENIX, Card.MAHJONG, Card.DRAGON, Card.HOUND); + + @Override + public boolean check(@NotNull List<@NotNull Card> cards) { + return checkBasics(cards, 1, SPECIAL_CARDS); + } + + @Override + public boolean isHigher(@NotNull Stack stack, @NotNull List<@NotNull Card> cards) { + assert isCompatible(stack, cards); + if (stack.isEmpty()) return true; // you can play any card onto an empty stack + if (stack.size() == 1 && stack.top().card(0) == Card.HOUND) return false; // you cannot play any card onto a hound + + var card = cards.get(0); + return switch (card) { + case HOUND, MAHJONG -> false; + case DRAGON -> true; + case PHOENIX -> stack.top().card(0) != Card.DRAGON; + default -> { + var top = stack.top().card(0); + if (top != Card.PHOENIX) { + yield card.getValue() > top.getValue(); + } else if (stack.size() == 1) { + // every normal card has a value of at least 2, which is higher that the phoenix 1½ + yield true; + } else { + yield card.getValue() > stack.top(-1).card(0).getValue(); + } + } + }; + } + }, + /** + * A pair, i.e. two {@linkplain Card#isNormal() normal cards} of the same {@linkplain Card#getValue() value} or the + * {@link Card#PHOENIX} and a normal card. + */ + PAIR { + private static final Set SPECIAL_CARDS = EnumSet.of(Card.PHOENIX); + private static final int[] INDICES = new int[] {0, 1}; + + @Override + public boolean check(@NotNull List<@NotNull Card> cards) { + return checkBasics(cards, 2, SPECIAL_CARDS) + && isSameValue(cards, INDICES); + } + + @Override + public boolean isHigher(@NotNull Stack stack, @NotNull List<@NotNull Card> cards) { + assert isCompatible(stack, cards); + return stack.isEmpty() + || getSameValue(cards, INDICES) > getSameValue(stack.top().cards(), INDICES); + } + }, + /** + * A triple, i.e. three {@linkplain Card#isNormal() normal cards} of the same {@linkplain Card#getValue() value} + * or the {@linkplain Card#PHOENIX} and two normal cards of the same value. + */ + TRIPLE { + private static final Set SPECIAL_CARDS = EnumSet.of(Card.PHOENIX); + private static final int[] INDICES = new int[] {0, 1, 2}; + + @Override + public boolean check(@NotNull List<@NotNull Card> cards) { + return checkBasics(cards, 3, SPECIAL_CARDS) + && isSameValue(cards, INDICES); + } + + @Override + public boolean isHigher(@NotNull Stack stack, @NotNull List<@NotNull Card> cards) { + assert isCompatible(stack, cards); + return stack.isEmpty() + || getSameValue(cards, INDICES) > getSameValue(stack.top().cards(), INDICES); + } + }, + /** + * A full house, i.e. five cards of which three form a {@link #TRIPLE} and the other two form a {@link #PAIR}. + * @apiNote A list of cards representing a full house must have the triple at the first three indices and the pair + * at the last two. This prevents any ambiguity, e.g. when the full house consists of two pairs and the phoenix. + */ + FULL_HOUSE { + private static final Set SPECIAL_CARDS = EnumSet.of(Card.PHOENIX); + + private static final int[] INDICES_TRIPLE = new int[] {0, 1, 2}; + private static final int[] INDICES_PAIR = new int[] {3, 4}; + + @Override + public boolean check(@NotNull List<@NotNull Card> cards) { + return checkBasics(cards, 5, SPECIAL_CARDS) + && isSameValue(cards, INDICES_TRIPLE) + && isSameValue(cards, INDICES_PAIR); + } + + @Override + public boolean isHigher(@NotNull Stack stack, @NotNull List<@NotNull Card> cards) { + assert isCompatible(stack, cards); + return stack.isEmpty() + || getSameValue(cards, INDICES_TRIPLE) > getSameValue(stack.top().cards(), INDICES_TRIPLE); + } + }, + /** + * A sequence, i.e. at least five cards with increasing values. The first card in the sequence may be a + * {@link Card#MAHJONG} and one of the cards may be the {@link Card#PHOENIX}. + * @apiNote A list of cards representing a sequence must be sorted by ascending value. This allows to distinguish + * between a sequence starting with the phoenix and a sequence ending with the phoenix. + */ + SEQUENCE { + private static final Set SPECIAL_CARDS = EnumSet.of(Card.PHOENIX, Card.MAHJONG); + + private int getStartValue(@NotNull List<@NotNull Card> cards) { + return cards.get(0) == Card.PHOENIX + ? cards.get(1).getValue() - 1 + : cards.get(0).getValue(); + } + + @Override + public boolean check(@NotNull List<@NotNull Card> cards) { + if (!checkBasics(cards, null, SPECIAL_CARDS)) return false; + if (cards.size() < 5) return false; + + int start = getStartValue(cards); + + for (int i = 0; i < cards.size(); i++) { + var card = cards.get(i); + if (card == Card.PHOENIX) { + if (start + i > 14) { + return false; + } + } else if (card.getValue() != start + i) { + return false; + } + } + + return true; + } + + @Override + public boolean isCompatible(@NotNull Stack stack, @NotNull List<@NotNull Card> cards) { + assert check(cards); + return stack.isEmpty() + || stack.top().type() == this && stack.top().cards().size() == cards.size(); + } + + @Override + public boolean isHigher(@NotNull Stack stack, @NotNull List<@NotNull Card> cards) { + assert isCompatible(stack, cards); + return stack.isEmpty() + || getStartValue(cards) > getStartValue(stack.top().cards()); + } + }, + /** + * A sequence of pairs, i.e. at least two pairs with increasing values. + * @apiNote Although not strictly necessary, for consistency with {@link #SEQUENCE} a list of cards representing + * a sequence of pairs must be sorted by ascending value. + */ + PAIR_SEQUENCE { + private static final Set SPECIAL_CARDS = EnumSet.of(Card.PHOENIX); + + private int getStartValue(@NotNull List<@NotNull Card> cards) { + return cards.get(0) == Card.PHOENIX + ? cards.get(1).getValue() + : cards.get(0).getValue(); + } + + @Override + public boolean check(@NotNull List<@NotNull Card> cards) { + if (!checkBasics(cards, null, SPECIAL_CARDS)) return false; + if (cards.size() < 4 || cards.size() % 2 == 1) return false; + + int start = getStartValue(cards); + + for (int i = 0; i < cards.size(); i++) { + var card = cards.get(i); + + if (card != Card.PHOENIX && card.getValue() != start + i / 2) { + return false; + } + } + + return true; + } + + @Override + public boolean isCompatible(@NotNull Stack stack, @NotNull List<@NotNull Card> cards) { + assert check(cards); + return stack.isEmpty() + || stack.top().type() == this && stack.top().cards().size() == cards.size(); + } + + @Override + public boolean isHigher(@NotNull Stack stack, @NotNull List<@NotNull Card> cards) { + assert isCompatible(stack, cards); + return stack.isEmpty() + || getStartValue(cards) > getStartValue(stack.top().cards()); + } + }, + /** + * A bomb, i.e. four {@linkplain Card#isNormal() normal cards} of the same {@linkplain Card#getValue() value}. + */ + BOMB { + private static final int[] INDICES = new int[] {0, 1, 2, 3}; + private static final Set SPECIAL_CARDS = Collections.emptySet(); + private static final Combination COMBINATION_HOUND = new Combination(SINGLE, List.of(Card.HOUND)); + + @Override + public boolean check(@NotNull List<@NotNull Card> cards) { + return checkBasics(cards, 4, SPECIAL_CARDS) + && isSameValue(cards, INDICES); + } + + @Override + public boolean isCompatible(@NotNull Stack stack, @NotNull List<@NotNull Card> cards) { + assert check(cards); + return stack.isEmpty() || !COMBINATION_HOUND.equals(stack.top()); + } + + @Override + public boolean isHigher(@NotNull Stack stack, @NotNull List<@NotNull Card> cards) { + assert isCompatible(stack, cards); + return stack.isEmpty() + || stack.top().type() != BOMB && stack.top().type() != BOMB_SEQUENCE + || stack.top().type() == BOMB && getSameValue(cards, INDICES) > getSameValue(stack.top().cards(), INDICES); + } + }, + /** + * A bomb sequence, i.e. a {@linkplain Card#getSuit() monochrome} {@link #SEQUENCE} that does not contain any + * {@linkplain Card#isSpecial() special cards}. + * @apiNote Although not strictly necessary, for consistency with {@link #SEQUENCE} a list of cards representing a + * bomb sequence must be sorted by ascending value. + * @apiNote + */ + BOMB_SEQUENCE { + private static final Set SPECIAL_CARDS = Collections.emptySet(); + private static final Combination COMBINATION_HOUND = new Combination(SINGLE, List.of(Card.HOUND)); + + private int getStartValue(@NotNull List<@NotNull Card> cards) { + return cards.get(0).getValue(); + } + + @Override + public boolean check(@NotNull List<@NotNull Card> cards) { + if (!checkBasics(cards, null, SPECIAL_CARDS)) return false; + if (cards.size() < 5) return false; + if (cards.stream().map(Card::getSuit).distinct().count() != 1) return false; + + int start = getStartValue(cards); + for (int i = 1; i < cards.size(); i++) { + var card = cards.get(i); + if (card.getValue() != start + i) { + return false; + } + } + + return true; + } + + @Override + public boolean isCompatible(@NotNull Stack stack, @NotNull List<@NotNull Card> cards) { + assert check(cards); + return stack.isEmpty() || !COMBINATION_HOUND.equals(stack.top()); + } + + @Override + public boolean isHigher(@NotNull Stack stack, @NotNull List<@NotNull Card> cards) { + assert isCompatible(stack, cards); + return stack.isEmpty() + || stack.top().type() != BOMB_SEQUENCE + || cards.size() > stack.top().cards().size() + || getStartValue(cards) > getStartValue(stack.top().cards()); + } + }; + + /** + * Checks whether the given cards form a valid combination of this type. See the documentation of a specific + * {@code CombinationType} for more information on what cards form a combination of that particular type. + * @param cards a list of cards + * @return {@code true} if the cards form a valid combination of this type. + */ + public abstract boolean check(@NotNull List<@NotNull Card> cards); + + /** + *

Checks whether the {@code cards} are compatible with the {@code stack}, i.e. the {@code cards} can + * theoretically be played on top of the {@code stack}. For example a {@link #SEQUENCE} is compatible with a given + * stack only if the stack is empty, or it consists of sequences of the same length. + *

However, this method does not check whether the {@code cards} are actually higher than the top of the + * {@code stack}. If the {@code cards} are not a {@linkplain #check(List) valid} combination of this type, the + * behaviour of this method is undefined and an exception may be thrown. + * @param stack the current stack of cards + * @param cards the cards to be played + * @return {@code true} iff the {@code cards} are compatible with the {@code stack} + */ + public boolean isCompatible(@NotNull Stack stack, @NotNull List<@NotNull Card> cards) { + assert check(cards); + return stack.isEmpty() || stack.top().type() == this; + } + + /** + *

Checks whether the {@code cards} are higher than the combination currently on top of the {@code stack}. + *

If the {@code cards} are not {@linkplain #isCompatible(Stack, List) compatible} with the {@code stack}, + * the behaviour of this method is undefined and an exception may be thrown. + * @param stack the current stack of cards + * @param cards the cards to be played + * @return {@code true} iff the {@code cards} can be played on top of the {@code stack} + */ + public abstract boolean isHigher(@NotNull Stack stack, @NotNull List<@NotNull Card> cards); + + // + /** + * Performs basic checks on a list of cards, i.e. this method checks + *

    + *
  • that the list does not contain any duplicate cards
  • + *
  • when {@code count != null}, that the list is of size {@code count}
  • + *
  • + * that the list does not contain any {@linkplain Card#isSpecial() special card} other than those in + * {@code special} + *
  • + *
+ * @param cards the list of cards + * @param count the expected number of cards, or {@code null} when the number of cards should not be checked + * @param special the set of allowed special cards or {@code null}, when all special cards are allowed. + */ + private static boolean checkBasics(@NotNull List<@NotNull Card> cards, Integer count, @NotNull Set<@NotNull Card> special) { + if (count != null && cards.size() != count) return false; + if (containsDuplicates(cards)) return false; + + for (Card card : cards) { + if (card.isSpecial() && !special.contains(card)) { + return false; + } + } + + return true; + } + + /** + * Checks whether the list contains any duplicate cards, i.e. there exists {@code a} and {@code b} such that + *
cards.contains(a) && cards.contains(b) && a.equals(b)
+ * @param cards a list of cards + * @return {@code true} iff the list contains duplicates. + */ + private static boolean containsDuplicates(@NotNull List<@NotNull Card> cards) { + if (cards.size() <= 1) return false; + var set = EnumSet.noneOf(Card.class); + for (Card card : cards) { + if (!set.add(card)) return true; + } + return false; + } + + /** + * Checks that the cards at the specified indices all have the same value. + * {@linkplain Card#isSpecial() Special cards} are ignored. + * @param cards a list of cards + * @param indices an array of indices of cards in the list + * @return {@code true} iff all of the specified cards have the same value + */ + private static boolean isSameValue(@NotNull List<@NotNull Card> cards, int @NotNull... indices) { + Integer value = null; + + for (int index : indices) { + Card card = cards.get(index); + if (card.isSpecial()) continue; + + if (value == null) { + value = card.getValue(); + } else if (!value.equals(card.getValue())) { + return false; + } + } + + return true; + } + + /** + * Assuming that all the {@linkplain Card#isNormal() normal cards} at the specified indices have the same value + * returns that value. The return value is unspecified if the assumption is violated. + * @param cards a list of cards + * @param indices an array of indices of cards in the list + * @see #isSameValue(List, int...) + * @throws NoSuchElementException if there is no normal card among the specified indices + */ + private static @Range(from = 2, to = 14) int getSameValue(@NotNull List<@NotNull Card> cards, int @NotNull... indices) { + for (int index : indices) { + Card card = cards.get(index); + if (card.isSpecial()) continue; + + return card.getValue(); + } + throw new NoSuchElementException(); + } + // +} diff --git a/tichu-common/src/main/java/eu/jonahbauer/tichu/common/model/Stack.java b/tichu-common/src/main/java/eu/jonahbauer/tichu/common/model/Stack.java new file mode 100644 index 0000000..6c84950 --- /dev/null +++ b/tichu-common/src/main/java/eu/jonahbauer/tichu/common/model/Stack.java @@ -0,0 +1,184 @@ +package eu.jonahbauer.tichu.common.model; + +import eu.jonahbauer.tichu.common.model.exceptions.IncompatibleCombinationException; +import eu.jonahbauer.tichu.common.model.exceptions.TooLowCombinationException; +import lombok.EqualsAndHashCode; +import org.jetbrains.annotations.Contract; +import org.jetbrains.annotations.NotNull; +import org.jetbrains.annotations.Range; +import org.jetbrains.annotations.Unmodifiable; + +import java.util.*; + +@EqualsAndHashCode +public final class Stack { + private static final Stack EMPTY = new Stack(Collections.emptyList()); + + private final @Unmodifiable @NotNull List<@NotNull Combination> combinations; + + public static @NotNull Stack of(@NotNull Combination @NotNull... combinations) { + return of(Arrays.asList(combinations)); + } + + public static @NotNull Stack of(@NotNull List<@NotNull Combination> combinations) { + Stack out = new Stack(List.copyOf(combinations)); // List#copyOf performs null-checks + + for (int i = 0; i < out.size(); i++) { + Stack lower = out.substack(0, i); + Combination top = out.get(i); + + if (!top.type().isHigher(lower, top.cards())) { + throw new IllegalArgumentException("Invalid stack: %s cannot be played on top of %s".formatted(top, lower)); + } + } + + return out; + } + + public static @NotNull Stack empty() { + return EMPTY; + } + + private Stack(@Unmodifiable @NotNull List<@NotNull Combination> combinations) { + this.combinations = combinations; + } + + @Contract(value = "_, _ -> new", pure = true) + public @NotNull Stack substack(int fromIndex, int toIndex) { + return new Stack(combinations.subList(fromIndex, toIndex)); + } + + /** + * Returns the number of elements on this stack. + * @return the number of elements on this stack + */ + @Contract(pure = true) + public int size() { + return combinations.size(); + } + + /** + * Returns {@code true} if this stack contains no elements. + * @return {@code true} if this stack contains no elements + */ + @Contract(pure = true) + public boolean isEmpty() { + return combinations.isEmpty(); + } + + /** + * Returns the {@code index}-th Combination from the bottom of this stack. + * @param index the index + * @return a combination on this stack + * @throws IndexOutOfBoundsException if the index is out of range ({@code index < 0 || index >= size()}) + */ + @Contract(pure = true) + public @NotNull Combination get(@Range(from = 0, to = Integer.MAX_VALUE) int index) { + return combinations.get(index); + } + + /** + * Returns the topmost {@link Combination} on this stack. This is equivalent to {@link #top(int) top(0)}. + * @return the topmost combination + * @throws IndexOutOfBoundsException if the stack is {@linkplain #isEmpty() empty} + */ + @Contract(pure = true) + public @NotNull Combination top() { + return top(0); + } + + /** + * Returns the ({@code -index})-th {@link Combination} from the top of this stack, i.e. {@code top(0)} returns the + * topmost combination, {@code top(-1)} returns the second-topmost combination, and so on. + * @param index the index + * @return a combination on this stack + * @throws IndexOutOfBoundsException if the index is out of range ({@code index > 0 || index <= -size()}) + */ + @Contract(pure = true) + public @NotNull Combination top(@Range(from = Integer.MIN_VALUE, to = 0) int index) { + if (size() <= -index) throw new IndexOutOfBoundsException(index); + return combinations.get(combinations.size() - 1 + index); + } + + /** + * Checks whether the {@code combination} is {@linkplain CombinationType#isCompatible(Stack, List) compatible} with + * this stack. + * @param combination a combination + * @return {@code true} iff the {@code combination} is compatible + * @see CombinationType#isCompatible(Stack, List) + * @see #isHigher(Combination) + * @see #isCompatibleAndHigher(Combination) + */ + @Contract(pure = true) + public boolean isCompatible(@NotNull Combination combination) { + Objects.requireNonNull(combination, "combination must not be null"); + return combination.type().isCompatible(this, combination.cards()); + } + + /** + * Checks whether a {@linkplain #isCompatible(Combination) compatible} {@code combination} is + * {@linkplain CombinationType#isHigher(Stack, List) higher} than the {@linkplain #top() topmost} combination + * on this stack. + * The behaviour of this method is undefined if the {@code combination} is incompatible and an exception may be thrown. + * @param combination a combination + * @return {@code true} iff the combination is higher than the topmost combination on this stack + * @see CombinationType#isHigher(Stack, List) + * @see #isCompatible(Combination) + * @see #isCompatibleAndHigher(Combination) + */ + @Contract(pure = true) + public boolean isHigher(@NotNull Combination combination) { + Objects.requireNonNull(combination, "combination must not be null"); + return combination.type().isHigher(this, combination.cards()); + } + + /** + * Checks whether a {@code combination} can be played onto this stack, i.e. the {@code combination} is + * {@linkplain #isCompatible(Combination) compatible} with this stack and {@linkplain #isHigher(Combination) higher} + * than the {@linkplain #top() topmost} combination on this stack. + * @param combination a combination + * @return {@code true} iff the combination is compatible with and higher than this stack + * @see #isHigher(Combination) + * @see #isCompatible(Combination) + */ + @Contract(pure = true) + public boolean isCompatibleAndHigher(@NotNull Combination combination) { + Objects.requireNonNull(combination, "combination must not be null"); + return combination.type().isCompatible(this, combination.cards()) + && combination.type().isHigher(this, combination.cards()); + } + + /** + * Puts the {@code combination} on top of this stack and returns a new, modified stack. + * @param combination a combination + * @return a new, modified stack + * @throws IncompatibleCombinationException if the {@code combination} is + * {@linkplain #isCompatible(Combination) incompatible} + * @throws IllegalArgumentException if the {@code combination} is not {@linkplain #isHigher(Combination) higher} + * than the combination currently on top of this tack + */ + @Contract(value = "_ -> new", pure = true) + public @NotNull Stack put(@NotNull Combination combination) { + Objects.requireNonNull(combination, "combination must not be null"); + if (!isCompatible(combination)) { + throw new IncompatibleCombinationException(this, combination); + } else if (!isHigher(combination)) { + throw new TooLowCombinationException(this, combination); + } + + // copy + var arr = combinations.toArray(new Combination[combinations.size() + 1]); + arr[arr.length - 1] = combination; + return new Stack(Arrays.asList(arr)); + } + + @Override + public @NotNull String toString() { + StringBuilder out = new StringBuilder("Stack["); + for (int i = combinations.size(); i --> 0;) { + out.append("\n ").append(combinations.get(i)); + } + out.append("\n]"); + return out.toString(); + } +} diff --git a/tichu-common/src/main/java/eu/jonahbauer/tichu/common/model/Suit.java b/tichu-common/src/main/java/eu/jonahbauer/tichu/common/model/Suit.java new file mode 100644 index 0000000..4d99b30 --- /dev/null +++ b/tichu-common/src/main/java/eu/jonahbauer/tichu/common/model/Suit.java @@ -0,0 +1,5 @@ +package eu.jonahbauer.tichu.common.model; + +public enum Suit { + GREEN, RED, BLACK, BLUE +} diff --git a/tichu-common/src/main/java/eu/jonahbauer/tichu/common/model/exceptions/IncompatibleCombinationException.java b/tichu-common/src/main/java/eu/jonahbauer/tichu/common/model/exceptions/IncompatibleCombinationException.java new file mode 100644 index 0000000..ae50055 --- /dev/null +++ b/tichu-common/src/main/java/eu/jonahbauer/tichu/common/model/exceptions/IncompatibleCombinationException.java @@ -0,0 +1,24 @@ +package eu.jonahbauer.tichu.common.model.exceptions; + +import eu.jonahbauer.tichu.common.model.Combination; +import eu.jonahbauer.tichu.common.model.Stack; +import lombok.Getter; +import org.jetbrains.annotations.NotNull; + +import java.util.Objects; + +@Getter +public class IncompatibleCombinationException extends IllegalArgumentException { + private final @NotNull Stack stack; + private final @NotNull Combination combination; + + public IncompatibleCombinationException(@NotNull Stack stack, @NotNull Combination combination) { + this.stack = Objects.requireNonNull(stack, "stack must not be null"); + this.combination = Objects.requireNonNull(combination, "combination must not be null"); + } + + @Override + public String getMessage() { + return "Cannot play %s on top of %s.".formatted(combination, stack); + } +} diff --git a/tichu-common/src/main/java/eu/jonahbauer/tichu/common/model/exceptions/InvalidCombinationException.java b/tichu-common/src/main/java/eu/jonahbauer/tichu/common/model/exceptions/InvalidCombinationException.java new file mode 100644 index 0000000..168c293 --- /dev/null +++ b/tichu-common/src/main/java/eu/jonahbauer/tichu/common/model/exceptions/InvalidCombinationException.java @@ -0,0 +1,25 @@ +package eu.jonahbauer.tichu.common.model.exceptions; + +import eu.jonahbauer.tichu.common.model.Card; +import eu.jonahbauer.tichu.common.model.CombinationType; +import lombok.Getter; +import org.jetbrains.annotations.NotNull; + +import java.util.List; +import java.util.Objects; + +@Getter +public class InvalidCombinationException extends IllegalArgumentException { + private final @NotNull CombinationType type; + private final @NotNull List<@NotNull Card> cards; + + public InvalidCombinationException(@NotNull CombinationType type, @NotNull List<@NotNull Card> cards) { + this.type = Objects.requireNonNull(type, "type must not be null"); + this.cards = List.copyOf(Objects.requireNonNull(cards, "cards must not be null")); // List#copyOf performs null-checks + } + + @Override + public String getMessage() { + return "%s does not compose a valid %s".formatted(cards, type); + } +} diff --git a/tichu-common/src/main/java/eu/jonahbauer/tichu/common/model/exceptions/TooLowCombinationException.java b/tichu-common/src/main/java/eu/jonahbauer/tichu/common/model/exceptions/TooLowCombinationException.java new file mode 100644 index 0000000..cd29f1c --- /dev/null +++ b/tichu-common/src/main/java/eu/jonahbauer/tichu/common/model/exceptions/TooLowCombinationException.java @@ -0,0 +1,24 @@ +package eu.jonahbauer.tichu.common.model.exceptions; + +import eu.jonahbauer.tichu.common.model.Combination; +import eu.jonahbauer.tichu.common.model.Stack; +import lombok.Getter; +import org.jetbrains.annotations.NotNull; + +import java.util.Objects; + +@Getter +public class TooLowCombinationException extends IllegalArgumentException { + private final @NotNull Stack stack; + private final @NotNull Combination combination; + + public TooLowCombinationException(@NotNull Stack stack, @NotNull Combination combination) { + this.stack = Objects.requireNonNull(stack, "stack must not be null"); + this.combination = Objects.requireNonNull(combination, "combination must not be null"); + } + + @Override + public String getMessage() { + return "Cannot play %s on top of %s.".formatted(combination, stack); + } +} diff --git a/tichu-common/src/main/java/module-info.java b/tichu-common/src/main/java/module-info.java new file mode 100644 index 0000000..e7832da --- /dev/null +++ b/tichu-common/src/main/java/module-info.java @@ -0,0 +1,7 @@ +module eu.jonahbauer.tichu.common { + exports eu.jonahbauer.tichu.common.model; + exports eu.jonahbauer.tichu.common.model.exceptions; + + requires static lombok; + requires static org.jetbrains.annotations; +} \ No newline at end of file diff --git a/tichu-common/src/test/java/eu/jonahbauer/tichu/common/model/CardTest.java b/tichu-common/src/test/java/eu/jonahbauer/tichu/common/model/CardTest.java new file mode 100644 index 0000000..9083f98 --- /dev/null +++ b/tichu-common/src/test/java/eu/jonahbauer/tichu/common/model/CardTest.java @@ -0,0 +1,56 @@ +package eu.jonahbauer.tichu.common.model; + +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.EnumSource; + +import java.util.Set; + +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertTrue; + +public class CardTest { + + @ParameterizedTest + @EnumSource(Card.class) + public void testGetNormalCardsContainsAllNormalCards(Card card) { + Set normalCards = Card.getNormalCards(); + + if (card.isNormal()) { + assertTrue( + normalCards.contains(card), + "getNormalCards() should contain normal card %s".formatted(card) + ); + } else { + assertFalse( + normalCards.contains(card), + "getNormalCards() should not contain non-normal card %s".formatted(card) + ); + } + } + + @ParameterizedTest + @EnumSource(Card.class) + public void testGetSpecialCardsContainsAllSpecialCards(Card card) { + Set specialCards = Card.getSpecialCards(); + if (card.isSpecial()) { + assertTrue( + specialCards.contains(card), + "getSpecialCards() should contain special card %s".formatted(card) + ); + } else { + assertFalse( + specialCards.contains(card), + "getSpecialCards() should not contain non-special card %s".formatted(card) + ); + } + } + + @ParameterizedTest + @EnumSource(Card.class) + public void testIsNormalXorIsSpecial(Card card) { + assertTrue( + card.isNormal() ^ card.isSpecial(), + "Card %s should be normal xor special".formatted(card) + ); + } +} diff --git a/tichu-common/src/test/java/eu/jonahbauer/tichu/common/model/CombinationTest.java b/tichu-common/src/test/java/eu/jonahbauer/tichu/common/model/CombinationTest.java new file mode 100644 index 0000000..cc6dbd0 --- /dev/null +++ b/tichu-common/src/test/java/eu/jonahbauer/tichu/common/model/CombinationTest.java @@ -0,0 +1,333 @@ +package eu.jonahbauer.tichu.common.model; + +import eu.jonahbauer.tichu.common.model.exceptions.IncompatibleCombinationException; +import eu.jonahbauer.tichu.common.model.exceptions.InvalidCombinationException; +import eu.jonahbauer.tichu.common.model.exceptions.TooLowCombinationException; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.EnumSource; + +import java.util.Arrays; +import java.util.List; +import java.util.Map; +import java.util.stream.IntStream; +import java.util.stream.Stream; + +import static eu.jonahbauer.tichu.common.model.Card.*; +import static eu.jonahbauer.tichu.common.model.CombinationType.*; +import static java.util.function.Predicate.not; +import static org.junit.jupiter.api.Assertions.*; + +public class CombinationTest { + private static final Map>>> VALID_COMBINATIONS = Map.of( + SINGLE, Stream.concat( + Arrays.stream(Card.values()) // [[RED_2]], [[GREEN_2]], [[BLUE_2]], ... + .map(List::of) + .map(List::of), + Arrays.stream(Suit.values()) // [[RED_2], [RED_3], [RED_4], ...], [[BLUE_2], ...], ... + .map(suit -> IntStream.rangeClosed(2, 14) + .mapToObj(Card::getCardsByValue) + .flatMap(cards -> cards.stream() + .filter(card -> card.getSuit() == suit) + ) + .map(List::of) + .toList() + ) + ).toList(), + PAIR, List.of(List.of( + List.of(BLUE_5, RED_5), + List.of(BLUE_JACK, BLACK_JACK), + List.of(PHOENIX, GREEN_KING), + List.of(RED_ACE, PHOENIX) + )), + TRIPLE, List.of(List.of( + List.of(BLUE_5, RED_5, GREEN_5), + List.of(BLUE_7, RED_7, PHOENIX), + List.of(BLUE_10, PHOENIX, BLACK_10), + List.of(PHOENIX, BLUE_QUEEN, RED_QUEEN) + )), + FULL_HOUSE, List.of(List.of( + List.of(BLUE_5, RED_5, GREEN_5, BLACK_10, GREEN_10), + List.of(BLUE_6, RED_6, GREEN_6, BLACK_4, PHOENIX), + List.of(BLUE_8, RED_8, GREEN_8, PHOENIX, GREEN_4), + List.of(BLUE_9, RED_9, PHOENIX, BLACK_ACE, GREEN_ACE), + List.of(BLUE_JACK, PHOENIX, GREEN_JACK, BLACK_4, GREEN_4), + List.of(PHOENIX, RED_KING, GREEN_KING, BLACK_4, GREEN_4) + )), + SEQUENCE, List.of( + List.of( + List.of(MAHJONG, RED_2, BLACK_3, RED_4, BLUE_5) + ), + List.of( + List.of(MAHJONG, PHOENIX, BLACK_3, RED_4, BLUE_5) + ), + List.of( + List.of(MAHJONG, RED_2, BLACK_3, PHOENIX, BLUE_5), + List.of(BLUE_4, RED_5, BLACK_6, RED_7, BLUE_8), + List.of(PHOENIX, RED_6, BLACK_7, RED_8, BLUE_9), + List.of(BLUE_10, RED_JACK, BLACK_QUEEN, RED_KING, PHOENIX) + ), + List.of( + List.of(RED_6, BLUE_7, BLUE_8, RED_9, BLACK_10, RED_JACK, BLUE_QUEEN), + List.of(PHOENIX, BLUE_8, BLUE_9, RED_10, BLACK_JACK, RED_QUEEN, BLUE_KING), + List.of(RED_8, BLUE_9, BLUE_10, RED_JACK, BLACK_QUEEN, RED_KING, PHOENIX) + ) + ), + PAIR_SEQUENCE, List.of( + List.of( + List.of(RED_2, BLUE_2, GREEN_3, BLACK_3), + List.of(RED_3, BLUE_3, PHOENIX, BLACK_4) + ), + List.of( + List.of(RED_2, BLUE_2, GREEN_3, BLACK_3, BLACK_4, RED_4), + List.of(RED_5, BLUE_5, PHOENIX, BLACK_6, BLACK_7, RED_7) + ) + ), + BOMB, List.of( + IntStream.rangeClosed(2, 14).mapToObj(Card::getCardsByValue).map(List::copyOf).toList() + ), + BOMB_SEQUENCE, List.of(List.of( + List.of(RED_2, RED_3, RED_4, RED_5, RED_6), + List.of(RED_2, RED_3, RED_4, RED_5, RED_6, RED_7, RED_8, RED_9), + List.of(BLUE_7, BLUE_8, BLUE_9, BLUE_10, BLUE_JACK, BLUE_QUEEN, BLUE_KING, BLUE_ACE) + )) + ); + + private static final Map>> INVALID_COMBINATIONS = Map.of( + SINGLE, List.of( + List.of(BLUE_2, RED_2), // too many cards + List.of(BLUE_2, RED_3) // too many cards + ), + PAIR, List.of( + List.of(RED_ACE, BLUE_ACE, GREEN_ACE), // too many cards + List.of(RED_ACE, RED_ACE), // duplicate card + List.of(RED_KING, RED_ACE), // different cards + List.of(MAHJONG, PHOENIX), // phoenix cannot be mahjong / mahjong cannot be part of a tuple + List.of(DRAGON, PHOENIX), // phoenix cannot be dragon / dragon cannot be part of a tuple + List.of(HOUND, PHOENIX), // phoenix cannot be hound / hound cannot be part of a tuple + List.of(RED_5) // too few cards + ), + TRIPLE, List.of( + List.of(RED_ACE, BLUE_ACE, GREEN_ACE, BLACK_ACE), // too many cards + List.of(RED_ACE, BLUE_ACE, BLUE_ACE), // duplicate card + List.of(RED_ACE, BLUE_ACE, GREEN_KING), // different cards + List.of(MAHJONG, DRAGON, PHOENIX), + List.of(RED_ACE, BLUE_ACE), // too few cards + List.of(RED_ACE) // too few cards + ), + FULL_HOUSE, List.of( + List.of(BLUE_5, RED_5, BLUE_5, BLACK_4, GREEN_4, BLUE_4), // too many cards + List.of(BLUE_5, RED_5, BLUE_5, BLACK_4), // too few cards + List.of(BLUE_5, RED_5, BLUE_4, BLACK_4, GREEN_4), // wrong order + List.of(BLUE_5, RED_5, BLUE_6, BLACK_4, GREEN_4), // different cards in triple + List.of(BLUE_5, RED_5, GREEN_5, BLACK_3, GREEN_4), // different cards in pair + List.of(BLUE_5, RED_5, GREEN_5, BLUE_5, GREEN_5), // duplicate cards + List.of(BLUE_5, PHOENIX, GREEN_5, PHOENIX, RED_5), // duplicate cards + List.of(BLUE_5, RED_5, GREEN_5, DRAGON, GREEN_4), // dragon cannot be part of a tuple + List.of(BLUE_5, RED_5, GREEN_5, HOUND, GREEN_4), // hound cannot be part of a tuple + List.of(BLUE_5, RED_5, GREEN_5, MAHJONG, PHOENIX) // mahjong cannot be part of a tuple + ), + SEQUENCE, List.of( + List.of(RED_ACE, RED_2, BLACK_3, RED_4, BLUE_5), // ace is no 1 + List.of(RED_2, BLACK_3, RED_4, BLUE_5), // too few cards + List.of(RED_2, BLACK_3, RED_4), // too few cards + List.of(RED_2, BLACK_3), // too few cards + List.of(RED_2), // too few cards + List.of(PHOENIX, MAHJONG, RED_2, BLACK_3, PHOENIX, BLUE_5), // phoenix cannot be 0 + List.of(RED_2, MAHJONG, BLACK_4, RED_5, GREEN_6), // wrong order + List.of(BLACK_8, GREEN_7, BLUE_6, RED_5, GREEN_4), // wrong order + List.of(BLACK_10, GREEN_JACK, BLUE_QUEEN, RED_KING, GREEN_ACE, DRAGON), // dragon cannot be part of sequence + List.of(BLACK_10, GREEN_JACK, BLUE_QUEEN, RED_KING, GREEN_ACE, PHOENIX) // phoenix cannot be higher than ace + ), + PAIR_SEQUENCE, List.of( + List.of(RED_2, BLUE_2), // too few cards + List.of(RED_2, BLUE_2, GREEN_3), // too few cards + List.of(RED_2, BLUE_3, GREEN_4, BLUE_5), // missing pairs + List.of(RED_2, BLUE_2, GREEN_3, BLUE_3, BLACK_4), // partially missing pairs + List.of(RED_3, BLUE_3, GREEN_2, BLUE_2), // wrong order + List.of(PHOENIX, MAHJONG, RED_2, BLUE_2), // phoenix cannot be mahjong + List.of(RED_ACE, BLUE_ACE, DRAGON, PHOENIX) // phoenix cannot be dragon + ), + BOMB, List.of( + List.of(RED_2, RED_2, BLUE_2), // too few cards + List.of(RED_2, RED_2, BLUE_2, GREEN_2, BLACK_2), // too many cards / duplicate cards + List.of(RED_2, BLUE_2, GREEN_2, RED_2), // duplicate cards + List.of(RED_2, BLUE_2, GREEN_2, RED_3), // different cards + List.of(RED_2, BLUE_2, GREEN_2, PHOENIX) // phoenix cannot be part of a bomb + ), + BOMB_SEQUENCE, List.of( + List.of(RED_2, RED_3, RED_4, BLACK_5, RED_6), // wrong suit + List.of(BLUE_10, PHOENIX, BLUE_QUEEN, BLUE_KING, BLUE_ACE), // phoenix cannot be part of a bomb + List.of(MAHJONG, RED_2, RED_3, RED_4, RED_5), // mahjong cannot be part of a bomb + List.of(RED_2, RED_3, RED_4, RED_5), // too few cards + List.of(RED_2, RED_3, RED_4, RED_5, PHOENIX), // phoenix cannot be part of a bomb + List.of(RED_2, RED_3, RED_5, RED_4, RED_6), // wrong order + List.of(RED_6, RED_5, RED_4, RED_3, RED_2) // wrong order + ) + ); + + @ParameterizedTest + @EnumSource(CombinationType.class) + public void testCheck(CombinationType type) { + VALID_COMBINATIONS.get(type).stream().flatMap(List::stream).forEach(cards -> assertValidCombination(type, cards)); + INVALID_COMBINATIONS.get(type).forEach(cards -> assertInvalidCombination(type, cards)); + } + + @ParameterizedTest + @EnumSource(CombinationType.class) + public void testIsCompatible(CombinationType type) { + VALID_COMBINATIONS.get(type).forEach(combinations -> { + for (List combinationA : combinations) { + for (List combinationB : combinations) { + assertIsCompatible(type, combinationA, combinationB); + } + } + }); + } + + @ParameterizedTest + @EnumSource(CombinationType.class) + public void testIsHigher(CombinationType type) { + VALID_COMBINATIONS.get(type).forEach(combinations -> { + Stack stack = Stack.empty(); + for (int i = 0; i < combinations.size(); i++) { + stack = stack.put(new Combination(type, combinations.get(i))); + for (int j = 0; j < i; j++) { + assertIsNotHigher(stack, type, combinations.get(j)); + } + + for (int j = i + 1; j < combinations.size(); j++) { + assertIsHigher(stack, type, combinations.get(j)); + } + } + }); + } + + @Test + public void testBombIsCompatible() { + List stacks = VALID_COMBINATIONS.entrySet().stream() + .flatMap(entry -> entry.getValue().stream() + .flatMap(List::stream) + .filter(not(cards -> cards.size() == 1 && cards.get(0) == HOUND)) // hound cannot be bombed + .map(cards -> new Combination(entry.getKey(), cards)) + ) + .map(Stack::of) + .toList(); + Stack hound = Stack.of(Combination.single(HOUND)); + + VALID_COMBINATIONS.get(BOMB).stream() + .flatMap(List::stream) + .forEach(bomb -> { + stacks.forEach(stack -> assertIsCompatible(stack, BOMB, bomb)); + assertIsIncompatible(hound, BOMB, bomb); + }); + + VALID_COMBINATIONS.get(BOMB_SEQUENCE).stream() + .flatMap(List::stream) + .forEach(bomb -> { + stacks.forEach(stack -> assertIsCompatible(stack, BOMB_SEQUENCE, bomb)); + assertIsIncompatible(hound, BOMB_SEQUENCE, bomb); + }); + } + + private static void assertValidCombination(CombinationType type, List cards) { + try { + new Combination(type, cards); + } catch (InvalidCombinationException e) { + fail("%s should be a valid %s.".formatted(cards, type), e); + } + + assertTrue( + type.check(cards), + "%s should be a valid %s.".formatted(cards, type) + ); + } + + private static void assertInvalidCombination(CombinationType type, List cards) { + assertThrows(InvalidCombinationException.class, () -> { + new Combination(type, cards); + }, "%s should not be a valid %s.".formatted(cards, type)); + + assertFalse( + type.check(cards), + "%s should not be a valid %s.".formatted(cards, type) + ); + } + + private static void assertIsCompatible(CombinationType type, List stack, List cards) { + assertIsCompatible(Stack.of(new Combination(type, stack)), type, cards); + } + + private static void assertIsCompatible(Stack stack, CombinationType type, List cards) { + assertTrue( + type.isCompatible(stack, cards), + "%s should be compatible with %s".formatted(cards, stack) + ); + + assertTrue( + stack.isCompatible(new Combination(type, cards)), + "%s should be compatible with %s".formatted(cards, stack) + ); + } + + private static void assertIsIncompatible(Stack stack, CombinationType type, List cards) { + assertFalse( + type.isCompatible(stack, cards), + "%s should be incompatible with %s".formatted(cards, stack) + ); + + assertFalse( + stack.isCompatible(new Combination(type, cards)), + "%s should be incompatible with %s".formatted(cards, stack) + ); + + assertFalse( + stack.isCompatibleAndHigher(new Combination(type, cards)), + "%s should be incompatible with %s".formatted(cards, stack) + ); + + assertThrows(IncompatibleCombinationException.class, () -> { + //noinspection ResultOfMethodCallIgnored + stack.put(new Combination(type, cards)); + }, "%s should be incompatible with %s".formatted(cards, stack)); + } + + private static void assertIsHigher(Stack stack, CombinationType type, List cards) { + assertTrue( + type.isHigher(stack, cards), + "%s should be higher than %s".formatted(cards, stack) + ); + + assertTrue( + stack.isHigher(new Combination(type, cards)), + "%s should be higher than %s".formatted(cards, stack) + ); + + assertTrue( + stack.isCompatibleAndHigher(new Combination(type, cards)), + "%s should be higher than %s".formatted(cards, stack) + ); + } + + private static void assertIsNotHigher(Stack stack, CombinationType type, List cards) { + assertFalse( + type.isHigher(stack, cards), + "%s should not be higher than %s".formatted(cards, stack) + ); + + assertFalse( + stack.isHigher(new Combination(type, cards)), + "%s should not be higher than %s".formatted(cards, stack) + ); + + assertFalse( + stack.isCompatibleAndHigher(new Combination(type, cards)), + "%s should not be higher than %s".formatted(cards, stack) + ); + + assertThrows(TooLowCombinationException.class, () -> { + //noinspection ResultOfMethodCallIgnored + stack.put(new Combination(type, cards)); + }); + } +}