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 extends T> 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 extends T> 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 extends GameState> 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);
+ }
+}