diff --git a/pom.xml b/pom.xml index 5aa2bc7..a2026d7 100644 --- a/pom.xml +++ b/pom.xml @@ -19,6 +19,7 @@ 17 2.14.1 1.18.22 + UTF-8 @@ -34,7 +35,13 @@ provided + + com.google.code.gson + gson + 2.8.8 + + org.apache.logging.log4j log4j-api @@ -50,6 +57,32 @@ log4j-slf4j-impl ${log4j.version} + + + + org.junit.jupiter + junit-jupiter + 5.8.1 + test + + + org.junit.jupiter + junit-jupiter-engine + 5.8.1 + test + + + org.mockito + mockito-inline + 4.0.0 + test + + + org.mockito + mockito-junit-jupiter + 4.0.0 + test + @@ -74,6 +107,11 @@ maven-assembly-plugin 3.3.0 + + org.apache.maven.plugins + maven-surefire-plugin + 3.0.0-M5 + diff --git a/wizard-core/src/main/java/com/google/gson/typeadapters/RuntimeTypeAdapterFactory.java b/wizard-core/src/main/java/com/google/gson/typeadapters/RuntimeTypeAdapterFactory.java new file mode 100644 index 0000000..78cb7fe --- /dev/null +++ b/wizard-core/src/main/java/com/google/gson/typeadapters/RuntimeTypeAdapterFactory.java @@ -0,0 +1,273 @@ +/* + * Copyright (C) 2011 Google Inc. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.google.gson.typeadapters; + +import com.google.gson.*; +import com.google.gson.reflect.TypeToken; +import com.google.gson.stream.JsonReader; +import com.google.gson.stream.JsonWriter; + +import java.io.IOException; +import java.util.LinkedHashMap; +import java.util.Map; + +/** + * Adapts values whose runtime type may differ from their declaration type. This + * is necessary when a field's type is not the same type that GSON should create + * when deserializing that field. For example, consider these types: + *
   {@code
+ *   abstract class Shape {
+ *     int x;
+ *     int y;
+ *   }
+ *   class Circle extends Shape {
+ *     int radius;
+ *   }
+ *   class Rectangle extends Shape {
+ *     int width;
+ *     int height;
+ *   }
+ *   class Diamond extends Shape {
+ *     int width;
+ *     int height;
+ *   }
+ *   class Drawing {
+ *     Shape bottomShape;
+ *     Shape topShape;
+ *   }
+ * }
+ *

Without additional type information, the serialized JSON is ambiguous. Is + * the bottom shape in this drawing a rectangle or a diamond?

   {@code
+ *   {
+ *     "bottomShape": {
+ *       "width": 10,
+ *       "height": 5,
+ *       "x": 0,
+ *       "y": 0
+ *     },
+ *     "topShape": {
+ *       "radius": 2,
+ *       "x": 4,
+ *       "y": 1
+ *     }
+ *   }}
+ * This class addresses this problem by adding type information to the + * serialized JSON and honoring that type information when the JSON is + * deserialized:
   {@code
+ *   {
+ *     "bottomShape": {
+ *       "type": "Diamond",
+ *       "width": 10,
+ *       "height": 5,
+ *       "x": 0,
+ *       "y": 0
+ *     },
+ *     "topShape": {
+ *       "type": "Circle",
+ *       "radius": 2,
+ *       "x": 4,
+ *       "y": 1
+ *     }
+ *   }}
+ * Both the type field name ({@code "type"}) and the type labels ({@code + * "Rectangle"}) are configurable. + * + *

Registering Types

+ * Create a {@code RuntimeTypeAdapterFactory} by passing the base type and type field + * name to the {@link #of} factory method. If you don't supply an explicit type + * field name, {@code "type"} will be used.
   {@code
+ *   RuntimeTypeAdapterFactory shapeAdapterFactory
+ *       = RuntimeTypeAdapterFactory.of(Shape.class, "type");
+ * }
+ * Next register all of your subtypes. Every subtype must be explicitly + * registered. This protects your application from injection attacks. If you + * don't supply an explicit type label, the type's simple name will be used. + *
   {@code
+ *   shapeAdapterFactory.registerSubtype(Rectangle.class, "Rectangle");
+ *   shapeAdapterFactory.registerSubtype(Circle.class, "Circle");
+ *   shapeAdapterFactory.registerSubtype(Diamond.class, "Diamond");
+ * }
+ * Finally, register the type adapter factory in your application's GSON builder: + *
   {@code
+ *   Gson gson = new GsonBuilder()
+ *       .registerTypeAdapterFactory(shapeAdapterFactory)
+ *       .create();
+ * }
+ * Like {@code GsonBuilder}, this API supports chaining:
   {@code
+ *   RuntimeTypeAdapterFactory shapeAdapterFactory = RuntimeTypeAdapterFactory.of(Shape.class)
+ *       .registerSubtype(Rectangle.class)
+ *       .registerSubtype(Circle.class)
+ *       .registerSubtype(Diamond.class);
+ * }
+ * + *

Serialization and deserialization

+ * In order to serialize and deserialize a polymorphic object, + * you must specify the base type explicitly. + *
   {@code
+ *   Diamond diamond = new Diamond();
+ *   String json = gson.toJson(diamond, Shape.class);
+ * }
+ * And then: + *
   {@code
+ *   Shape shape = gson.fromJson(json, Shape.class);
+ * }
+ */ +@SuppressWarnings("ALL") +public final class RuntimeTypeAdapterFactory implements TypeAdapterFactory { + private final Class baseType; + private final String typeFieldName; + private final Map> labelToSubtype = new LinkedHashMap<>(); + private final Map, String> subtypeToLabel = new LinkedHashMap<>(); + private final boolean maintainType; + + private RuntimeTypeAdapterFactory(Class baseType, String typeFieldName, boolean maintainType) { + if (typeFieldName == null || baseType == null) { + throw new NullPointerException(); + } + this.baseType = baseType; + this.typeFieldName = typeFieldName; + this.maintainType = maintainType; + } + + /** + * Creates a new runtime type adapter using for {@code baseType} using {@code + * typeFieldName} as the type field name. Type field names are case sensitive. + * {@code maintainType} flag decide if the type will be stored in pojo or not. + */ + public static RuntimeTypeAdapterFactory of(Class baseType, String typeFieldName, boolean maintainType) { + return new RuntimeTypeAdapterFactory<>(baseType, typeFieldName, maintainType); + } + + /** + * Creates a new runtime type adapter using for {@code baseType} using {@code + * typeFieldName} as the type field name. Type field names are case sensitive. + */ + public static RuntimeTypeAdapterFactory of(Class baseType, String typeFieldName) { + return new RuntimeTypeAdapterFactory<>(baseType, typeFieldName, false); + } + + /** + * Creates a new runtime type adapter for {@code baseType} using {@code "type"} as + * the type field name. + */ + public static RuntimeTypeAdapterFactory of(Class baseType) { + return new RuntimeTypeAdapterFactory<>(baseType, "type", false); + } + + /** + * Registers {@code type} identified by {@code label}. Labels are case + * sensitive. + * + * @throws IllegalArgumentException if either {@code type} or {@code label} + * have already been registered on this type adapter. + */ + public RuntimeTypeAdapterFactory registerSubtype(Class type, String label) { + if (type == null || label == null) { + throw new NullPointerException(); + } + if (subtypeToLabel.containsKey(type) || labelToSubtype.containsKey(label)) { + throw new IllegalArgumentException("types and labels must be unique"); + } + labelToSubtype.put(label, type); + subtypeToLabel.put(type, label); + return this; + } + + /** + * Registers {@code type} identified by its {@link Class#getSimpleName simple + * name}. Labels are case sensitive. + * + * @throws IllegalArgumentException if either {@code type} or its simple name + * have already been registered on this type adapter. + */ + public RuntimeTypeAdapterFactory registerSubtype(Class type) { + return registerSubtype(type, type.getSimpleName()); + } + + @Override + public TypeAdapter create(Gson gson, TypeToken type) { + if (type.getRawType() != baseType) { + return null; + } + + final TypeAdapter jsonElementAdapter = gson.getAdapter(JsonElement.class); + final Map> labelToDelegate + = new LinkedHashMap<>(); + final Map, TypeAdapter> subtypeToDelegate + = new LinkedHashMap<>(); + for (Map.Entry> entry : labelToSubtype.entrySet()) { + TypeAdapter delegate = gson.getDelegateAdapter(this, TypeToken.get(entry.getValue())); + labelToDelegate.put(entry.getKey(), delegate); + subtypeToDelegate.put(entry.getValue(), delegate); + } + + return new TypeAdapter() { + @Override public R read(JsonReader in) throws IOException { + JsonElement jsonElement = jsonElementAdapter.read(in); + JsonElement labelJsonElement; + if (maintainType) { + labelJsonElement = jsonElement.getAsJsonObject().get(typeFieldName); + } else { + labelJsonElement = jsonElement.getAsJsonObject().remove(typeFieldName); + } + + if (labelJsonElement == null) { + throw new JsonParseException("cannot deserialize " + baseType + + " because it does not define a field named " + typeFieldName); + } + String label = labelJsonElement.getAsString(); + @SuppressWarnings("unchecked") // registration requires that subtype extends T + TypeAdapter delegate = (TypeAdapter) labelToDelegate.get(label); + if (delegate == null) { + throw new JsonParseException("cannot deserialize " + baseType + " subtype named " + + label + "; did you forget to register a subtype?"); + } + return delegate.fromJsonTree(jsonElement); + } + + @Override public void write(JsonWriter out, R value) throws IOException { + Class srcType = value.getClass(); + String label = subtypeToLabel.get(srcType); + @SuppressWarnings("unchecked") // registration requires that subtype extends T + TypeAdapter delegate = (TypeAdapter) subtypeToDelegate.get(srcType); + if (delegate == null) { + throw new JsonParseException("cannot serialize " + srcType.getName() + + "; did you forget to register a subtype?"); + } + JsonObject jsonObject = delegate.toJsonTree(value).getAsJsonObject(); + + if (maintainType) { + jsonElementAdapter.write(out, jsonObject); + return; + } + + JsonObject clone = new JsonObject(); + + if (jsonObject.has(typeFieldName)) { + throw new JsonParseException("cannot serialize " + srcType.getName() + + " because it already defines a field named " + typeFieldName); + } + clone.add(typeFieldName, new JsonPrimitive(label)); + + for (Map.Entry e : jsonObject.entrySet()) { + clone.add(e.getKey(), e.getValue()); + } + jsonElementAdapter.write(out, clone); + } + }.nullSafe(); + } +} diff --git a/wizard-core/src/main/java/eu/jonahbauer/wizard/core/CLI.java b/wizard-core/src/main/java/eu/jonahbauer/wizard/core/CLI.java new file mode 100644 index 0000000..bca3c31 --- /dev/null +++ b/wizard-core/src/main/java/eu/jonahbauer/wizard/core/CLI.java @@ -0,0 +1,68 @@ +package eu.jonahbauer.wizard.core; + +import eu.jonahbauer.wizard.core.machine.Game; +import eu.jonahbauer.wizard.core.messages.player.PickTrumpMessage; +import eu.jonahbauer.wizard.core.messages.player.PlayCardMessage; +import eu.jonahbauer.wizard.core.messages.player.PredictMessage; +import eu.jonahbauer.wizard.core.model.Card; +import eu.jonahbauer.wizard.core.model.Configurations; + +import java.util.List; +import java.util.Scanner; +import java.util.UUID; +import java.util.regex.Matcher; +import java.util.regex.Pattern; + +public class CLI { + public static void main(String[] args) { + Game game = new Game(Configurations.DEFAULT, (player, msg) -> System.out.println(msg)); + var players = List.of( + UUID.randomUUID(), + UUID.randomUUID(), + UUID.randomUUID(), + UUID.randomUUID() + ); + + game.start(players); + + Scanner scanner = new Scanner(System.in); + Pattern pattern = Pattern.compile("(\\d) (predict|play|trump) (.*)"); + while (scanner.hasNextLine()) { + try { + Matcher matcher = pattern.matcher(scanner.nextLine()); + if (!matcher.find()) { + System.err.println("Format is \"(\\\\d) (predict|play|trump) (.*)\""); + continue; + } + String player = matcher.group(1); + String command = matcher.group(2); + String param = matcher.group(3); + + int id = Integer.parseInt(player); + + if (id > players.size()) { + System.err.println("ID must be between 0 and " + (players.size() - 1)); + continue; + } + + switch (command) { + case "predict" -> { + int prediction = Integer.parseInt(param); + game.onMessage(players.get(id), new PredictMessage(prediction)); + } + case "play" -> { + Card card = Card.valueOf(param); + game.onMessage(players.get(id), new PlayCardMessage(card)); + } + case "trump" -> { + Card.Suit suit = Card.Suit.valueOf(param); + game.onMessage(players.get(id), new PickTrumpMessage(suit)); + } + default -> System.err.println("Unknown command: " + command); + } + } catch (Exception e) { + System.err.println(e.getMessage()); + } + } + } +} diff --git a/wizard-core/src/main/java/eu/jonahbauer/wizard/core/machine/Context.java b/wizard-core/src/main/java/eu/jonahbauer/wizard/core/machine/Context.java new file mode 100644 index 0000000..e333aed --- /dev/null +++ b/wizard-core/src/main/java/eu/jonahbauer/wizard/core/machine/Context.java @@ -0,0 +1,107 @@ +package eu.jonahbauer.wizard.core.machine; + +import eu.jonahbauer.wizard.core.machine.states.State; +import org.jetbrains.annotations.Blocking; +import org.jetbrains.annotations.NotNull; + +import java.util.concurrent.*; +import java.util.concurrent.locks.Condition; +import java.util.concurrent.locks.ReentrantLock; + +public abstract class Context { + protected final ScheduledExecutorService scheduler = Executors.newSingleThreadScheduledExecutor(); + protected T state; + protected final ReentrantLock lock = new ReentrantLock(); + private final Condition finishCondition = lock.newCondition(); + private boolean finished; + private Throwable exception; + + protected void start(@NotNull T state) { + lock.lock(); + try { + if (finished) throw new IllegalStateException("Context has already finished."); + transition(null, state); + } finally { + lock.unlock(); + } + } + + public void transition(T currentState, T newState) { + lock.lock(); + try { + if (state == currentState) { + state = newState; + if (currentState != null) currentState.onExit(); + onTransition(currentState, newState); + if (newState != null) newState.onEnter(); + } else { + throw new IllegalStateException("Current state does not match."); + } + } catch (Throwable t) { + handleError(t); + } finally { + lock.unlock(); + } + } + + public void finish() { + finish(null); + } + + public void finish(Throwable exception) { + lock.lock(); + try { + finished = true; + this.exception = exception; + finishCondition.signalAll(); + transition(state, null); + scheduler.shutdown(); + } finally { + lock.unlock(); + } + } + + public void cancel() { + finish(new CancellationException()); + } + + @Blocking + public void await() throws InterruptedException, ExecutionException, CancellationException { + lock.lock(); + try { + while (!finished) { + finishCondition.await(); + } + if (exception != null) { + if (exception instanceof CancellationException cancelled) { + throw cancelled; + } else { + throw new ExecutionException(exception); + } + } + } finally { + lock.unlock(); + } + } + + public void timeout(@NotNull T currentState, long delay) { + scheduler.schedule(() -> { + lock.lock(); + try { + if (state == currentState) { + state.onTimeout(); + } + } catch (Throwable t) { + handleError(t); + } finally { + lock.unlock(); + } + }, delay, TimeUnit.MILLISECONDS); + } + + protected void handleError(Throwable t) { + finish(t); + } + + protected void onTransition(T from, T to) {} +} diff --git a/wizard-core/src/main/java/eu/jonahbauer/wizard/core/machine/Game.java b/wizard-core/src/main/java/eu/jonahbauer/wizard/core/machine/Game.java new file mode 100644 index 0000000..f60f2a3 --- /dev/null +++ b/wizard-core/src/main/java/eu/jonahbauer/wizard/core/machine/Game.java @@ -0,0 +1,66 @@ +package eu.jonahbauer.wizard.core.machine; + +import eu.jonahbauer.wizard.core.machine.states.GameState; +import eu.jonahbauer.wizard.core.machine.states.game.Starting; +import eu.jonahbauer.wizard.core.messages.Observer; +import eu.jonahbauer.wizard.core.messages.observer.ObserverMessage; +import eu.jonahbauer.wizard.core.messages.observer.StateMessage; +import eu.jonahbauer.wizard.core.messages.player.PlayerMessage; +import eu.jonahbauer.wizard.core.model.Configuration; +import lombok.Getter; + +import java.util.List; +import java.util.UUID; + +public final class Game extends Context { + @Getter + private final Configuration config; + private final Observer observer; + + public Game(Configuration config, Observer observer) { + this.config = config; + this.observer = observer; + } + + public void start(List players) { + start(new Starting(this, GameData.builder().players(players).build())); + } + + public void resume(GameState state) { + start(state); + } + + public void onMessage(UUID player, PlayerMessage message) { + lock.lock(); + try { + state.onMessage(player, message); + } catch (IllegalStateException | IllegalArgumentException e) { + throw e; + } catch (Throwable t) { + handleError(t); + } finally { + lock.unlock(); + } + } + + @Override + protected void onTransition(GameState from, GameState to) { + notify(new StateMessage(to != null ? to.getClass() : null)); + } + + public void notify(ObserverMessage message) { + try { + observer.notify(message); + } catch (Exception e) { + e.printStackTrace(); + } + } + + public void notify(UUID player, ObserverMessage message) { + try { + observer.notify(player, message); + } catch (Exception e) { + e.printStackTrace(); + } + } +} diff --git a/wizard-core/src/main/java/eu/jonahbauer/wizard/core/machine/GameData.java b/wizard-core/src/main/java/eu/jonahbauer/wizard/core/machine/GameData.java new file mode 100644 index 0000000..f4ddea3 --- /dev/null +++ b/wizard-core/src/main/java/eu/jonahbauer/wizard/core/machine/GameData.java @@ -0,0 +1,334 @@ +package eu.jonahbauer.wizard.core.machine; + + +import eu.jonahbauer.wizard.core.model.Card; +import eu.jonahbauer.wizard.core.util.Pair; +import lombok.*; +import org.jetbrains.annotations.NotNull; +import org.jetbrains.annotations.Nullable; +import org.jetbrains.annotations.Unmodifiable; + +import java.util.*; + +@With +@Getter +@EqualsAndHashCode +@AllArgsConstructor(access = AccessLevel.PRIVATE) +@SuppressWarnings({"unused", "UnusedReturnValue", "OptionalUsedAsFieldOrParameterType", "OptionalAssignedToNull"}) +public final class GameData { + private final @Unmodifiable List players; + private final @Unmodifiable Map score; + + private final Integer round; + private final @Unmodifiable Map> hands; + private final Optional trumpCard; + private final Card.Suit trumpSuit; + private final @Unmodifiable Map predictions; + private final @Unmodifiable Map tricks; + + private final Integer trick; + private final @Unmodifiable List> stack; + + private final UUID currentPlayer; + + private @EqualsAndHashCode.Exclude transient boolean requiredPlayers; + private @EqualsAndHashCode.Exclude transient boolean requiredRound; + private @EqualsAndHashCode.Exclude transient boolean requiredTrick; + private @EqualsAndHashCode.Exclude transient boolean requiredHands; + private @EqualsAndHashCode.Exclude transient boolean requiredTrumpCard; + private @EqualsAndHashCode.Exclude transient boolean requiredTrumpSuit; + private @EqualsAndHashCode.Exclude transient boolean requiredStack; + private @EqualsAndHashCode.Exclude transient boolean requiredPredictions; + private @EqualsAndHashCode.Exclude transient boolean requiredTricks; + private @EqualsAndHashCode.Exclude transient boolean requiredScore; + private @EqualsAndHashCode.Exclude transient boolean requiredCurrentPlayer; + + @Builder(toBuilder = true) + private GameData(@Unmodifiable List players, + @Unmodifiable Map score, + Integer round, + @Unmodifiable Map> hands, + Optional trumpCard, + Card.Suit trumpSuit, + @Unmodifiable Map predictions, + @Unmodifiable Map tricks, + Integer trick, + @Unmodifiable List> stack, + UUID currentPlayer) + { + this.players = players != null ? List.copyOf(players) : null; + this.score = score != null ? Map.copyOf(score) : null; + + this.round = round; + this.hands = hands != null ? Map.copyOf(hands) : null; + this.trumpCard = trumpCard; + this.trumpSuit = trumpSuit; + this.predictions = predictions != null ? Map.copyOf(predictions) : null; + this.tricks = tricks != null ? Map.copyOf(tricks) : null; + + this.trick = trick; + this.stack = stack != null ? List.copyOf(stack) : null; + + this.currentPlayer = currentPlayer; + } + + // + public List getPlayers() { + if (players == null) throw new UnsupportedOperationException(); + return players; + } + + public int getRound() { + if (round == null) throw new UnsupportedOperationException(); + return round; + } + + public int getTrick() { + if (trick == null) throw new UnsupportedOperationException(); + return trick; + } + + public Map> getHands() { + if (hands == null) throw new UnsupportedOperationException(); + return hands; + } + + public Card getTrumpCard() { + if (trumpCard == null) throw new UnsupportedOperationException(); + return trumpCard.orElse(null); + } + + public Card.Suit getTrumpSuit() { + if (trumpSuit == null) throw new UnsupportedOperationException(); + return trumpSuit; + } + + public List> getStack() { + if (stack == null) throw new UnsupportedOperationException(); + return stack; + } + + public Map getPredictions() { + if (predictions == null) throw new UnsupportedOperationException(); + return predictions; + } + + public Map getTricks() { + if (tricks == null) throw new UnsupportedOperationException(); + return tricks; + } + + public Map getScore() { + if (score == null) throw new UnsupportedOperationException(); + return score; + } + + public UUID getCurrentPlayer() { + if (currentPlayer == null) throw new UnsupportedOperationException(); + return currentPlayer; + } + + public UUID getPlayer(int index) { + return getPlayers().get(index); + } + + public int getPlayerCount() { + return getPlayers().size(); + } + // + + // + public GameData requirePlayers() { + if (players == null) throw new AssertionError(); + requiredPlayers = true; + return this; + } + + public GameData requireRound() { + if (round == null) throw new AssertionError(); + requiredRound = true; + return this; + } + + public GameData requireTrick() { + if (trick == null) throw new AssertionError(); + requiredTrick = true; + return this; + } + + public GameData requireHands() { + if (hands == null) throw new AssertionError(); + requiredHands = true; + return this; + } + + public GameData requireAllHands() { + requirePlayers(); + requireHands(); + for (UUID uuid : players) { + if (!hands.containsKey(uuid)) { + throw new AssertionError(); + } + } + return this; + } + + public GameData requireTrumpCard() { + if (trumpCard == null) throw new AssertionError(); + requiredTrumpCard = true; + return this; + } + + public GameData requireTrumpSuit() { + if (trumpSuit == null) throw new AssertionError(); + requiredTrumpSuit = true; + return this; + } + + public GameData requireStack() { + if (stack == null) throw new AssertionError(); + requiredStack = true; + return this; + } + + public GameData requirePredictions() { + if (predictions == null) throw new AssertionError(); + requiredPredictions = true; + return this; + } + + public GameData requireAllPredictions() { + requirePlayers(); + requirePredictions(); + for (UUID uuid : players) { + if (!predictions.containsKey(uuid)) { + throw new AssertionError(); + } + } + return this; + } + + public GameData requireTricks() { + if (tricks == null) throw new AssertionError(); + requiredTricks = true; + return this; + } + + public GameData requireScore() { + if (score == null) throw new AssertionError(); + requiredScore = true; + return this; + } + + public GameData requireCurrentPlayer() { + if (currentPlayer == null) throw new AssertionError(); + requiredCurrentPlayer = true; + return this; + } + + public GameData onlyRequired() { + var builder = toBuilder(); + if (!requiredPlayers) builder.players(null); + if (!requiredRound) builder.round(null); + if (!requiredTrick) builder.trick(null); + if (!requiredHands) builder.hands(null); + if (!requiredTrumpCard) builder.trumpCard(null); + if (!requiredTrumpSuit) builder.trumpSuit(null); + if (!requiredStack) builder.stack(null); + if (!requiredPredictions) builder.predictions(null); + if (!requiredTricks) builder.tricks(null); + if (!requiredScore) builder.score(null); + if (!requiredCurrentPlayer) builder.currentPlayer(null); + requiredPlayers = false; + requiredRound = false; + requiredTrick = false; + requiredHands = false; + requiredTrumpCard = false; + requiredTrumpSuit = false; + requiredStack = false; + requiredPredictions = false; + requiredTricks = false; + requiredScore = false; + requiredCurrentPlayer = false; + return builder.build(); + } + // + + // + public GameData withPrediction(UUID player, int prediction) { + Map predictions = new HashMap<>(getPredictions()); + predictions.put(player, prediction); + return withPredictions(predictions); + } + + public GameData withNextPlayer() { + int index = getPlayers().indexOf(getCurrentPlayer()); + UUID next; + if (index == -1) { + throw new IllegalArgumentException(); + } else if (index == getPlayerCount() - 1) { + next = getPlayers().get(0); + } else { + next = getPlayers().get(index + 1); + } + return withCurrentPlayer(next); + } + + public GameData withNextTrick() { + int trick = getTrick(); + return withTrick(trick + 1); + } + + public GameData withNextRound() { + int round = getRound(); + return withRound(round + 1); + } + + public GameData withCardPlayed(UUID player, Card card) { + List hand = new ArrayList<>(getHands().get(player)); + hand.remove(card); + Map> hands = new HashMap<>(getHands()); + hands.put(player, hand); + List> stack = new ArrayList<>(getStack()); + stack.add(Pair.of(player, card)); + return withStack(stack).withHands(hands); + } + // + + public String toString() { + StringBuilder builder = new StringBuilder("("); + if (players != null) builder.append("players=").append(players).append(", "); + if (round != null) builder.append("round=").append(round).append(", "); + if (trick != null) builder.append("trick=").append(trick).append(", "); + if (hands != null) builder.append("hands=").append(hands).append(", "); + if (trumpCard != null) builder.append("trumpCard=").append(trumpCard).append(", "); + if (trumpSuit != null) builder.append("trumpSuit=").append(trumpSuit).append(", "); + if (stack != null) builder.append("stack=").append(stack).append(", "); + if (predictions != null) builder.append("predictions=").append(predictions).append(", "); + if (tricks != null) builder.append("tricks=").append(tricks).append(", "); + if (currentPlayer != null) builder.append("currentPlayer=").append(currentPlayer).append(", "); + if (builder.length() > 1) builder.setLength(builder.length() - 2); + builder.append(")"); + return builder.toString(); + } + + // An interface for @lombok.experimental.Delegate + public interface Getters { + @NotNull List getPlayers(); + @NotNull Map getScore(); + + int getRound(); + @NotNull Map> getHands(); + @Nullable Card getTrumpCard(); + @NotNull Card.Suit getTrumpSuit(); + @NotNull Map getPredictions(); + @NotNull Map getTricks(); + + int getTrick(); + @NotNull List> getStack(); + + @NotNull UUID getCurrentPlayer(); + @NotNull UUID getPlayer(int index); + int getPlayerCount(); + } +} diff --git a/wizard-core/src/main/java/eu/jonahbauer/wizard/core/machine/states/GameState.java b/wizard-core/src/main/java/eu/jonahbauer/wizard/core/machine/states/GameState.java new file mode 100644 index 0000000..32ba9ac --- /dev/null +++ b/wizard-core/src/main/java/eu/jonahbauer/wizard/core/machine/states/GameState.java @@ -0,0 +1,45 @@ +package eu.jonahbauer.wizard.core.machine.states; + +import eu.jonahbauer.wizard.core.machine.Game; +import eu.jonahbauer.wizard.core.machine.GameData; +import eu.jonahbauer.wizard.core.messages.player.PlayerMessage; +import lombok.Getter; +import lombok.experimental.Delegate; +import org.jetbrains.annotations.Unmodifiable; + +import java.util.UUID; + +@Unmodifiable +public abstract class GameState implements State { + @Getter + private final Game game; + @Getter + @Delegate(types = GameData.Getters.class) + private final GameData data; + + public GameState(Game game, GameData data) { + this.game = game; + this.data = data.requirePlayers().onlyRequired(); + } + + protected final void transition(GameState state) { + getGame().transition(this, state); + } + + protected final void timeout() { + getGame().timeout(this, getTimeout(false)); + } + + protected final long getTimeout(boolean absolute) { + return (absolute ? System.currentTimeMillis() : 0) + getGame().getConfig().timeout(); + } + + public void onMessage(UUID player, PlayerMessage message) { + throw new IllegalStateException("You cannot do that right now."); + } + + @Override + public String toString() { + return getClass().getSimpleName(); + } +} diff --git a/wizard-core/src/main/java/eu/jonahbauer/wizard/core/machine/states/State.java b/wizard-core/src/main/java/eu/jonahbauer/wizard/core/machine/states/State.java new file mode 100644 index 0000000..88f8801 --- /dev/null +++ b/wizard-core/src/main/java/eu/jonahbauer/wizard/core/machine/states/State.java @@ -0,0 +1,7 @@ +package eu.jonahbauer.wizard.core.machine.states; + +public interface State { + default void onEnter() {} + default void onTimeout() {} + default void onExit() {} +} diff --git a/wizard-core/src/main/java/eu/jonahbauer/wizard/core/machine/states/game/Finishing.java b/wizard-core/src/main/java/eu/jonahbauer/wizard/core/machine/states/game/Finishing.java new file mode 100644 index 0000000..212ef10 --- /dev/null +++ b/wizard-core/src/main/java/eu/jonahbauer/wizard/core/machine/states/game/Finishing.java @@ -0,0 +1,19 @@ +package eu.jonahbauer.wizard.core.machine.states.game; + +import eu.jonahbauer.wizard.core.machine.Game; +import eu.jonahbauer.wizard.core.machine.GameData; +import eu.jonahbauer.wizard.core.machine.states.GameState; +import eu.jonahbauer.wizard.core.messages.observer.ScoreMessage; + +public final class Finishing extends GameState { + + public Finishing(Game game, GameData data) { + super(game, data.requireScore()); + } + + @Override + public void onEnter() { + getGame().notify(new ScoreMessage(getScore())); + getGame().finish(); + } +} diff --git a/wizard-core/src/main/java/eu/jonahbauer/wizard/core/machine/states/game/Starting.java b/wizard-core/src/main/java/eu/jonahbauer/wizard/core/machine/states/game/Starting.java new file mode 100644 index 0000000..40b953f --- /dev/null +++ b/wizard-core/src/main/java/eu/jonahbauer/wizard/core/machine/states/game/Starting.java @@ -0,0 +1,20 @@ +package eu.jonahbauer.wizard.core.machine.states.game; + +import eu.jonahbauer.wizard.core.machine.Game; +import eu.jonahbauer.wizard.core.machine.GameData; +import eu.jonahbauer.wizard.core.machine.states.GameState; +import eu.jonahbauer.wizard.core.machine.states.round.StartingRound; + +import java.util.Collections; + +public final class Starting extends GameState { + + public Starting(Game game, GameData data) { + super(game, data); + } + + @Override + public void onEnter() { + transition(new StartingRound(getGame(), getData().withRound(0).withScore(Collections.emptyMap()))); + } +} diff --git a/wizard-core/src/main/java/eu/jonahbauer/wizard/core/machine/states/round/Dealing.java b/wizard-core/src/main/java/eu/jonahbauer/wizard/core/machine/states/round/Dealing.java new file mode 100644 index 0000000..20f5c7e --- /dev/null +++ b/wizard-core/src/main/java/eu/jonahbauer/wizard/core/machine/states/round/Dealing.java @@ -0,0 +1,50 @@ +package eu.jonahbauer.wizard.core.machine.states.round; + +import eu.jonahbauer.wizard.core.machine.Game; +import eu.jonahbauer.wizard.core.machine.GameData; +import eu.jonahbauer.wizard.core.messages.observer.HandMessage; +import eu.jonahbauer.wizard.core.model.Card; +import eu.jonahbauer.wizard.core.model.Deck; + +import java.util.*; + +public final class Dealing extends RoundState { + private transient Map> hands; + + public Dealing(Game game, GameData data) { + super(game, data); + } + + @Override + public void onEnter() { + Deck deck = new Deck(getGame().getConfig().cards()); + deck.shuffle(); + + hands = new HashMap<>(); + + int dealer = getRound(); + int playerCount = getPlayerCount(); + int cardCount = getRound() + 1; + for (int i = 1; i <= playerCount; i++) { + int player = (dealer + i) % playerCount; + hands.put(getPlayer(player), deck.draw(cardCount)); + } + + Optional trumpCard = Optional.ofNullable(deck.draw()); + + transition(new DeterminingTrump( + getGame(), + getData().withHands(hands) + .withTrumpCard(trumpCard) + )); + } + + @Override + public void onExit() { + if (hands != null) { + for (UUID player : getPlayers()) { + getGame().notify(player, new HandMessage(player, hands.get(player))); + } + } + } +} diff --git a/wizard-core/src/main/java/eu/jonahbauer/wizard/core/machine/states/round/DeterminingTrump.java b/wizard-core/src/main/java/eu/jonahbauer/wizard/core/machine/states/round/DeterminingTrump.java new file mode 100644 index 0000000..862306b --- /dev/null +++ b/wizard-core/src/main/java/eu/jonahbauer/wizard/core/machine/states/round/DeterminingTrump.java @@ -0,0 +1,87 @@ +package eu.jonahbauer.wizard.core.machine.states.round; + +import eu.jonahbauer.wizard.core.machine.Game; +import eu.jonahbauer.wizard.core.machine.GameData; +import eu.jonahbauer.wizard.core.messages.observer.TrumpMessage; +import eu.jonahbauer.wizard.core.messages.observer.UserInputMessage; +import eu.jonahbauer.wizard.core.messages.player.PickTrumpMessage; +import eu.jonahbauer.wizard.core.messages.player.PlayerMessage; +import eu.jonahbauer.wizard.core.model.Card; +import org.jetbrains.annotations.VisibleForTesting; + +import java.util.Arrays; +import java.util.Collections; +import java.util.UUID; + +import static eu.jonahbauer.wizard.core.messages.observer.UserInputMessage.Action.PICK_TRUMP; + +/** + * + */ +public final class DeterminingTrump extends RoundState { + private transient Card.Suit trumpSuit; + + public DeterminingTrump(Game game, GameData data) { + super(game, data.requireAllHands().requireTrumpCard()); + } + + @Override + public void onEnter() { + Card trumpCard = getTrumpCard(); + Card.Suit trumpSuit = trumpCard != null ? trumpCard.getTrumpSuit() : Card.Suit.NONE; + if (trumpSuit == null) { + getGame().notify(new TrumpMessage(getTrumpCard(), null)); + getGame().notify(new UserInputMessage(getDealer(), PICK_TRUMP, getTimeout(true))); + timeout(); + } else { + this.trumpSuit = trumpSuit; + transition(); + } + } + + @Override + public void onTimeout() { + Card.Suit[] suits = new Card.Suit[] {Card.Suit.BLUE, Card.Suit.GREEN, Card.Suit.RED, Card.Suit.YELLOW}; + this.trumpSuit = suits[(int)(Math.random() * suits.length)]; + transition(); + } + + @Override + public void onExit() { + if (trumpSuit != null) { + getGame().notify(new TrumpMessage(getTrumpCard(), trumpSuit)); + } + } + + @Override + public void onMessage(UUID player, PlayerMessage message) { + if (getDealer().equals(player) && message instanceof PickTrumpMessage trumpMessage) { + checkTrumpSuit(trumpMessage.getTrumpSuit()); + this.trumpSuit = trumpMessage.getTrumpSuit(); + transition(); + } else { + super.onMessage(player, message); + } + } + + @VisibleForTesting + void checkTrumpSuit(Card.Suit suit) { + Card.Suit[] suits = new Card.Suit[] {Card.Suit.BLUE, Card.Suit.GREEN, Card.Suit.RED, Card.Suit.YELLOW}; + for (Card.Suit s : suits) { + if (s == suit) return; + } + throw new IllegalArgumentException("Trump suit must be one of " + Arrays.toString(suits) + "."); + } + + private void transition() { + if (trumpSuit == null) throw new AssertionError(); + + transition(new Predicting( + getGame(), + getData().withTrumpSuit(trumpSuit) + .withPredictions(Collections.emptyMap()) + .withCurrentPlayer(getDealer()) + .withNextPlayer() + )); + } +} diff --git a/wizard-core/src/main/java/eu/jonahbauer/wizard/core/machine/states/round/FinishingRound.java b/wizard-core/src/main/java/eu/jonahbauer/wizard/core/machine/states/round/FinishingRound.java new file mode 100644 index 0000000..85f75a8 --- /dev/null +++ b/wizard-core/src/main/java/eu/jonahbauer/wizard/core/machine/states/round/FinishingRound.java @@ -0,0 +1,63 @@ +package eu.jonahbauer.wizard.core.machine.states.round; + +import eu.jonahbauer.wizard.core.machine.Game; +import eu.jonahbauer.wizard.core.machine.GameData; +import eu.jonahbauer.wizard.core.machine.states.game.Finishing; +import eu.jonahbauer.wizard.core.messages.observer.ScoreMessage; +import org.jetbrains.annotations.VisibleForTesting; + +import java.util.HashMap; +import java.util.Map; +import java.util.UUID; + +public final class FinishingRound extends RoundState { + private transient Map points; + + public FinishingRound(Game game, GameData data) { + super(game, data.requireAllPredictions().requireTricks().requireScore()); + + int tricks = data.getTricks().values().stream().mapToInt(i -> i).sum(); + if (tricks != data.getRound() + 1) { + throw new AssertionError("Unexpected number of tricks in round " + data.getRound() + ": " + tricks + "."); + } + } + + @Override + public void onEnter() { + points = getPoints(); + + var score = new HashMap<>(getScore()); + points.forEach((uuid, p) -> score.compute(uuid, (u, total) -> total == null ? p : total + p)); + + GameData data = getData().withScore(score); + + if (60 / getPlayerCount() == getRound() + 1) { + transition(new Finishing(getGame(), data)); + } else { + transition(new StartingRound(getGame(), data.withNextRound())); + } + } + + @Override + public void onExit() { + if (points != null) { + getGame().notify(new ScoreMessage(points)); + } + } + + @VisibleForTesting + Map getPoints() { + var points = new HashMap(); + for (UUID player : getPlayers()) { + int prediction = getPredictions().get(player); + int tricks = getTricks().getOrDefault(player, 0); + + if (tricks == prediction) { + points.put(player, 20 + 10 * tricks); + } else { + points.put(player, -10 * Math.abs(tricks - prediction)); + } + } + return points; + } +} diff --git a/wizard-core/src/main/java/eu/jonahbauer/wizard/core/machine/states/round/Predicting.java b/wizard-core/src/main/java/eu/jonahbauer/wizard/core/machine/states/round/Predicting.java new file mode 100644 index 0000000..8e606f5 --- /dev/null +++ b/wizard-core/src/main/java/eu/jonahbauer/wizard/core/machine/states/round/Predicting.java @@ -0,0 +1,103 @@ +package eu.jonahbauer.wizard.core.machine.states.round; + +import eu.jonahbauer.wizard.core.machine.Game; +import eu.jonahbauer.wizard.core.machine.GameData; +import eu.jonahbauer.wizard.core.machine.states.trick.StartingTrick; +import eu.jonahbauer.wizard.core.messages.observer.PredictionMessage; +import eu.jonahbauer.wizard.core.messages.observer.UserInputMessage; +import eu.jonahbauer.wizard.core.messages.player.PlayerMessage; +import eu.jonahbauer.wizard.core.messages.player.PredictMessage; +import lombok.Getter; +import org.jetbrains.annotations.VisibleForTesting; + +import java.util.Collections; +import java.util.UUID; + +import static eu.jonahbauer.wizard.core.messages.observer.UserInputMessage.Action.MAKE_PREDICTION; + +@Getter +public final class Predicting extends RoundState { + private transient Integer prediction; + + public Predicting(Game game, GameData data) { + super(game, data.requireAllHands().requirePredictions().requireTrumpSuit().requireCurrentPlayer()); + } + + @Override + public void onEnter() { + getGame().notify(new UserInputMessage(getCurrentPlayer(), MAKE_PREDICTION, getTimeout(true))); + timeout(); + } + + @Override + public void onTimeout() { + try { + checkPrediction(0); + this.prediction = 0; + transition(); + } catch (IllegalArgumentException e) { + try { + checkPrediction(1); + this.prediction = 1; + transition(); + } catch (IllegalArgumentException e2) { + throw new AssertionError(e2); + } + } + } + + @Override + public void onExit() { + if (prediction != null) { + getGame().notify(new PredictionMessage(getCurrentPlayer(), prediction)); + } + } + + @Override + public void onMessage(UUID player, PlayerMessage message) { + if (getCurrentPlayer().equals(player) && message instanceof PredictMessage predictMessage) { + checkPrediction(predictMessage.getPrediction()); + this.prediction = predictMessage.getPrediction(); + transition(); + } else { + super.onMessage(player, message); + } + } + + @VisibleForTesting + void checkPrediction(int prediction) { + if (prediction < 0 || prediction > getRound() + 1) { + throw new IllegalArgumentException("Prediction must be between 0 and " + (getRound() + 1) + "."); + } + + if (!getGame().getConfig().allowExactPredictions() && isLastPlayer()) { + int sum = getPredictions().values().stream().mapToInt(i -> i).sum(); + if (sum + prediction == getRound() + 1) { + throw new IllegalArgumentException("Predictions must not add up."); + } + } + } + + private void transition() { + if (prediction == null) throw new AssertionError(); + + GameData data = getData().withPrediction(getCurrentPlayer(), prediction) + .withNextPlayer(); + + if (isLastPlayer()) { + transition(new StartingTrick( + getGame(), + data.withTrick(0).withTricks(Collections.emptyMap()) + )); + } else { + transition(new Predicting( + getGame(), + data + )); + } + } + + private boolean isLastPlayer() { + return getDealer().equals(getCurrentPlayer()); + } +} diff --git a/wizard-core/src/main/java/eu/jonahbauer/wizard/core/machine/states/round/RoundState.java b/wizard-core/src/main/java/eu/jonahbauer/wizard/core/machine/states/round/RoundState.java new file mode 100644 index 0000000..b9a5321 --- /dev/null +++ b/wizard-core/src/main/java/eu/jonahbauer/wizard/core/machine/states/round/RoundState.java @@ -0,0 +1,17 @@ +package eu.jonahbauer.wizard.core.machine.states.round; + +import eu.jonahbauer.wizard.core.machine.Game; +import eu.jonahbauer.wizard.core.machine.GameData; +import eu.jonahbauer.wizard.core.machine.states.GameState; + +import java.util.UUID; + +public abstract class RoundState extends GameState { + public RoundState(Game game, GameData data) { + super(game, data.requireRound().requireScore()); + } + + protected UUID getDealer() { + return getPlayer(getRound() % getPlayerCount()); + } +} diff --git a/wizard-core/src/main/java/eu/jonahbauer/wizard/core/machine/states/round/StartingRound.java b/wizard-core/src/main/java/eu/jonahbauer/wizard/core/machine/states/round/StartingRound.java new file mode 100644 index 0000000..9c36b2e --- /dev/null +++ b/wizard-core/src/main/java/eu/jonahbauer/wizard/core/machine/states/round/StartingRound.java @@ -0,0 +1,15 @@ +package eu.jonahbauer.wizard.core.machine.states.round; + +import eu.jonahbauer.wizard.core.machine.Game; +import eu.jonahbauer.wizard.core.machine.GameData; + +public final class StartingRound extends RoundState { + public StartingRound(Game game, GameData data) { + super(game, data); + } + + @Override + public void onEnter() { + transition(new Dealing(getGame(), getData())); + } +} diff --git a/wizard-core/src/main/java/eu/jonahbauer/wizard/core/machine/states/trick/FinishingTrick.java b/wizard-core/src/main/java/eu/jonahbauer/wizard/core/machine/states/trick/FinishingTrick.java new file mode 100644 index 0000000..f9e7ef3 --- /dev/null +++ b/wizard-core/src/main/java/eu/jonahbauer/wizard/core/machine/states/trick/FinishingTrick.java @@ -0,0 +1,89 @@ +package eu.jonahbauer.wizard.core.machine.states.trick; + +import eu.jonahbauer.wizard.core.machine.Game; +import eu.jonahbauer.wizard.core.machine.GameData; +import eu.jonahbauer.wizard.core.machine.states.round.FinishingRound; +import eu.jonahbauer.wizard.core.messages.observer.TrickMessage; +import eu.jonahbauer.wizard.core.model.Card; +import eu.jonahbauer.wizard.core.util.Pair; +import org.jetbrains.annotations.VisibleForTesting; + +import java.util.Comparator; +import java.util.HashMap; +import java.util.List; +import java.util.UUID; + +public final class FinishingTrick extends TrickState { + private transient UUID winner; + + public FinishingTrick(Game game, GameData data) { + super(game, data.requireStack()); + assert assertions(); + } + + @Override + public void onEnter() { + this.winner = getWinner(); + + var tricks = new HashMap<>(getTricks()); + tricks.compute(winner, (k, t) -> t == null ? 1 : t + 1); + GameData data = getData().withTricks(tricks); + + if (getTrick() < getRound()) { + transition(new StartingTrick( + getGame(), + data.withCurrentPlayer(winner) + .withNextTrick() + )); + } else { + transition(new FinishingRound(getGame(), data)); + } + } + + @Override + public void onExit() { + if (winner != null) { + getGame().notify(new TrickMessage(winner, getStack().stream().map(Pair::second).toList())); + } + } + + @VisibleForTesting + UUID getWinner() { + var wizard = getStack().stream() + .filter(pair -> pair.second().getSuit() == Card.Suit.WIZARD) + .findFirst() + .orElse(null); + if (wizard != null) { + return wizard.first(); + } + + if (getStack().stream().allMatch(pair -> pair.second().getSuit() == Card.Suit.JESTER)) { + return getStack().get(0).first(); + } + + var trumpSuit = getTrumpSuit(); + var suit = getTrickSuit(); + return getStack().stream() + .max( + Comparator.>comparingInt(pair -> pair.second() + .getSuit() == trumpSuit ? 1 : 0) + .thenComparing(pair -> pair.second().getSuit() == suit ? 1 : 0) + .thenComparing(pair -> pair.second().getValue()) + ) + .orElseThrow(AssertionError::new) + .first(); + } + + private boolean assertions() { + if (getStack().size() != getPlayerCount()) { + throw new AssertionError("Unexpected stack size: " + getStack().size() + " (expected " + getPlayerCount() + ")."); + } + int expectedSize = getRound() - getTrick(); + for (List hand : getHands().values()) { + if (hand.size() != expectedSize) { + throw new AssertionError("Unexpected hand size: " + hand.size() + " (expected " + expectedSize + ")."); + } + } + return true; + } +} diff --git a/wizard-core/src/main/java/eu/jonahbauer/wizard/core/machine/states/trick/PlayingCard.java b/wizard-core/src/main/java/eu/jonahbauer/wizard/core/machine/states/trick/PlayingCard.java new file mode 100644 index 0000000..5df0abb --- /dev/null +++ b/wizard-core/src/main/java/eu/jonahbauer/wizard/core/machine/states/trick/PlayingCard.java @@ -0,0 +1,109 @@ +package eu.jonahbauer.wizard.core.machine.states.trick; + +import eu.jonahbauer.wizard.core.machine.Game; +import eu.jonahbauer.wizard.core.machine.GameData; +import eu.jonahbauer.wizard.core.messages.observer.CardMessage; +import eu.jonahbauer.wizard.core.messages.observer.UserInputMessage; +import eu.jonahbauer.wizard.core.messages.player.PlayCardMessage; +import eu.jonahbauer.wizard.core.messages.player.PlayerMessage; +import eu.jonahbauer.wizard.core.model.Card; +import org.jetbrains.annotations.VisibleForTesting; + +import java.util.Collection; +import java.util.List; +import java.util.UUID; + +import static eu.jonahbauer.wizard.core.messages.observer.UserInputMessage.Action.PLAY_CARD; + +public final class PlayingCard extends TrickState { + private transient Card card; + + public PlayingCard(Game game, GameData data) { + super(game, data.requireStack()); + assert assertions(); + } + + @Override + public void onEnter() { + getGame().notify(new UserInputMessage(getCurrentPlayer(), PLAY_CARD, getTimeout(true))); + timeout(); + } + + @Override + public void onMessage(UUID player, PlayerMessage message) { + if (getCurrentPlayer().equals(player) && message instanceof PlayCardMessage cardMessage) { + checkCard(cardMessage.getCard()); + this.card = cardMessage.getCard(); + transition(); + } else { + super.onMessage(player, message); + } + } + + @Override + public void onTimeout() { + var hand = getHands().get(getCurrentPlayer()); + this.card = hand.stream().filter(c -> { + try { + checkCard(c); + return true; + } catch (IllegalArgumentException e) { + return false; + } + }).findAny().orElseThrow(() -> new AssertionError("Cannot play any card.")); + transition(); + } + + @Override + public void onExit() { + if (card != null) { + getGame().notify(new CardMessage(getCurrentPlayer(), card)); + } + } + + @VisibleForTesting + void checkCard(Card card) { + var hand = getHands().get(getCurrentPlayer()); + if (!hand.contains(card)) { + throw new IllegalArgumentException("You do not have this card on your hand."); + } + + Card.Suit suit = getTrickSuit(); + if (card.getSuit().isColor() && suit != null && card.getSuit() != suit) { + if (hand.stream().anyMatch(c -> c.getSuit() == suit)) { + throw new IllegalArgumentException("Must follow suit."); + } + } + } + + private void transition() { + if (card == null) throw new AssertionError(); + + GameData data = getData().withCardPlayed(getCurrentPlayer(), card); + + var summary = data.getHands().values().stream() + .mapToInt(Collection::size) + .summaryStatistics(); + + if (summary.getMax() == summary.getMin()) { + transition(new FinishingTrick(getGame(), data)); + } else { + transition(new PlayingCard(getGame(), data.withNextPlayer())); + } + + } + + private boolean assertions() { + int expectedSize = getRound() - getTrick(); + for (List hand : getHands().values()) { + if (hand.size() != expectedSize && hand.size() != expectedSize + 1) { + throw new AssertionError("Unexpected hand size: " + hand.size() + " (expected " + expectedSize + " or " + (expectedSize + 1) + ")."); + } + } + int size = getHands().get(getCurrentPlayer()).size(); + if (size != expectedSize + 1) { + throw new AssertionError("Unexpected current player's hand size: " + size + " (expected " + (expectedSize + 1) + ")"); + } + return true; + } +} diff --git a/wizard-core/src/main/java/eu/jonahbauer/wizard/core/machine/states/trick/StartingTrick.java b/wizard-core/src/main/java/eu/jonahbauer/wizard/core/machine/states/trick/StartingTrick.java new file mode 100644 index 0000000..671f096 --- /dev/null +++ b/wizard-core/src/main/java/eu/jonahbauer/wizard/core/machine/states/trick/StartingTrick.java @@ -0,0 +1,20 @@ +package eu.jonahbauer.wizard.core.machine.states.trick; + +import eu.jonahbauer.wizard.core.machine.Game; +import eu.jonahbauer.wizard.core.machine.GameData; + +import java.util.Collections; + +public final class StartingTrick extends TrickState { + public StartingTrick(Game game, GameData data) { + super(game, data); + } + + @Override + public void onEnter() { + transition(new PlayingCard( + getGame(), + getData().withStack(Collections.emptyList()) + )); + } +} diff --git a/wizard-core/src/main/java/eu/jonahbauer/wizard/core/machine/states/trick/TrickState.java b/wizard-core/src/main/java/eu/jonahbauer/wizard/core/machine/states/trick/TrickState.java new file mode 100644 index 0000000..cb330b8 --- /dev/null +++ b/wizard-core/src/main/java/eu/jonahbauer/wizard/core/machine/states/trick/TrickState.java @@ -0,0 +1,32 @@ +package eu.jonahbauer.wizard.core.machine.states.trick; + +import eu.jonahbauer.wizard.core.machine.Game; +import eu.jonahbauer.wizard.core.machine.GameData; +import eu.jonahbauer.wizard.core.machine.states.round.RoundState; +import eu.jonahbauer.wizard.core.model.Card; + +public abstract class TrickState extends RoundState { + public TrickState(Game game, GameData data) { + super( + game, + data.requireAllHands() + .requireAllPredictions() + .requireTrumpSuit() + .requireTrick() + .requireTricks() + .requireCurrentPlayer() + ); + } + + protected Card.Suit getTrickSuit() { + for (var pair : getStack()) { + Card.Suit suit = pair.second().getSuit(); + if (suit == Card.Suit.WIZARD) { + return Card.Suit.NONE; + } else if (suit.isColor()) { + return suit; + } + } + return Card.Suit.NONE; + } +} diff --git a/wizard-core/src/main/java/eu/jonahbauer/wizard/core/messages/Observer.java b/wizard-core/src/main/java/eu/jonahbauer/wizard/core/messages/Observer.java new file mode 100644 index 0000000..0021324 --- /dev/null +++ b/wizard-core/src/main/java/eu/jonahbauer/wizard/core/messages/Observer.java @@ -0,0 +1,12 @@ +package eu.jonahbauer.wizard.core.messages; + +import eu.jonahbauer.wizard.core.messages.observer.ObserverMessage; + +import java.util.UUID; + +public interface Observer { + default void notify(ObserverMessage message) { + notify(null, message); + } + void notify(UUID player, ObserverMessage message); +} diff --git a/wizard-core/src/main/java/eu/jonahbauer/wizard/core/messages/observer/CardMessage.java b/wizard-core/src/main/java/eu/jonahbauer/wizard/core/messages/observer/CardMessage.java new file mode 100644 index 0000000..1dba761 --- /dev/null +++ b/wizard-core/src/main/java/eu/jonahbauer/wizard/core/messages/observer/CardMessage.java @@ -0,0 +1,24 @@ +package eu.jonahbauer.wizard.core.messages.observer; + +import eu.jonahbauer.wizard.core.model.Card; +import lombok.Getter; +import lombok.RequiredArgsConstructor; +import org.jetbrains.annotations.NotNull; + +import java.util.UUID; + +/** + * A {@link CardMessage} is sent whenever a player plays a card. + */ +@Getter +@RequiredArgsConstructor +public final class CardMessage extends ObserverMessage { + /** + * The UUID of the player. + */ + private final @NotNull UUID player; + /** + * The card played. + */ + private final @NotNull Card card; +} diff --git a/wizard-core/src/main/java/eu/jonahbauer/wizard/core/messages/observer/HandMessage.java b/wizard-core/src/main/java/eu/jonahbauer/wizard/core/messages/observer/HandMessage.java new file mode 100644 index 0000000..50f186f --- /dev/null +++ b/wizard-core/src/main/java/eu/jonahbauer/wizard/core/messages/observer/HandMessage.java @@ -0,0 +1,29 @@ +package eu.jonahbauer.wizard.core.messages.observer; + +import eu.jonahbauer.wizard.core.model.Card; +import lombok.Getter; +import org.jetbrains.annotations.NotNull; + +import java.util.List; +import java.util.UUID; + +/** + * A {@link HandMessage} is sent when the player receives information about hit own or another player's hand cards. + */ +@Getter +public final class HandMessage extends ObserverMessage { + /** + * The UUID of player whose hand cards are sent. + */ + private final @NotNull UUID player; + /** + * A list of all the hand cards. May consist only of {@link Card#HIDDEN} if the cars are not visible to the player + * receiving this message + */ + private final @NotNull List<@NotNull Card> hand; + + public HandMessage(@NotNull UUID player, @NotNull List<@NotNull Card> hand) { + this.player = player; + this.hand = List.copyOf(hand); + } +} diff --git a/wizard-core/src/main/java/eu/jonahbauer/wizard/core/messages/observer/ObserverMessage.java b/wizard-core/src/main/java/eu/jonahbauer/wizard/core/messages/observer/ObserverMessage.java new file mode 100644 index 0000000..a55efcc --- /dev/null +++ b/wizard-core/src/main/java/eu/jonahbauer/wizard/core/messages/observer/ObserverMessage.java @@ -0,0 +1,16 @@ +package eu.jonahbauer.wizard.core.messages.observer; + +import com.google.gson.Gson; +import com.google.gson.GsonBuilder; +import eu.jonahbauer.wizard.core.util.SealedClassTypeAdapterFactory; + +public abstract sealed class ObserverMessage permits CardMessage, HandMessage, PredictionMessage, ScoreMessage, StateMessage, TrickMessage, TrumpMessage, UserInputMessage { + public static final Gson GSON = new GsonBuilder() + .registerTypeAdapterFactory(SealedClassTypeAdapterFactory.of(ObserverMessage.class, "Message")) + .create(); + + @Override + public String toString() { + return GSON.toJson(this, ObserverMessage.class); + } +} \ No newline at end of file diff --git a/wizard-core/src/main/java/eu/jonahbauer/wizard/core/messages/observer/PredictionMessage.java b/wizard-core/src/main/java/eu/jonahbauer/wizard/core/messages/observer/PredictionMessage.java new file mode 100644 index 0000000..9dd1d6d --- /dev/null +++ b/wizard-core/src/main/java/eu/jonahbauer/wizard/core/messages/observer/PredictionMessage.java @@ -0,0 +1,24 @@ +package eu.jonahbauer.wizard.core.messages.observer; + +import lombok.Getter; +import lombok.RequiredArgsConstructor; +import org.jetbrains.annotations.NotNull; +import org.jetbrains.annotations.Range; + +import java.util.UUID; + +/** + * A {@link PredictionMessage} is sent after a player makes (or changes) his prediction. + */ +@Getter +@RequiredArgsConstructor +public final class PredictionMessage extends ObserverMessage { + /** + * The UUID of the player who made a prediction. + */ + private final @NotNull UUID player; + /** + * The prediction. + */ + private final @Range(from = 0, to = Integer.MAX_VALUE) int prediction; +} diff --git a/wizard-core/src/main/java/eu/jonahbauer/wizard/core/messages/observer/ScoreMessage.java b/wizard-core/src/main/java/eu/jonahbauer/wizard/core/messages/observer/ScoreMessage.java new file mode 100644 index 0000000..5301146 --- /dev/null +++ b/wizard-core/src/main/java/eu/jonahbauer/wizard/core/messages/observer/ScoreMessage.java @@ -0,0 +1,23 @@ +package eu.jonahbauer.wizard.core.messages.observer; + +import lombok.Getter; +import org.jetbrains.annotations.NotNull; + +import java.util.Map; +import java.util.UUID; + +/** + * A {@link ScoreMessage} is sent at the end of each round and at the end of the game. It contains the number of points + * gained by each player in this round or the final result. + */ +@Getter +public final class ScoreMessage extends ObserverMessage { + /** + * The number of points for each player. + */ + private final @NotNull Map<@NotNull UUID, @NotNull Integer> points; + + public ScoreMessage(@NotNull Map<@NotNull UUID, @NotNull Integer> points) { + this.points = Map.copyOf(points); + } +} diff --git a/wizard-core/src/main/java/eu/jonahbauer/wizard/core/messages/observer/StateMessage.java b/wizard-core/src/main/java/eu/jonahbauer/wizard/core/messages/observer/StateMessage.java new file mode 100644 index 0000000..4072629 --- /dev/null +++ b/wizard-core/src/main/java/eu/jonahbauer/wizard/core/messages/observer/StateMessage.java @@ -0,0 +1,26 @@ +package eu.jonahbauer.wizard.core.messages.observer; + +import eu.jonahbauer.wizard.core.machine.Game; +import eu.jonahbauer.wizard.core.machine.states.GameState; +import lombok.Getter; + +import java.util.Locale; + +/** + * A {@link StateMessage} is sent whenever the {@link Game} changes its internal {@link GameState}. + */ +@Getter +public final class StateMessage extends ObserverMessage { + /** + * The name of the new state in snake_case. + */ + private final String state; + + public StateMessage(Class state) { + if (state == null) { + this.state = "null"; + } else { + this.state = state.getSimpleName().replaceAll("([a-z])([A-Z]+)", "$1_$2").toLowerCase(Locale.ROOT); + } + } +} diff --git a/wizard-core/src/main/java/eu/jonahbauer/wizard/core/messages/observer/TrickMessage.java b/wizard-core/src/main/java/eu/jonahbauer/wizard/core/messages/observer/TrickMessage.java new file mode 100644 index 0000000..d6b66fb --- /dev/null +++ b/wizard-core/src/main/java/eu/jonahbauer/wizard/core/messages/observer/TrickMessage.java @@ -0,0 +1,28 @@ +package eu.jonahbauer.wizard.core.messages.observer; + +import eu.jonahbauer.wizard.core.model.Card; +import lombok.Getter; + +import java.util.List; +import java.util.UUID; + +/** + * A {@link TrickMessage} is sent when a trick is completed. It contains the player who won the trick and a list of the + * cards played. + */ +@Getter +public final class TrickMessage extends ObserverMessage { + /** + * The UUID of the player who won the trick. + */ + private final UUID player; + /** + * The cards played. + */ + private final List cards; + + public TrickMessage(UUID player, List cards) { + this.player = player; + this.cards = List.copyOf(cards); + } +} diff --git a/wizard-core/src/main/java/eu/jonahbauer/wizard/core/messages/observer/TrumpMessage.java b/wizard-core/src/main/java/eu/jonahbauer/wizard/core/messages/observer/TrumpMessage.java new file mode 100644 index 0000000..141f3a2 --- /dev/null +++ b/wizard-core/src/main/java/eu/jonahbauer/wizard/core/messages/observer/TrumpMessage.java @@ -0,0 +1,29 @@ +package eu.jonahbauer.wizard.core.messages.observer; + +import eu.jonahbauer.wizard.core.model.Card; +import lombok.Getter; +import org.jetbrains.annotations.Nullable; + +/** + * A {@link TrumpMessage} is sent when the trump suit of the current round is (being) determined. + */ +@Getter +public final class TrumpMessage extends ObserverMessage { + /** + * The {@link Card} that was revealed to determine the {@linkplain Card.Suit trump suit} or {@code null} no cards + * were left. + */ + private final @Nullable Card card; + /** + * The trump suit or {@code null} is the dealer has yet to decide. + */ + private final @Nullable Card.Suit suit; + + public TrumpMessage(@Nullable Card card, @Nullable Card.Suit suit) { + if (suit != null && !suit.isColor()) throw new IllegalArgumentException("The trump suit must be a color or null."); + if (card == null && suit == null) throw new IllegalArgumentException("Card and suit must not both be null"); + + this.card = card; + this.suit = suit; + } +} diff --git a/wizard-core/src/main/java/eu/jonahbauer/wizard/core/messages/observer/UserInputMessage.java b/wizard-core/src/main/java/eu/jonahbauer/wizard/core/messages/observer/UserInputMessage.java new file mode 100644 index 0000000..4b99008 --- /dev/null +++ b/wizard-core/src/main/java/eu/jonahbauer/wizard/core/messages/observer/UserInputMessage.java @@ -0,0 +1,47 @@ +package eu.jonahbauer.wizard.core.messages.observer; + +import eu.jonahbauer.wizard.core.messages.player.PickTrumpMessage; +import eu.jonahbauer.wizard.core.messages.player.PlayCardMessage; +import eu.jonahbauer.wizard.core.messages.player.PredictMessage; +import lombok.Getter; +import lombok.RequiredArgsConstructor; + +import java.util.UUID; + +/** + * A {@link UserInputMessage} is sent when user input is required. + */ +@Getter +@RequiredArgsConstructor +public final class UserInputMessage extends ObserverMessage { + /** + * The UUID of the player whose input is required. + */ + private final UUID player; + /** + * The type of input that is required. + */ + private final Action action; + /** + * A timeout in {@link System#currentTimeMillis() UNIX time} after which a default action is taken. + */ + private final long timeout; + + public enum Action { + /** + * An action that indicates that a player should make a prediction. A {@link UserInputMessage} with this + * {@link UserInputMessage#getAction()} should be responded to with a {@link PredictMessage}. + */ + MAKE_PREDICTION, + /** + * An action that indicates that a player should play a card. A {@link UserInputMessage} with this + * {@link UserInputMessage#getAction()} should be responded to with a {@link PlayCardMessage}. + */ + PLAY_CARD, + /** + * An action that indicates that a player should pick a trump suit. A {@link UserInputMessage} with this + * {@link UserInputMessage#getAction()} should be responded to with a {@link PickTrumpMessage}. + */ + PICK_TRUMP + } +} diff --git a/wizard-core/src/main/java/eu/jonahbauer/wizard/core/messages/player/PickTrumpMessage.java b/wizard-core/src/main/java/eu/jonahbauer/wizard/core/messages/player/PickTrumpMessage.java new file mode 100644 index 0000000..6de50f6 --- /dev/null +++ b/wizard-core/src/main/java/eu/jonahbauer/wizard/core/messages/player/PickTrumpMessage.java @@ -0,0 +1,11 @@ +package eu.jonahbauer.wizard.core.messages.player; + +import eu.jonahbauer.wizard.core.model.Card; +import lombok.Getter; +import lombok.RequiredArgsConstructor; + +@Getter +@RequiredArgsConstructor +public final class PickTrumpMessage extends PlayerMessage { + private final Card.Suit trumpSuit; +} diff --git a/wizard-core/src/main/java/eu/jonahbauer/wizard/core/messages/player/PlayCardMessage.java b/wizard-core/src/main/java/eu/jonahbauer/wizard/core/messages/player/PlayCardMessage.java new file mode 100644 index 0000000..8bfeabb --- /dev/null +++ b/wizard-core/src/main/java/eu/jonahbauer/wizard/core/messages/player/PlayCardMessage.java @@ -0,0 +1,11 @@ +package eu.jonahbauer.wizard.core.messages.player; + +import eu.jonahbauer.wizard.core.model.Card; +import lombok.Getter; +import lombok.RequiredArgsConstructor; + +@Getter +@RequiredArgsConstructor +public final class PlayCardMessage extends PlayerMessage { + private final Card card; +} diff --git a/wizard-core/src/main/java/eu/jonahbauer/wizard/core/messages/player/PlayerMessage.java b/wizard-core/src/main/java/eu/jonahbauer/wizard/core/messages/player/PlayerMessage.java new file mode 100644 index 0000000..677dde5 --- /dev/null +++ b/wizard-core/src/main/java/eu/jonahbauer/wizard/core/messages/player/PlayerMessage.java @@ -0,0 +1,16 @@ +package eu.jonahbauer.wizard.core.messages.player; + +import com.google.gson.Gson; +import com.google.gson.GsonBuilder; +import eu.jonahbauer.wizard.core.util.SealedClassTypeAdapterFactory; + +public abstract sealed class PlayerMessage permits PickTrumpMessage, PlayCardMessage, PredictMessage { + public static final Gson GSON = new GsonBuilder() + .registerTypeAdapterFactory(SealedClassTypeAdapterFactory.of(PlayerMessage.class, "Message")) + .create(); + + @Override + public String toString() { + return GSON.toJson(this); + } +} diff --git a/wizard-core/src/main/java/eu/jonahbauer/wizard/core/messages/player/PredictMessage.java b/wizard-core/src/main/java/eu/jonahbauer/wizard/core/messages/player/PredictMessage.java new file mode 100644 index 0000000..f930c20 --- /dev/null +++ b/wizard-core/src/main/java/eu/jonahbauer/wizard/core/messages/player/PredictMessage.java @@ -0,0 +1,10 @@ +package eu.jonahbauer.wizard.core.messages.player; + +import lombok.Getter; +import lombok.RequiredArgsConstructor; + +@Getter +@RequiredArgsConstructor +public final class PredictMessage extends PlayerMessage { + private final int prediction; +} diff --git a/wizard-core/src/main/java/eu/jonahbauer/wizard/core/model/Card.java b/wizard-core/src/main/java/eu/jonahbauer/wizard/core/model/Card.java new file mode 100644 index 0000000..3ed29e9 --- /dev/null +++ b/wizard-core/src/main/java/eu/jonahbauer/wizard/core/model/Card.java @@ -0,0 +1,98 @@ +package eu.jonahbauer.wizard.core.model; + +import lombok.Getter; +import org.jetbrains.annotations.NotNull; +import org.jetbrains.annotations.Nullable; + +@Getter +public enum Card { + HIDDEN(0, Suit.NONE, Suit.NONE), + RED_JESTER(0, Suit.JESTER, Suit.NONE), + RED_1(1, Suit.RED, Suit.RED), + RED_2(2, Suit.RED, Suit.RED), + RED_3(3, Suit.RED, Suit.RED), + RED_4(4, Suit.RED, Suit.RED), + RED_5(5, Suit.RED, Suit.RED), + RED_6(6, Suit.RED, Suit.RED), + RED_7(7, Suit.RED, Suit.RED), + RED_8(8, Suit.RED, Suit.RED), + RED_9(9, Suit.RED, Suit.RED), + RED_10(10, Suit.RED, Suit.RED), + RED_11(11, Suit.RED, Suit.RED), + RED_12(12, Suit.RED, Suit.RED), + RED_13(13, Suit.RED, Suit.RED), + RED_WIZARD(14, Suit.WIZARD, null), + YELLOW_JESTER(0, Suit.JESTER, Suit.NONE), + YELLOW_1(1, Suit.YELLOW, Suit.YELLOW), + YELLOW_2(2, Suit.YELLOW, Suit.YELLOW), + YELLOW_3(3, Suit.YELLOW, Suit.YELLOW), + YELLOW_4(4, Suit.YELLOW, Suit.YELLOW), + YELLOW_5(5, Suit.YELLOW, Suit.YELLOW), + YELLOW_6(6, Suit.YELLOW, Suit.YELLOW), + YELLOW_7(7, Suit.YELLOW, Suit.YELLOW), + YELLOW_8(8, Suit.YELLOW, Suit.YELLOW), + YELLOW_9(9, Suit.YELLOW, Suit.YELLOW), + YELLOW_10(10, Suit.YELLOW, Suit.YELLOW), + YELLOW_11(11, Suit.YELLOW, Suit.YELLOW), + YELLOW_12(12, Suit.YELLOW, Suit.YELLOW), + YELLOW_13(13, Suit.YELLOW, Suit.YELLOW), + YELLOW_WIZARD(14, Suit.WIZARD, null), + GREEN_JESTER(0, Suit.JESTER, Suit.NONE), + GREEN_1(1, Suit.GREEN, Suit.GREEN), + GREEN_2(2, Suit.GREEN, Suit.GREEN), + GREEN_3(3, Suit.GREEN, Suit.GREEN), + GREEN_4(4, Suit.GREEN, Suit.GREEN), + GREEN_5(5, Suit.GREEN, Suit.GREEN), + GREEN_6(6, Suit.GREEN, Suit.GREEN), + GREEN_7(7, Suit.GREEN, Suit.GREEN), + GREEN_8(8, Suit.GREEN, Suit.GREEN), + GREEN_9(9, Suit.GREEN, Suit.GREEN), + GREEN_10(10, Suit.GREEN, Suit.GREEN), + GREEN_11(11, Suit.GREEN, Suit.GREEN), + GREEN_12(12, Suit.GREEN, Suit.GREEN), + GREEN_13(13, Suit.GREEN, Suit.GREEN), + GREEN_WIZARD(14, Suit.WIZARD, null), + BLUE_JESTER(0, Suit.JESTER, Suit.NONE), + BLUE_1(1, Suit.BLUE, Suit.BLUE), + BLUE_2(2, Suit.BLUE, Suit.BLUE), + BLUE_3(3, Suit.BLUE, Suit.BLUE), + BLUE_4(4, Suit.BLUE, Suit.BLUE), + BLUE_5(5, Suit.BLUE, Suit.BLUE), + BLUE_6(6, Suit.BLUE, Suit.BLUE), + BLUE_7(7, Suit.BLUE, Suit.BLUE), + BLUE_8(8, Suit.BLUE, Suit.BLUE), + BLUE_9(9, Suit.BLUE, Suit.BLUE), + BLUE_10(10, Suit.BLUE, Suit.BLUE), + BLUE_11(11, Suit.BLUE, Suit.BLUE), + BLUE_12(12, Suit.BLUE, Suit.BLUE), + BLUE_13(13, Suit.BLUE, Suit.BLUE), + BLUE_WIZARD(14, Suit.WIZARD, null); + + private final int value; + private final @NotNull Suit suit; + private final @Nullable Suit trumpSuit; + + Card(int value, @NotNull Suit suit, @Nullable Suit trumpSuit) { + if (trumpSuit != null && !trumpSuit.isColor()) throw new IllegalArgumentException(); + this.value = value; + this.suit = suit; + this.trumpSuit = trumpSuit; + } + + @Getter + public enum Suit { + NONE(true), + YELLOW(true), + RED(true), + GREEN(true), + BLUE(true), + JESTER(false), + WIZARD(false); + + private final boolean color; + + Suit(boolean color) { + this.color = color; + } + } +} diff --git a/wizard-core/src/main/java/eu/jonahbauer/wizard/core/model/Cards.java b/wizard-core/src/main/java/eu/jonahbauer/wizard/core/model/Cards.java new file mode 100644 index 0000000..ffb3450 --- /dev/null +++ b/wizard-core/src/main/java/eu/jonahbauer/wizard/core/model/Cards.java @@ -0,0 +1,26 @@ +package eu.jonahbauer.wizard.core.model; + +import lombok.experimental.UtilityClass; + +import java.util.Set; + +@UtilityClass +public class Cards { + public static final Set DEFAULT = Set.of( + Card.RED_JESTER, Card.GREEN_JESTER, Card.YELLOW_JESTER, Card.BLUE_JESTER, + Card.RED_1, Card.GREEN_1, Card.YELLOW_1, Card.BLUE_1, + Card.RED_2, Card.GREEN_2, Card.YELLOW_2, Card.BLUE_2, + Card.RED_3, Card.GREEN_3, Card.YELLOW_3, Card.BLUE_3, + Card.RED_4, Card.GREEN_4, Card.YELLOW_4, Card.BLUE_4, + Card.RED_5, Card.GREEN_5, Card.YELLOW_5, Card.BLUE_5, + Card.RED_6, Card.GREEN_6, Card.YELLOW_6, Card.BLUE_6, + Card.RED_7, Card.GREEN_7, Card.YELLOW_7, Card.BLUE_7, + Card.RED_8, Card.GREEN_8, Card.YELLOW_8, Card.BLUE_8, + Card.RED_9, Card.GREEN_9, Card.YELLOW_9, Card.BLUE_9, + Card.RED_10, Card.GREEN_10, Card.YELLOW_10, Card.BLUE_10, + Card.RED_11, Card.GREEN_11, Card.YELLOW_11, Card.BLUE_11, + Card.RED_12, Card.GREEN_12, Card.YELLOW_12, Card.BLUE_12, + Card.RED_13, Card.GREEN_13, Card.YELLOW_13, Card.BLUE_13, + Card.RED_WIZARD, Card.GREEN_WIZARD, Card.YELLOW_WIZARD, Card.BLUE_WIZARD + ); +} diff --git a/wizard-core/src/main/java/eu/jonahbauer/wizard/core/model/Configuration.java b/wizard-core/src/main/java/eu/jonahbauer/wizard/core/model/Configuration.java new file mode 100644 index 0000000..27bef22 --- /dev/null +++ b/wizard-core/src/main/java/eu/jonahbauer/wizard/core/model/Configuration.java @@ -0,0 +1,18 @@ +package eu.jonahbauer.wizard.core.model; + +import lombok.AccessLevel; +import lombok.AllArgsConstructor; +import lombok.Value; +import lombok.With; +import lombok.experimental.Accessors; + +import java.util.Set; + +@Value +@Accessors(fluent = true) +@AllArgsConstructor(access = AccessLevel.PACKAGE) +public class Configuration { + Set cards; + boolean allowExactPredictions; + @With long timeout; +} diff --git a/wizard-core/src/main/java/eu/jonahbauer/wizard/core/model/Configurations.java b/wizard-core/src/main/java/eu/jonahbauer/wizard/core/model/Configurations.java new file mode 100644 index 0000000..2814a32 --- /dev/null +++ b/wizard-core/src/main/java/eu/jonahbauer/wizard/core/model/Configurations.java @@ -0,0 +1,36 @@ +package eu.jonahbauer.wizard.core.model; + +import lombok.experimental.UtilityClass; +import org.jetbrains.annotations.Contract; +import org.jetbrains.annotations.NotNull; + +import java.util.HashMap; +import java.util.Locale; +import java.util.Map; +import java.util.NoSuchElementException; + +@UtilityClass +public class Configurations { + private static final Map CONFIGURATIONS = new HashMap<>(); + + public static final Configuration DEFAULT = register("default", new Configuration( + Cards.DEFAULT, + true, + 10 * 60 * 1000 + )); + + @Contract("_,_ -> param2") + private static Configuration register(@NotNull String name, @NotNull Configuration configuration) { + if (CONFIGURATIONS.putIfAbsent(name.toLowerCase(Locale.ROOT), configuration) != null) { + throw new IllegalArgumentException("Name already taken."); + } + return configuration; + } + + @NotNull + public static Configuration get(@NotNull String name) { + var out = CONFIGURATIONS.get(name.toLowerCase(Locale.ROOT)); + if (out == null) throw new NoSuchElementException("Configuration with name '" + name + "' does not exist."); + return out; + } +} diff --git a/wizard-core/src/main/java/eu/jonahbauer/wizard/core/model/Deck.java b/wizard-core/src/main/java/eu/jonahbauer/wizard/core/model/Deck.java new file mode 100644 index 0000000..f07b0df --- /dev/null +++ b/wizard-core/src/main/java/eu/jonahbauer/wizard/core/model/Deck.java @@ -0,0 +1,45 @@ +package eu.jonahbauer.wizard.core.model; + +import org.jetbrains.annotations.Nullable; +import org.jetbrains.annotations.Unmodifiable; + +import java.util.*; + +public final class Deck { + private final ArrayList cards = new ArrayList<>(); + private int next = 0; + + public Deck(Collection cards) { + this.cards.addAll(cards); + } + + public void shuffle() { + Collections.shuffle(cards); + next = 0; + } + + @Unmodifiable + public List draw(int n) { + if (remaining() < n) { + throw new NoSuchElementException("Not enough cards left."); + } + + return List.copyOf(cards.subList(next, next += n)); + } + + @Nullable + public Card draw() { + if (next >= cards.size()) { + return null; + } + return cards.get(next++); + } + + public int size() { + return this.cards.size(); + } + + public int remaining() { + return size() - next; + } +} diff --git a/wizard-core/src/main/java/eu/jonahbauer/wizard/core/util/Pair.java b/wizard-core/src/main/java/eu/jonahbauer/wizard/core/util/Pair.java new file mode 100644 index 0000000..d723f47 --- /dev/null +++ b/wizard-core/src/main/java/eu/jonahbauer/wizard/core/util/Pair.java @@ -0,0 +1,7 @@ +package eu.jonahbauer.wizard.core.util; + +public record Pair(F first, S second) { + public static Pair of(F first, S second) { + return new Pair<>(first, second); + } +} diff --git a/wizard-core/src/main/java/eu/jonahbauer/wizard/core/util/SealedClassTypeAdapterFactory.java b/wizard-core/src/main/java/eu/jonahbauer/wizard/core/util/SealedClassTypeAdapterFactory.java new file mode 100644 index 0000000..40a08ab --- /dev/null +++ b/wizard-core/src/main/java/eu/jonahbauer/wizard/core/util/SealedClassTypeAdapterFactory.java @@ -0,0 +1,44 @@ +package eu.jonahbauer.wizard.core.util; + +import com.google.gson.Gson; +import com.google.gson.TypeAdapter; +import com.google.gson.TypeAdapterFactory; +import com.google.gson.reflect.TypeToken; +import com.google.gson.typeadapters.RuntimeTypeAdapterFactory; +import org.jetbrains.annotations.Nullable; + +import java.util.Locale; + +public final class SealedClassTypeAdapterFactory implements TypeAdapterFactory { + private final RuntimeTypeAdapterFactory factory; + + public static SealedClassTypeAdapterFactory of(Class clazz) { + return new SealedClassTypeAdapterFactory<>(clazz, null); + } + + public static SealedClassTypeAdapterFactory of(Class clazz, @Nullable String suffix) { + return new SealedClassTypeAdapterFactory<>(clazz, suffix); + } + + private SealedClassTypeAdapterFactory(Class clazz, @Nullable String suffix) { + factory = RuntimeTypeAdapterFactory.of(clazz); + for (Class subclass : clazz.getPermittedSubclasses()) { + String name = subclass.getSimpleName(); + + // remove suffix + if (suffix != null) { + if (name.endsWith(suffix)) name = name.substring(0, name.length() - suffix.length()); + } + + // transform camelCast to snake_case + name = name.replaceAll("([a-z])([A-Z]+)", "$1_$2").toLowerCase(Locale.ROOT); + + factory.registerSubtype(subclass.asSubclass(clazz), name); + } + } + + @Override + public TypeAdapter create(Gson gson, TypeToken type) { + return factory.create(gson, type); + } +} diff --git a/wizard-core/src/test/java/eu/jonahbauer/wizard/core/machine/GameTest.java b/wizard-core/src/test/java/eu/jonahbauer/wizard/core/machine/GameTest.java new file mode 100644 index 0000000..7ea734c --- /dev/null +++ b/wizard-core/src/test/java/eu/jonahbauer/wizard/core/machine/GameTest.java @@ -0,0 +1,24 @@ +package eu.jonahbauer.wizard.core.machine; + +import eu.jonahbauer.wizard.core.model.Configurations; +import org.junit.jupiter.api.Test; + +import java.util.List; +import java.util.UUID; +import java.util.concurrent.ExecutionException; + +public class GameTest { + @Test + public void run() throws InterruptedException, ExecutionException { + Game game = new Game(Configurations.DEFAULT.withTimeout(0), (player, msg) -> System.out.println(msg)); + var players = List.of( + UUID.randomUUID(), + UUID.randomUUID(), + UUID.randomUUID(), + UUID.randomUUID() + ); + + game.start(players); + game.await(); + } +} diff --git a/wizard-core/src/test/java/eu/jonahbauer/wizard/core/machine/states/round/FinishingRoundTest.java b/wizard-core/src/test/java/eu/jonahbauer/wizard/core/machine/states/round/FinishingRoundTest.java new file mode 100644 index 0000000..028027d --- /dev/null +++ b/wizard-core/src/test/java/eu/jonahbauer/wizard/core/machine/states/round/FinishingRoundTest.java @@ -0,0 +1,50 @@ +package eu.jonahbauer.wizard.core.machine.states.round; + +import org.junit.jupiter.api.Test; + +import java.util.List; +import java.util.Map; +import java.util.UUID; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.when; + +public class FinishingRoundTest { + @Test + public void getPoints() { + UUID player0 = new UUID(0, 0); + UUID player1 = new UUID(0, 1); + UUID player2 = new UUID(0, 2); + + List players = List.of( + player0, + player1, + player2 + ); + + Map predictions = Map.of( + player0, 0, + player1, 3, + player2, 3 + ); + + Map tricks = Map.of( + player0, 0, + player1, 3, + player2, 5 + ); + + FinishingRound state = mock(FinishingRound.class); + when(state.getPoints()).thenCallRealMethod(); + when(state.getPredictions()).thenReturn(predictions); + when(state.getTricks()).thenReturn(tricks); + when(state.getPlayers()).thenReturn(players); + + assertEquals(Map.of( + player0, 20, + player1, 50, + player2, -20 + ), state.getPoints()); + } +} diff --git a/wizard-core/src/test/java/eu/jonahbauer/wizard/core/machine/states/round/PredictingTest.java b/wizard-core/src/test/java/eu/jonahbauer/wizard/core/machine/states/round/PredictingTest.java new file mode 100644 index 0000000..7ab3021 --- /dev/null +++ b/wizard-core/src/test/java/eu/jonahbauer/wizard/core/machine/states/round/PredictingTest.java @@ -0,0 +1,66 @@ +package eu.jonahbauer.wizard.core.machine.states.round; + +import eu.jonahbauer.wizard.core.machine.Game; +import eu.jonahbauer.wizard.core.model.Configuration; +import org.junit.jupiter.api.Test; + +import java.util.Map; +import java.util.UUID; + +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.mockito.Mockito.*; + +public class PredictingTest { + @Test + public void checkPrediction_ThrowsIllegalArgument_IfOutOfBounds() { + Map predictions = Map.of(); + + Configuration config = mock(Configuration.class); + when(config.allowExactPredictions()).thenReturn(true); + + Game game = mock(Game.class); + when(game.getConfig()).thenReturn(config); + + Predicting state = mock(Predicting.class); + doCallRealMethod().when(state).checkPrediction(anyInt()); + when(state.getGame()).thenReturn(game); + when(state.getPredictions()).thenReturn(predictions); + when(state.getDealer()).thenReturn(new UUID(0,0)); + when(state.getCurrentPlayer()).thenReturn(new UUID(0,1)); + when(state.getRound()).thenReturn(10); + + assertThrows(IllegalArgumentException.class, () -> state.checkPrediction(-1)); + assertThrows(IllegalArgumentException.class, () -> state.checkPrediction(12)); + + for (int i = 0; i < 12; i++) { + state.checkPrediction(i); + } + } + + @Test + public void checkPrediction_ThrowsIllegalArgument_IfAddsUp() { + UUID player = new UUID(0,0); + Map predictions = Map.of(new UUID(0,1), 10); + + Configuration config = mock(Configuration.class); + when(config.allowExactPredictions()).thenReturn(false); + + Game game = mock(Game.class); + when(game.getConfig()).thenReturn(config); + + Predicting state = mock(Predicting.class); + doCallRealMethod().when(state).checkPrediction(anyInt()); + when(state.getGame()).thenReturn(game); + when(state.getPredictions()).thenReturn(predictions); + when(state.getDealer()).thenReturn(player); + when(state.getCurrentPlayer()).thenReturn(player); + when(state.getRound()).thenReturn(10); + + assertThrows(IllegalArgumentException.class, () -> state.checkPrediction(1)); + + for (int i = 0; i < 12; i++) { + if (i == 1) continue; + state.checkPrediction(i); + } + } +} diff --git a/wizard-core/src/test/java/eu/jonahbauer/wizard/core/machine/states/trick/FinishingTrickTest.java b/wizard-core/src/test/java/eu/jonahbauer/wizard/core/machine/states/trick/FinishingTrickTest.java new file mode 100644 index 0000000..8ed88e6 --- /dev/null +++ b/wizard-core/src/test/java/eu/jonahbauer/wizard/core/machine/states/trick/FinishingTrickTest.java @@ -0,0 +1,90 @@ +package eu.jonahbauer.wizard.core.machine.states.trick; + +import eu.jonahbauer.wizard.core.machine.states.trick.FinishingTrick; +import eu.jonahbauer.wizard.core.model.Card; +import eu.jonahbauer.wizard.core.util.Pair; +import org.junit.jupiter.api.Test; + +import java.util.List; +import java.util.UUID; + +import static org.junit.jupiter.api.Assertions.assertNotNull; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.when; + +public class FinishingTrickTest { + @Test + public void getWinner_ReturnsFirstWizard() { + List> stack = List.of( + Pair.of(null, Card.RED_1), + Pair.of(null, Card.YELLOW_1), + Pair.of(UUID.randomUUID(), Card.BLUE_WIZARD), + Pair.of(null, Card.BLUE_JESTER), + Pair.of(null, Card.GREEN_11) + ); + + FinishingTrick state = mock(FinishingTrick.class); + when(state.getWinner()).thenCallRealMethod(); + when(state.getStack()).thenReturn(stack); + when(state.getTrickSuit()).thenReturn(Card.Suit.RED); + when(state.getTrumpSuit()).thenReturn(Card.Suit.YELLOW); + + assertNotNull(state.getWinner()); + } + + @Test + public void getWinner_ReturnsHighestTrump_IfNoWizard() { + List> stack = List.of( + Pair.of(null, Card.RED_1), + Pair.of(null, Card.YELLOW_1), + Pair.of(null, Card.BLUE_JESTER), + Pair.of(UUID.randomUUID(), Card.YELLOW_13), + Pair.of(null, Card.GREEN_11) + ); + + FinishingTrick state = mock(FinishingTrick.class); + when(state.getWinner()).thenCallRealMethod(); + when(state.getStack()).thenReturn(stack); + when(state.getTrickSuit()).thenReturn(Card.Suit.RED); + when(state.getTrumpSuit()).thenReturn(Card.Suit.YELLOW); + + assertNotNull(state.getWinner()); + } + + @Test + public void getWinner_ReturnsHighestTrickSuit_IfNeitherWizardNorTrump() { + List> stack = List.of( + Pair.of(null, Card.YELLOW_1), + Pair.of(null, Card.BLUE_JESTER), + Pair.of(UUID.randomUUID(), Card.RED_1), + Pair.of(null, Card.YELLOW_13), + Pair.of(null, Card.GREEN_11) + ); + + FinishingTrick state = mock(FinishingTrick.class); + when(state.getWinner()).thenCallRealMethod(); + when(state.getStack()).thenReturn(stack); + when(state.getTrickSuit()).thenReturn(Card.Suit.RED); + when(state.getTrumpSuit()).thenReturn(Card.Suit.BLUE); + + assertNotNull(state.getWinner()); + } + + @Test + public void getWinner_ReturnsFirstJester_IfOnlyJester() { + List> stack = List.of( + Pair.of(UUID.randomUUID(), Card.GREEN_JESTER), + Pair.of(null, Card.BLUE_JESTER), + Pair.of(null, Card.RED_JESTER), + Pair.of(null, Card.YELLOW_JESTER) + ); + + FinishingTrick state = mock(FinishingTrick.class); + when(state.getWinner()).thenCallRealMethod(); + when(state.getStack()).thenReturn(stack); + when(state.getTrickSuit()).thenReturn(Card.Suit.NONE); + when(state.getTrumpSuit()).thenReturn(Card.Suit.NONE); + + assertNotNull(state.getWinner()); + } +} diff --git a/wizard-core/src/test/java/eu/jonahbauer/wizard/core/machine/states/trick/PlayingCardTest.java b/wizard-core/src/test/java/eu/jonahbauer/wizard/core/machine/states/trick/PlayingCardTest.java new file mode 100644 index 0000000..cf6b8d4 --- /dev/null +++ b/wizard-core/src/test/java/eu/jonahbauer/wizard/core/machine/states/trick/PlayingCardTest.java @@ -0,0 +1,81 @@ +package eu.jonahbauer.wizard.core.machine.states.trick; + +import eu.jonahbauer.wizard.core.machine.states.trick.PlayingCard; +import eu.jonahbauer.wizard.core.messages.player.PickTrumpMessage; +import eu.jonahbauer.wizard.core.messages.player.PlayCardMessage; +import eu.jonahbauer.wizard.core.messages.player.PredictMessage; +import eu.jonahbauer.wizard.core.model.Card; +import org.junit.jupiter.api.Test; + +import java.util.List; +import java.util.Map; +import java.util.UUID; + +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.mockito.Mockito.*; + +public class PlayingCardTest { + @Test + public void checkCard_ThrowsIllegalArgument_IfCardNotInHand() { + List hand = List.of(Card.BLUE_1, Card.RED_1, Card.YELLOW_1, Card.GREEN_JESTER, Card.BLUE_WIZARD); + UUID player = new UUID(0, 0); + + PlayingCard state = mock(PlayingCard.class); + doCallRealMethod().when(state).checkCard(any()); + when(state.getHands()).thenReturn(Map.of(player, hand)); + when(state.getStack()).thenReturn(List.of()); + when(state.getCurrentPlayer()).thenReturn(player); + + for (Card value : Card.values()) { + if (hand.contains(value)) continue; + assertThrows(IllegalArgumentException.class, () -> state.checkCard(value)); + } + + verify(state, times(0)).getData(); + } + + @Test + public void checkCard_ThrowsIllegalArgument_IfNotFollowingSuit() { + List hand = List.of(Card.BLUE_1, Card.RED_1, Card.YELLOW_1, Card.GREEN_JESTER, Card.BLUE_WIZARD); + UUID player = new UUID(0, 0); + + PlayingCard state = mock(PlayingCard.class); + doCallRealMethod().when(state).checkCard(any()); + when(state.getTrickSuit()).thenReturn(Card.Suit.YELLOW); + when(state.getHands()).thenReturn(Map.of(player, hand)); + when(state.getCurrentPlayer()).thenReturn(player); + + assertThrows(IllegalArgumentException.class, () -> state.checkCard(Card.BLUE_1)); + assertThrows(IllegalArgumentException.class, () -> state.checkCard(Card.RED_1)); + + state.checkCard(Card.YELLOW_1); + state.checkCard(Card.GREEN_JESTER); + state.checkCard(Card.BLUE_WIZARD); + } + + @Test + public void onMessage_ThrowsIllegalState_IfNotCurrentPlayer() { + UUID player = new UUID(0, 0); + UUID player2 = new UUID(0, 1); + + PlayingCard state = mock(PlayingCard.class); + doCallRealMethod().when(state).onMessage(any(), any()); + when(state.getCurrentPlayer()).thenReturn(player); + + assertThrows(IllegalStateException.class, () -> state.onMessage(player2, new PlayCardMessage(Card.BLUE_WIZARD))); + assertThrows(IllegalStateException.class, () -> state.onMessage(player2, new PredictMessage(1))); + assertThrows(IllegalStateException.class, () -> state.onMessage(player2, new PickTrumpMessage(Card.Suit.BLUE))); + } + + @Test + public void onMessage_ThrowsIllegalState_IfNotPlayCard() { + UUID player = new UUID(0, 0); + + PlayingCard state = mock(PlayingCard.class); + doCallRealMethod().when(state).onMessage(any(), any()); + when(state.getCurrentPlayer()).thenReturn(player); + + assertThrows(IllegalStateException.class, () -> state.onMessage(player, new PredictMessage(1))); + assertThrows(IllegalStateException.class, () -> state.onMessage(player, new PickTrumpMessage(Card.Suit.BLUE))); + } +} diff --git a/wizard-core/src/test/java/eu/jonahbauer/wizard/core/machine/states/trick/TrickStateTest.java b/wizard-core/src/test/java/eu/jonahbauer/wizard/core/machine/states/trick/TrickStateTest.java new file mode 100644 index 0000000..58462f0 --- /dev/null +++ b/wizard-core/src/test/java/eu/jonahbauer/wizard/core/machine/states/trick/TrickStateTest.java @@ -0,0 +1,90 @@ +package eu.jonahbauer.wizard.core.machine.states.trick; + +import eu.jonahbauer.wizard.core.machine.states.trick.TrickState; +import eu.jonahbauer.wizard.core.model.Card; +import eu.jonahbauer.wizard.core.util.Pair; +import org.junit.jupiter.api.Test; + +import java.util.List; +import java.util.UUID; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.when; + +public class TrickStateTest { + @Test + public void getTrickSuit_ReturnsNone_IfEmpty() { + List> stack = List.of(); + + TrickState trickState = mock(TrickState.class); + when(trickState.getTrickSuit()).thenCallRealMethod(); + when(trickState.getStack()).thenReturn(stack); + + assertEquals(trickState.getTrickSuit(), Card.Suit.NONE); + } + + @Test + public void getTrickSuit_ReturnsColor() { + List> stack = List.of( + Pair.of(null, Card.RED_1), + Pair.of(null, Card.YELLOW_1), + Pair.of(null, Card.BLUE_WIZARD), + Pair.of(null, Card.BLUE_JESTER), + Pair.of(null, Card.GREEN_11) + ); + + TrickState trickState = mock(TrickState.class); + when(trickState.getTrickSuit()).thenCallRealMethod(); + when(trickState.getStack()).thenReturn(stack); + + assertEquals(trickState.getTrickSuit(), Card.Suit.RED); + } + + @Test + public void getTickSuit_ReturnsNone_IfFirstCardIsWizard() { + List> stack = List.of( + Pair.of(null, Card.BLUE_WIZARD), + Pair.of(null, Card.RED_1), + Pair.of(null, Card.YELLOW_1), + Pair.of(null, Card.BLUE_JESTER), + Pair.of(null, Card.GREEN_11) + ); + + TrickState trickState = mock(TrickState.class); + when(trickState.getTrickSuit()).thenCallRealMethod(); + when(trickState.getStack()).thenReturn(stack); + + assertEquals(trickState.getTrickSuit(), Card.Suit.NONE); + } + + @Test + public void getTrickSuit_IgnoresJesters() { + List> stack = List.of( + Pair.of(null, Card.BLUE_JESTER), + Pair.of(null, Card.BLUE_WIZARD), + Pair.of(null, Card.RED_1), + Pair.of(null, Card.YELLOW_1), + Pair.of(null, Card.GREEN_11) + ); + + TrickState trickState = mock(TrickState.class); + when(trickState.getTrickSuit()).thenCallRealMethod(); + when(trickState.getStack()).thenReturn(stack); + + assertEquals(trickState.getTrickSuit(), Card.Suit.NONE); + + List> stack2 = List.of( + Pair.of(null, Card.BLUE_JESTER), + Pair.of(null, Card.YELLOW_1), + Pair.of(null, Card.BLUE_WIZARD), + Pair.of(null, Card.RED_1), + Pair.of(null, Card.GREEN_11) + ); + TrickState trickState2 = mock(TrickState.class); + when(trickState2.getTrickSuit()).thenCallRealMethod(); + when(trickState2.getStack()).thenReturn(stack2); + + assertEquals(trickState2.getTrickSuit(), Card.Suit.YELLOW); + } +}