From 37b31869cdf40a0d3a705ffbae7125d0b2677af1 Mon Sep 17 00:00:00 2001 From: Jonah Bauer Date: Thu, 25 Nov 2021 19:33:31 +0100 Subject: [PATCH] CLI Client #15 --- .gitlab-ci.yml | 6 - .../wizard-client-cli/build.gradle.kts | 3 +- .../jonahbauer/wizard/client/cli/Client.java | 221 ++++++++++++++++++ .../wizard/client/cli/ClientSocket.java | 45 ++++ .../client/cli/commands/GameCommand.java | 117 ++++++++++ .../client/cli/commands/LobbyCommand.java | 101 ++++++++ .../client/cli/commands/MenuCommand.java | 46 ++++ .../client/cli/commands/QuitCommand.java | 7 + .../client/cli/commands/SessionCommand.java | 54 +++++ .../client/cli/commands/ShowCommand.java | 57 +++++ .../wizard/client/cli/state/Awaiting.java | 35 +++ .../cli/state/AwaitingAcknowledgement.java | 43 ++++ .../client/cli/state/AwaitingConnection.java | 26 +++ .../client/cli/state/AwaitingJoinLobby.java | 25 ++ .../client/cli/state/AwaitingJoinSession.java | 35 +++ .../wizard/client/cli/state/BaseState.java | 36 +++ .../wizard/client/cli/state/ClientState.java | 17 ++ .../wizard/client/cli/state/Game.java | 158 +++++++++++++ .../wizard/client/cli/state/Lobby.java | 55 +++++ .../wizard/client/cli/state/Menu.java | 40 ++++ .../wizard/client/cli/state/Session.java | 80 +++++++ .../client/cli/util/DelegateCompleter.java | 13 ++ .../wizard/client/cli/util/Pair.java | 24 ++ .../client/cli/util/StateAwareFactory.java | 50 ++++ .../common/messages/client/ClientMessage.java | 4 +- .../common/messages/data/PlayerData.java | 2 + .../common/messages/data/SessionData.java | 2 + .../common/messages/server/NackMessage.java | 15 +- 28 files changed, 1306 insertions(+), 11 deletions(-) create mode 100644 wizard-client/wizard-client-cli/src/main/java/eu/jonahbauer/wizard/client/cli/Client.java create mode 100644 wizard-client/wizard-client-cli/src/main/java/eu/jonahbauer/wizard/client/cli/ClientSocket.java create mode 100644 wizard-client/wizard-client-cli/src/main/java/eu/jonahbauer/wizard/client/cli/commands/GameCommand.java create mode 100644 wizard-client/wizard-client-cli/src/main/java/eu/jonahbauer/wizard/client/cli/commands/LobbyCommand.java create mode 100644 wizard-client/wizard-client-cli/src/main/java/eu/jonahbauer/wizard/client/cli/commands/MenuCommand.java create mode 100644 wizard-client/wizard-client-cli/src/main/java/eu/jonahbauer/wizard/client/cli/commands/QuitCommand.java create mode 100644 wizard-client/wizard-client-cli/src/main/java/eu/jonahbauer/wizard/client/cli/commands/SessionCommand.java create mode 100644 wizard-client/wizard-client-cli/src/main/java/eu/jonahbauer/wizard/client/cli/commands/ShowCommand.java create mode 100644 wizard-client/wizard-client-cli/src/main/java/eu/jonahbauer/wizard/client/cli/state/Awaiting.java create mode 100644 wizard-client/wizard-client-cli/src/main/java/eu/jonahbauer/wizard/client/cli/state/AwaitingAcknowledgement.java create mode 100644 wizard-client/wizard-client-cli/src/main/java/eu/jonahbauer/wizard/client/cli/state/AwaitingConnection.java create mode 100644 wizard-client/wizard-client-cli/src/main/java/eu/jonahbauer/wizard/client/cli/state/AwaitingJoinLobby.java create mode 100644 wizard-client/wizard-client-cli/src/main/java/eu/jonahbauer/wizard/client/cli/state/AwaitingJoinSession.java create mode 100644 wizard-client/wizard-client-cli/src/main/java/eu/jonahbauer/wizard/client/cli/state/BaseState.java create mode 100644 wizard-client/wizard-client-cli/src/main/java/eu/jonahbauer/wizard/client/cli/state/ClientState.java create mode 100644 wizard-client/wizard-client-cli/src/main/java/eu/jonahbauer/wizard/client/cli/state/Game.java create mode 100644 wizard-client/wizard-client-cli/src/main/java/eu/jonahbauer/wizard/client/cli/state/Lobby.java create mode 100644 wizard-client/wizard-client-cli/src/main/java/eu/jonahbauer/wizard/client/cli/state/Menu.java create mode 100644 wizard-client/wizard-client-cli/src/main/java/eu/jonahbauer/wizard/client/cli/state/Session.java create mode 100644 wizard-client/wizard-client-cli/src/main/java/eu/jonahbauer/wizard/client/cli/util/DelegateCompleter.java create mode 100644 wizard-client/wizard-client-cli/src/main/java/eu/jonahbauer/wizard/client/cli/util/Pair.java create mode 100644 wizard-client/wizard-client-cli/src/main/java/eu/jonahbauer/wizard/client/cli/util/StateAwareFactory.java diff --git a/.gitlab-ci.yml b/.gitlab-ci.yml index a5e77d9..764da57 100644 --- a/.gitlab-ci.yml +++ b/.gitlab-ci.yml @@ -28,12 +28,6 @@ build: paths: - build - .gradle - artifacts: - paths: - - "**/build/distributions/*.tar" - - "**/build/distributions/*.zip" - - "**/build/libs/*.jar" - expire_in: 7 days test: stage: test diff --git a/wizard-client/wizard-client-cli/build.gradle.kts b/wizard-client/wizard-client-cli/build.gradle.kts index 400f764..6e7fc21 100644 --- a/wizard-client/wizard-client-cli/build.gradle.kts +++ b/wizard-client/wizard-client-cli/build.gradle.kts @@ -6,10 +6,11 @@ dependencies { implementation(JLine.id) implementation(Jansi.id) implementation(JavaWebSocket.id) + implementation("org.ajbrown:name-machine:1.0.0") implementation(PicoCLI.core) implementation(PicoCLI.shell_jline3) { - exclude(group = JLine.group) // prevent duplicates with org.jline:jline) + exclude(group = JLine.group) // prevent duplicates with org.jline:jline } annotationProcessor(PicoCLI.codegen) } diff --git a/wizard-client/wizard-client-cli/src/main/java/eu/jonahbauer/wizard/client/cli/Client.java b/wizard-client/wizard-client-cli/src/main/java/eu/jonahbauer/wizard/client/cli/Client.java new file mode 100644 index 0000000..fc8729c --- /dev/null +++ b/wizard-client/wizard-client-cli/src/main/java/eu/jonahbauer/wizard/client/cli/Client.java @@ -0,0 +1,221 @@ +package eu.jonahbauer.wizard.client.cli; + +import eu.jonahbauer.wizard.client.cli.state.ClientState; +import eu.jonahbauer.wizard.client.cli.state.Menu; +import eu.jonahbauer.wizard.client.cli.util.DelegateCompleter; +import eu.jonahbauer.wizard.client.cli.util.StateAwareFactory; +import eu.jonahbauer.wizard.common.machine.TimeoutContext; +import eu.jonahbauer.wizard.common.messages.client.ClientMessage; +import eu.jonahbauer.wizard.common.messages.server.ServerMessage; +import lombok.Getter; +import org.java_websocket.framing.CloseFrame; +import org.jline.reader.*; +import org.jline.reader.impl.DefaultParser; +import org.jline.terminal.Terminal; +import org.jline.terminal.TerminalBuilder; +import picocli.CommandLine; +import picocli.CommandLine.Model.CommandSpec; +import picocli.shell.jline3.PicocliJLineCompleter; + +import java.util.Objects; +import java.util.Optional; +import java.util.concurrent.atomic.AtomicReference; +import java.util.concurrent.locks.Condition; +import java.util.concurrent.locks.ReentrantLock; + +public class Client extends TimeoutContext implements Runnable { + private LineReader reader; + + @Getter + private ClientSocket socket; + + private final ReentrantLock lock = new ReentrantLock(); + private final Condition ready = lock.newCondition(); + private boolean isReady = true; + + private final DelegateCompleter completer = new DelegateCompleter(); + private CommandSpec spec; + + public static void main(String[] args) { + new Client().run(); + } + + public Client() { + super(new Menu()); + } + + @Override + public void run() { + try { + update(); + + Parser parser = new DefaultParser(); + try (Terminal terminal = TerminalBuilder.builder().build()) { + this.reader = LineReaderBuilder.builder() + .terminal(terminal) + .completer(completer) + .parser(parser) + .build(); + + String prompt = "> "; + + String line; + while (true) { + lock.lock(); + try { + while (!isReady) { + ready.await(); + } + } finally { + lock.unlock(); + } + + try { + line = reader.readLine(prompt).trim(); + terminal.flush(); + + if (line.isEmpty() || line.startsWith("#")) continue; + if (line.equals("exit") || line.equals("quit")) break; + + var parsedLine = parser.parse(line, 0); + execute(() -> execute(spec, parsedLine)); + terminal.flush(); + } catch (UserInterruptException e) { + // ignore + } catch (EndOfFileException e) { + break; + } catch (Exception e) { + e.printStackTrace(reader.getTerminal().writer()); + terminal.flush(); + } + } + } + } catch (Throwable t) { + t.printStackTrace(); + } + + if (socket != null && socket.isOpen()) { + socket.close(CloseFrame.GOING_AWAY); + } + shutdownNow(); + } + + @Override + protected void handleError(Throwable t) { + t.printStackTrace(); + System.exit(1); + } + + @Override + protected void onTransition(ClientState from, ClientState to) { + update(to); + } + + // + public void setSocket(ClientSocket socket) { + this.socket = socket; + if (socket != null) { + this.socket.setAttachment(this); + } + } + + public void onOpen() { + execute(s -> s.onOpen(this)); + } + + public void onMessage(ServerMessage message) { + execute(s -> s.onMessage(this, message)); + } + + public void onClose(int code, String reason, boolean remote) { + execute(s -> s.onClose(this, code, reason, remote)); + } + + public void send(ClientMessage message) { + getSocket().send(message.toString()); + } + // + + // + public void println(String str) { + reader.printAbove(str); + } + + public void println(Object object) { + println(Objects.toString(object)); + } + + public void printfln(String format, Object...args) { + reader.printAbove(format.formatted(args)); + } + // + + public void ready() { + lock.lock(); + try { + isReady = true; + ready.signalAll(); + } finally { + lock.unlock(); + } + } + + public void waitForReady() { + lock.lock(); + try { + isReady = false; + } finally { + lock.unlock(); + } + } + + private void update() { + update(getState()); + } + + private void update(ClientState state) { + synchronized (completer) { + var commandLine = new CommandLine(state.getCommand(), new StateAwareFactory(this, state)); + var spec = commandLine.getCommandSpec(); + if (this.spec != spec) { + this.spec = spec; + completer.setDelegate(new PicocliJLineCompleter(spec)); + } + } + } + + private Optional execute(CommandSpec spec, ParsedLine line) { + var commandLine = spec.commandLine(); + + AtomicReference exceptionRef = new AtomicReference<>(); + commandLine.setExecutionExceptionHandler((ex, cl, parseResult) -> { + exceptionRef.set(ex); + return -1; + }); + commandLine.execute(line.words().toArray(String[]::new)); + + Exception exception = exceptionRef.get(); + if (exception == null) { + Object result; + CommandLine.ParseResult parseResult = commandLine.getParseResult(); + if (parseResult.subcommand() != null) { + CommandLine sub = parseResult.subcommand().commandSpec().commandLine(); + result = sub.getExecutionResult(); + } else { + result = commandLine.getExecutionResult(); + } + + if (result instanceof ClientState state) { + return Optional.of(state); + } else if (result instanceof Optional) { + //noinspection unchecked + return (Optional) result; + } else { + return Optional.empty(); + } + } else { + handleError(exception); + return Optional.empty(); + } + } +} diff --git a/wizard-client/wizard-client-cli/src/main/java/eu/jonahbauer/wizard/client/cli/ClientSocket.java b/wizard-client/wizard-client-cli/src/main/java/eu/jonahbauer/wizard/client/cli/ClientSocket.java new file mode 100644 index 0000000..b5c5603 --- /dev/null +++ b/wizard-client/wizard-client-cli/src/main/java/eu/jonahbauer/wizard/client/cli/ClientSocket.java @@ -0,0 +1,45 @@ +package eu.jonahbauer.wizard.client.cli; + +import eu.jonahbauer.wizard.common.messages.server.ServerMessage; +import org.java_websocket.client.WebSocketClient; +import org.java_websocket.framing.CloseFrame; +import org.java_websocket.handshake.ServerHandshake; + +import javax.net.ssl.SSLSocketFactory; +import java.net.URI; + +public class ClientSocket extends WebSocketClient { + public ClientSocket(URI serverUri) { + super(serverUri); + + if ("wss".equals(getURI().getScheme())) { + setSocketFactory(SSLSocketFactory.getDefault()); + } + } + + @Override + public void onOpen(ServerHandshake serverHandshake) { + getClient().onOpen(); + } + + @Override + public void onMessage(String s) { + ServerMessage message = ServerMessage.parse(s); + getClient().onMessage(message); + } + + @Override + public void onClose(int i, String s, boolean b) { + getClient().onClose(i, s, b); + } + + @Override + public void onError(Exception e) { + getClient().println(e.getMessage()); + close(CloseFrame.ABNORMAL_CLOSE, e.getMessage()); + } + + private Client getClient() { + return getAttachment(); + } +} diff --git a/wizard-client/wizard-client-cli/src/main/java/eu/jonahbauer/wizard/client/cli/commands/GameCommand.java b/wizard-client/wizard-client-cli/src/main/java/eu/jonahbauer/wizard/client/cli/commands/GameCommand.java new file mode 100644 index 0000000..e2b13be --- /dev/null +++ b/wizard-client/wizard-client-cli/src/main/java/eu/jonahbauer/wizard/client/cli/commands/GameCommand.java @@ -0,0 +1,117 @@ +package eu.jonahbauer.wizard.client.cli.commands; + +import eu.jonahbauer.wizard.client.cli.Client; +import eu.jonahbauer.wizard.client.cli.state.AwaitingAcknowledgement; +import eu.jonahbauer.wizard.client.cli.state.Game; +import eu.jonahbauer.wizard.client.cli.state.Menu; +import eu.jonahbauer.wizard.common.messages.client.InteractionMessage; +import eu.jonahbauer.wizard.common.messages.player.JuggleMessage; +import eu.jonahbauer.wizard.common.messages.player.PickTrumpMessage; +import eu.jonahbauer.wizard.common.messages.player.PlayCardMessage; +import eu.jonahbauer.wizard.common.messages.player.PredictMessage; +import eu.jonahbauer.wizard.common.messages.server.NackMessage; +import eu.jonahbauer.wizard.common.model.Card; +import org.jetbrains.annotations.NotNull; + +import java.util.Iterator; +import java.util.stream.Stream; + +import static picocli.CommandLine.*; + +@Command(name = "\b", subcommands = {HelpCommand.class, QuitCommand.class, ShowCommand.class}) +public class GameCommand { + private final Client client; + private final Game game; + + public GameCommand(Client client, Game game) { + this.client = client; + this.game = game; + } + + @Command(name = "play") + public AwaitingAcknowledgement play( + @Parameters(index = "0", paramLabel = "", completionCandidates = HandCompletion.class) Card card + ) { + this.client.send(new InteractionMessage(new PlayCardMessage(card))); + return awaitAcknowledgement(); + } + + @Command(name = "predict") + public AwaitingAcknowledgement predict( + @Parameters(index = "0", paramLabel = "") int prediction + ) { + this.client.send(new InteractionMessage(new PredictMessage(prediction))); + return awaitAcknowledgement(); + } + + @Command(name = "trump") + public AwaitingAcknowledgement trump( + @Parameters(index = "0", paramLabel = "") Card.Suit suit + ) { + this.client.send(new InteractionMessage(new PickTrumpMessage(suit))); + return awaitAcknowledgement(); + } + + @Command(name = "juggle") + public AwaitingAcknowledgement juggle( + @Parameters(index = "0", paramLabel = "", completionCandidates = JuggleCompletion.class) Card card + ) { + this.client.send(new InteractionMessage(new JuggleMessage(card))); + return awaitAcknowledgement(); + } + + private AwaitingAcknowledgement awaitAcknowledgement() { + return new AwaitingAcknowledgement( + () -> game, + message -> { + if (message instanceof NackMessage nack) { + int code = nack.getCode(); + if (code == NackMessage.ILLEGAL_ARGUMENT || code == NackMessage.ILLEGAL_STATE) { + client.println("Error: " + nack.getMessage()); + return game; + } + } + client.println("Fatal: Unexpected message " + message + ". Returning to menu."); + return new Menu(); + } + ); + } + + public static class HandCompletion implements Iterable { + private final Game game; + + public HandCompletion(Game game) { + this.game = game; + } + + @NotNull + @Override + public Iterator iterator() { + return game.getHands().get(game.getSelf()).stream().flatMap(c -> { + if (c == Card.CLOUD) { + return Stream.of(Card.CLOUD_BLUE, Card.CLOUD_GREEN, Card.CLOUD_RED, Card.CLOUD_YELLOW); + } else if (c == Card.JUGGLER) { + return Stream.of(Card.JUGGLER_BLUE, Card.JUGGLER_GREEN, Card.JUGGLER_RED, Card.JUGGLER_YELLOW); + } else if (c == Card.CHANGELING) { + return Stream.of(Card.CHANGELING_JESTER, Card.CHANGELING_WIZARD); + } else { + return Stream.of(c); + } + }).map(Card::toString).iterator(); + } + } + + public static class JuggleCompletion implements Iterable { + private final Game game; + + public JuggleCompletion(Game game) { + this.game = game; + } + + @NotNull + @Override + public Iterator iterator() { + return game.getHands().get(game.getSelf()).stream().map(Card::toString).iterator(); + } + } +} diff --git a/wizard-client/wizard-client-cli/src/main/java/eu/jonahbauer/wizard/client/cli/commands/LobbyCommand.java b/wizard-client/wizard-client-cli/src/main/java/eu/jonahbauer/wizard/client/cli/commands/LobbyCommand.java new file mode 100644 index 0000000..ba73de8 --- /dev/null +++ b/wizard-client/wizard-client-cli/src/main/java/eu/jonahbauer/wizard/client/cli/commands/LobbyCommand.java @@ -0,0 +1,101 @@ +package eu.jonahbauer.wizard.client.cli.commands; + +import eu.jonahbauer.wizard.client.cli.Client; +import eu.jonahbauer.wizard.client.cli.state.AwaitingJoinSession; +import eu.jonahbauer.wizard.client.cli.state.Lobby; +import eu.jonahbauer.wizard.client.cli.state.Menu; +import eu.jonahbauer.wizard.common.messages.client.CreateSessionMessage; +import eu.jonahbauer.wizard.common.messages.client.JoinSessionMessage; +import eu.jonahbauer.wizard.common.messages.data.SessionData; +import eu.jonahbauer.wizard.common.model.Configuration; +import lombok.Getter; +import org.ajbrown.namemachine.NameGenerator; +import org.java_websocket.framing.CloseFrame; +import org.jetbrains.annotations.NotNull; + +import java.util.Iterator; +import java.util.UUID; + +import static picocli.CommandLine.*; + +@Command(name = "\b", subcommands = {HelpCommand.class, QuitCommand.class}) +public class LobbyCommand { + @Getter(lazy = true) + private static final NameGenerator nameGenerator = new NameGenerator(); + + private final Client client; + private final Lobby lobby; + + public LobbyCommand(Client client, Lobby lobby) { + this.client = client; + this.lobby = lobby; + } + + @Command(name = "list", description = "Shows a list of available sessions") + public void list() { + var sessions = lobby.getSessions(); + var count = sessions.size(); + if (count > 0) { + StringBuilder builder = new StringBuilder(); + + if (count == 1) builder.append("There is one open session:\n"); + else builder.append("There are ").append(count).append(" open sessions:\n"); + + sessions.forEach((session) -> builder.append(session).append('\n')); + client.println(builder); + } else { + client.println("No sessions."); + } + } + + @Command(name = "disconnect", description = "Disconnects from the server") + public Menu disconnect() { + client.getSocket().close(CloseFrame.GOING_AWAY); + client.setSocket(null); + return new Menu(); + } + + @Command(name = "join", description = "Joins the specified session") + public AwaitingJoinSession join( + @Parameters(index = "0", paramLabel = "", description = "session uuid", completionCandidates = SessionCompleter.class) UUID session, + @Option(names = "--name", description = "user name") String userName + ) { + if (userName == null) { + userName = getNameGenerator().generateName().getFirstName(); + client.println("Your name will be " + userName + "."); + } + + client.send(new JoinSessionMessage(session, userName)); + return new AwaitingJoinSession(); + } + + @Command(name = "create") + public AwaitingJoinSession create( + @Parameters(index = "0", paramLabel = "", description = "human readable session name") String sessionName, + @Option(names = "--name", description = "user name") String userName, + @Option(names = "--configuration", description = "game configuration", defaultValue = "DEFAULT") Configuration configuration, + @Option(names = "--timeout", description = "interaction timeout", defaultValue = "60000") long timeout + ) { + if (userName == null) { + userName = getNameGenerator().generateName().getFirstName(); + client.println("Your name will be " + userName + "."); + } + + client.send(new CreateSessionMessage(sessionName, userName, timeout, configuration)); + return new AwaitingJoinSession(); + } + + public static class SessionCompleter implements Iterable { + private final Lobby lobby; + + private SessionCompleter(Lobby lobby) { + this.lobby = lobby; + } + + @NotNull + @Override + public Iterator iterator() { + return lobby.getSessions().stream().map(SessionData::getUuid).map(UUID::toString).iterator(); + } + } +} diff --git a/wizard-client/wizard-client-cli/src/main/java/eu/jonahbauer/wizard/client/cli/commands/MenuCommand.java b/wizard-client/wizard-client-cli/src/main/java/eu/jonahbauer/wizard/client/cli/commands/MenuCommand.java new file mode 100644 index 0000000..882e1ed --- /dev/null +++ b/wizard-client/wizard-client-cli/src/main/java/eu/jonahbauer/wizard/client/cli/commands/MenuCommand.java @@ -0,0 +1,46 @@ +package eu.jonahbauer.wizard.client.cli.commands; + +import eu.jonahbauer.wizard.client.cli.Client; +import eu.jonahbauer.wizard.client.cli.ClientSocket; +import eu.jonahbauer.wizard.client.cli.state.AwaitingConnection; +import eu.jonahbauer.wizard.client.cli.state.Lobby; +import eu.jonahbauer.wizard.common.messages.server.SessionListMessage; + +import java.net.URI; +import java.util.List; +import java.util.concurrent.CompletableFuture; + +import static picocli.CommandLine.*; + +@Command(name = "\b", subcommands = {HelpCommand.class, QuitCommand.class}) +public class MenuCommand { + private final Client client; + + public MenuCommand(Client client) { + this.client = client; + } + + @Command(name = "connect", description = "Connects to the specified server") + public AwaitingConnection connect( + @Parameters(index = "0", paramLabel = "", description = "server uri") URI uri + ) { + ClientSocket socket = new ClientSocket(uri); + client.setSocket(socket); + socket.connect(); + return new AwaitingConnection(); + } + + @Command(name = "dummy") + public Lobby dummy() { + CompletableFuture.runAsync(() -> { + try { + Thread.sleep(4000); + } catch (InterruptedException e) { + e.printStackTrace(); + } + + client.onClose(1000, "Test", false); + }); + return new Lobby(new SessionListMessage(List.of())); + } +} diff --git a/wizard-client/wizard-client-cli/src/main/java/eu/jonahbauer/wizard/client/cli/commands/QuitCommand.java b/wizard-client/wizard-client-cli/src/main/java/eu/jonahbauer/wizard/client/cli/commands/QuitCommand.java new file mode 100644 index 0000000..39518be --- /dev/null +++ b/wizard-client/wizard-client-cli/src/main/java/eu/jonahbauer/wizard/client/cli/commands/QuitCommand.java @@ -0,0 +1,7 @@ +package eu.jonahbauer.wizard.client.cli.commands; + +import static picocli.CommandLine.Command; + +@Command(name = "quit", aliases = "exit") +public class QuitCommand { +} diff --git a/wizard-client/wizard-client-cli/src/main/java/eu/jonahbauer/wizard/client/cli/commands/SessionCommand.java b/wizard-client/wizard-client-cli/src/main/java/eu/jonahbauer/wizard/client/cli/commands/SessionCommand.java new file mode 100644 index 0000000..ccb23c4 --- /dev/null +++ b/wizard-client/wizard-client-cli/src/main/java/eu/jonahbauer/wizard/client/cli/commands/SessionCommand.java @@ -0,0 +1,54 @@ +package eu.jonahbauer.wizard.client.cli.commands; + +import eu.jonahbauer.wizard.client.cli.Client; +import eu.jonahbauer.wizard.client.cli.state.AwaitingAcknowledgement; +import eu.jonahbauer.wizard.client.cli.state.AwaitingJoinLobby; +import eu.jonahbauer.wizard.client.cli.state.Session; +import eu.jonahbauer.wizard.common.messages.client.LeaveSessionMessage; +import eu.jonahbauer.wizard.common.messages.client.ReadyMessage; + +import static picocli.CommandLine.*; + +@Command(name = "\b", subcommands = {HelpCommand.class, QuitCommand.class}) +public class SessionCommand { + private final Client client; + private final Session session; + + public SessionCommand(Client client, Session session) { + this.client = client; + this.session = session; + } + + @Command(name = "ready") + public AwaitingAcknowledgement ready( + @Parameters(index = "0", paramLabel = "", defaultValue = "true") boolean ready + ) { + client.send(new ReadyMessage(ready)); + return new AwaitingAcknowledgement( + () -> new Session(session.getSelf(), session.getSession(), ready, session.getPlayers()) + ); + } + + @Command(name = "leave", description = "Leaves the current session and returns to the lobby") + public AwaitingJoinLobby leave() { + client.send(new LeaveSessionMessage()); + return new AwaitingJoinLobby(); + } + + @Command(name = "list", description = "Shows a list of players in the current session") + public void list() { + var players = session.getPlayers(); + var count = players.size(); + if (count > 0) { + StringBuilder builder = new StringBuilder(); + + if (count == 1) builder.append("There is one player in this session:\n"); + else builder.append("There are ").append(count).append(" players in this session:\n"); + + players.forEach((id, session) -> builder.append(session).append('\n')); + client.println(builder); + } else { + client.println("There are no players in this session."); + } + } +} diff --git a/wizard-client/wizard-client-cli/src/main/java/eu/jonahbauer/wizard/client/cli/commands/ShowCommand.java b/wizard-client/wizard-client-cli/src/main/java/eu/jonahbauer/wizard/client/cli/commands/ShowCommand.java new file mode 100644 index 0000000..6684468 --- /dev/null +++ b/wizard-client/wizard-client-cli/src/main/java/eu/jonahbauer/wizard/client/cli/commands/ShowCommand.java @@ -0,0 +1,57 @@ +package eu.jonahbauer.wizard.client.cli.commands; + +import eu.jonahbauer.wizard.client.cli.Client; +import eu.jonahbauer.wizard.client.cli.state.Game; +import eu.jonahbauer.wizard.client.cli.util.Pair; +import eu.jonahbauer.wizard.common.model.Card; + +import java.util.Map; +import java.util.UUID; + +import static picocli.CommandLine.Command; + +@Command(name = "show") +public class ShowCommand { + private final Client client; + private final Game game; + + public ShowCommand(Client client, Game game) { + this.client = client; + this.game = game; + } + + @Command(name = "players") + public void players() { + StringBuilder builder = new StringBuilder(); + for (Map.Entry player : game.getPlayers().entrySet()) { + builder.append(player.getValue()).append('\t').append(player.getKey()).append('\n'); + } + client.println(builder.toString()); + } + + @Command(name = "stack") + public void stack() { + StringBuilder builder = new StringBuilder(); + for (Pair entry : game.getStack()) { + builder.append(entry.getValue()).append('\t').append(game.nameOf(entry.getKey())).append('\n'); + } + client.println(builder.toString()); + } + + @Command(name = "hand") + public void hand() { + client.println(game.getHands().get(game.getSelf())); + } + + @Command(name = "predictions") + public void predictions() { + StringBuilder builder = new StringBuilder(); + game.getPredictions().forEach((player, prediction) -> builder.append(game.nameOf(player)).append('\t').append(prediction).append('\n')); + client.println(builder.toString()); + } + + @Command(name = "trump") + public void trump() { + client.printfln("%s (%s)", game.getTrumpSuit(), game.getTrumpCard()); + } +} diff --git a/wizard-client/wizard-client-cli/src/main/java/eu/jonahbauer/wizard/client/cli/state/Awaiting.java b/wizard-client/wizard-client-cli/src/main/java/eu/jonahbauer/wizard/client/cli/state/Awaiting.java new file mode 100644 index 0000000..8e5b211 --- /dev/null +++ b/wizard-client/wizard-client-cli/src/main/java/eu/jonahbauer/wizard/client/cli/state/Awaiting.java @@ -0,0 +1,35 @@ +package eu.jonahbauer.wizard.client.cli.state; + +import eu.jonahbauer.wizard.client.cli.Client; +import eu.jonahbauer.wizard.common.messages.server.ServerMessage; +import lombok.SneakyThrows; +import picocli.CommandLine.Model.CommandSpec; + +import java.util.Optional; + +public abstract class Awaiting extends BaseState implements ClientState { + private static final CommandSpec COMMAND_SPEC = CommandSpec.create(); + @Override + public Optional onMessage(Client client, ServerMessage message) { + return unexpectedMessage(client, message); + } + + @Override + @SneakyThrows + public Optional onEnter(Client client) { + client.waitForReady(); + client.timeout(this, 10_000); + return Optional.empty(); + } + + @Override + public Optional onTimeout(Client client) { + client.println("Timed out. Returning to menu"); + return Optional.of(new Menu()); + } + + @Override + public Object getCommand() { + return COMMAND_SPEC; + } +} diff --git a/wizard-client/wizard-client-cli/src/main/java/eu/jonahbauer/wizard/client/cli/state/AwaitingAcknowledgement.java b/wizard-client/wizard-client-cli/src/main/java/eu/jonahbauer/wizard/client/cli/state/AwaitingAcknowledgement.java new file mode 100644 index 0000000..2ad9cbf --- /dev/null +++ b/wizard-client/wizard-client-cli/src/main/java/eu/jonahbauer/wizard/client/cli/state/AwaitingAcknowledgement.java @@ -0,0 +1,43 @@ +package eu.jonahbauer.wizard.client.cli.state; + +import eu.jonahbauer.wizard.client.cli.Client; +import eu.jonahbauer.wizard.common.messages.server.AckMessage; +import eu.jonahbauer.wizard.common.messages.server.Response; +import eu.jonahbauer.wizard.common.messages.server.ServerMessage; + +import java.util.Optional; +import java.util.function.Function; +import java.util.function.Predicate; +import java.util.function.Supplier; + +public final class AwaitingAcknowledgement extends Awaiting { + private final Supplier success; + private final Function failure; + + public AwaitingAcknowledgement(Supplier success, Function failure) { + this.success = success; + this.failure = failure; + } + + public AwaitingAcknowledgement(Supplier success) { + this.success = success; + this.failure = null; + } + + @Override + public Optional onEnter(Client client) { + client.println("Waiting for acknowledgment..."); + return super.onEnter(client); + } + + @Override + public Optional onMessage(Client client, ServerMessage message) { + if (message instanceof AckMessage) { + return Optional.of(success.get()); + } else if (failure != null) { + return Optional.of(failure.apply(message)); + } else { + return super.onMessage(client, message); + } + } +} diff --git a/wizard-client/wizard-client-cli/src/main/java/eu/jonahbauer/wizard/client/cli/state/AwaitingConnection.java b/wizard-client/wizard-client-cli/src/main/java/eu/jonahbauer/wizard/client/cli/state/AwaitingConnection.java new file mode 100644 index 0000000..1de6def --- /dev/null +++ b/wizard-client/wizard-client-cli/src/main/java/eu/jonahbauer/wizard/client/cli/state/AwaitingConnection.java @@ -0,0 +1,26 @@ +package eu.jonahbauer.wizard.client.cli.state; + +import eu.jonahbauer.wizard.client.cli.Client; + +import java.util.Optional; + +public final class AwaitingConnection extends Awaiting { + + @Override + public Optional onEnter(Client client) { + client.println("Awaiting connection..."); + return super.onEnter(client); + } + + @Override + public Optional onOpen(Client client) { + client.println("Connection established."); + return Optional.of(new AwaitingJoinLobby()); + } + + @Override + public Optional onClose(Client client, int code, String reason, boolean remote) { + client.printfln("Connection could not be established. (code=%d, reason=%s)", code, reason); + return Optional.of(new Menu()); + } +} diff --git a/wizard-client/wizard-client-cli/src/main/java/eu/jonahbauer/wizard/client/cli/state/AwaitingJoinLobby.java b/wizard-client/wizard-client-cli/src/main/java/eu/jonahbauer/wizard/client/cli/state/AwaitingJoinLobby.java new file mode 100644 index 0000000..60e0644 --- /dev/null +++ b/wizard-client/wizard-client-cli/src/main/java/eu/jonahbauer/wizard/client/cli/state/AwaitingJoinLobby.java @@ -0,0 +1,25 @@ +package eu.jonahbauer.wizard.client.cli.state; + +import eu.jonahbauer.wizard.client.cli.Client; +import eu.jonahbauer.wizard.common.messages.server.ServerMessage; +import eu.jonahbauer.wizard.common.messages.server.SessionListMessage; + +import java.util.Optional; + +public final class AwaitingJoinLobby extends Awaiting { + + @Override + public Optional onEnter(Client client) { + client.println("Waiting for session list..."); + return super.onEnter(client); + } + + @Override + public Optional onMessage(Client client, ServerMessage message) { + if (message instanceof SessionListMessage list) { + return Optional.of(new Lobby(list)); + } else { + return super.onMessage(client, message); + } + } +} diff --git a/wizard-client/wizard-client-cli/src/main/java/eu/jonahbauer/wizard/client/cli/state/AwaitingJoinSession.java b/wizard-client/wizard-client-cli/src/main/java/eu/jonahbauer/wizard/client/cli/state/AwaitingJoinSession.java new file mode 100644 index 0000000..46d4aac --- /dev/null +++ b/wizard-client/wizard-client-cli/src/main/java/eu/jonahbauer/wizard/client/cli/state/AwaitingJoinSession.java @@ -0,0 +1,35 @@ +package eu.jonahbauer.wizard.client.cli.state; + +import eu.jonahbauer.wizard.client.cli.Client; +import eu.jonahbauer.wizard.common.messages.server.NackMessage; +import eu.jonahbauer.wizard.common.messages.server.ServerMessage; +import eu.jonahbauer.wizard.common.messages.server.SessionJoinedMessage; + +import java.util.Optional; + +public final class AwaitingJoinSession extends Awaiting { + + @Override + public Optional onEnter(Client client) { + client.println("Waiting for acknowledgment..."); + return super.onEnter(client); + } + + @Override + public Optional onMessage(Client client, ServerMessage message) { + if (message instanceof SessionJoinedMessage joined) { + return Optional.of(new Session(joined)); + } else if (message instanceof NackMessage nack) { + switch (nack.getCode()) { + case NackMessage.GAME_ALREADY_STARTED -> client.println("Error: Game has already started."); + case NackMessage.SESSION_FULL -> client.println("Error: The session is full."); + case NackMessage.SESSION_NOT_FOUND -> client.println("Error: Session not found."); + case NackMessage.NAME_TAKEN -> client.println("Error: Name already taken."); + default -> { return super.onMessage(client, message); } + } + return Optional.of(new AwaitingJoinLobby()); + } else { + return super.onMessage(client, message); + } + } +} diff --git a/wizard-client/wizard-client-cli/src/main/java/eu/jonahbauer/wizard/client/cli/state/BaseState.java b/wizard-client/wizard-client-cli/src/main/java/eu/jonahbauer/wizard/client/cli/state/BaseState.java new file mode 100644 index 0000000..65cf3c2 --- /dev/null +++ b/wizard-client/wizard-client-cli/src/main/java/eu/jonahbauer/wizard/client/cli/state/BaseState.java @@ -0,0 +1,36 @@ +package eu.jonahbauer.wizard.client.cli.state; + +import eu.jonahbauer.wizard.client.cli.Client; +import eu.jonahbauer.wizard.common.messages.server.ServerMessage; + +import java.util.Optional; + +public abstract class BaseState implements ClientState { + + @Override + public Optional onEnter(Client context) { + context.ready(); + return ClientState.super.onEnter(context); + } + + @Override + public Optional onOpen(Client client) { + throw new IllegalStateException(); + } + + @Override + public Optional onClose(Client client, int code, String reason, boolean remote) { + if (remote) { + client.printfln("Lost connection (code=%d, reason=%s).", code, reason); + } else { + client.printfln("Connection closed (code=%d, reason=%s).", code, reason); + } + return Optional.of(new Menu()); + } + + protected static Optional unexpectedMessage(Client client, ServerMessage message) { + // return to menu on unexpected message + client.println("Fatal: Unexpected message " + message + ". Returning to menu."); + return Optional.of(new Menu()); + } +} diff --git a/wizard-client/wizard-client-cli/src/main/java/eu/jonahbauer/wizard/client/cli/state/ClientState.java b/wizard-client/wizard-client-cli/src/main/java/eu/jonahbauer/wizard/client/cli/state/ClientState.java new file mode 100644 index 0000000..edf90ff --- /dev/null +++ b/wizard-client/wizard-client-cli/src/main/java/eu/jonahbauer/wizard/client/cli/state/ClientState.java @@ -0,0 +1,17 @@ +package eu.jonahbauer.wizard.client.cli.state; + +import eu.jonahbauer.wizard.client.cli.Client; +import eu.jonahbauer.wizard.common.machine.TimeoutState; +import eu.jonahbauer.wizard.common.messages.server.ServerMessage; + +import java.util.Optional; + +public interface ClientState extends TimeoutState { + Optional onOpen(Client client); + + Optional onMessage(Client client, ServerMessage message); + + Optional onClose(Client client, int code, String reason, boolean remote); + + Object getCommand(); +} diff --git a/wizard-client/wizard-client-cli/src/main/java/eu/jonahbauer/wizard/client/cli/state/Game.java b/wizard-client/wizard-client-cli/src/main/java/eu/jonahbauer/wizard/client/cli/state/Game.java new file mode 100644 index 0000000..1008d0a --- /dev/null +++ b/wizard-client/wizard-client-cli/src/main/java/eu/jonahbauer/wizard/client/cli/state/Game.java @@ -0,0 +1,158 @@ +package eu.jonahbauer.wizard.client.cli.state; + +import eu.jonahbauer.wizard.client.cli.Client; +import eu.jonahbauer.wizard.client.cli.commands.GameCommand; +import eu.jonahbauer.wizard.client.cli.util.Pair; +import eu.jonahbauer.wizard.common.messages.data.PlayerData; +import eu.jonahbauer.wizard.common.messages.observer.*; +import eu.jonahbauer.wizard.common.messages.server.GameMessage; +import eu.jonahbauer.wizard.common.messages.server.ServerMessage; +import eu.jonahbauer.wizard.common.model.Card; +import lombok.Getter; + +import java.time.Instant; +import java.time.LocalDateTime; +import java.time.ZoneId; +import java.util.*; +import java.util.stream.Collectors; + +@Getter +public final class Game extends BaseState { + private final UUID self; + private final UUID session; + + private final Map players; + private final Map scores = new HashMap<>(); + + private int round = -1; + private final Map predictions = new HashMap<>(); + private final Map> hands = new HashMap<>(); + + private int trick = -1; + private final List> stack = new ArrayList<>(); + + private Card trumpCard; + private Card.Suit trumpSuit; + + public Game(UUID self, UUID session, Map players) { + this.self = self; + this.session = session; + this.players = players; + } + + @Override + public Optional onMessage(Client client, ServerMessage message) { + if (message instanceof GameMessage game) { + var observerMessage = game.getObserverMessage(); + if (observerMessage instanceof StateMessage state) { + switch (state.getState()) { + case "starting_round" -> { + client.printfln("Round %d is starting...", ++round); + predictions.clear(); + trumpSuit = null; + trumpCard = null; + stack.clear(); + trick = -1; + } + case "starting_trick" -> { + client.printfln("Trick %d is starting...", ++trick); + stack.clear(); + } + case "finished" -> { + client.println("The game has finished."); + return returnToSession(); + } + case "error" -> { + client.println("The game has finished with an error."); + return returnToSession(); + } + } + } else if (observerMessage instanceof HandMessage hand) { + hands.put(hand.getPlayer(), hand.getHand()); + if (hand.getPlayer().equals(self)) { + client.printfln("Your hand cards are: %s", hand.getHand()); + } else { + client.printfln("%s's hand cards are: %s", nameOf(hand.getPlayer()), hand.getHand()); + } + } else if (observerMessage instanceof PredictionMessage prediction) { + predictions.put(prediction.getPlayer(), prediction.getPrediction()); + if (prediction.getPlayer().equals(self)) { + client.printfln("You predicted: %d", prediction.getPrediction()); + } else { + client.printfln("%s predicted: %d", nameOf(prediction.getPlayer()), prediction.getPrediction()); + } + } else if (observerMessage instanceof TrumpMessage trump) { + trumpCard = trump.getCard(); + trumpSuit = trump.getSuit(); + if (trumpCard == null) { + client.println("There is no trump in this round."); + } else { + client.printfln("The trump suit is %s (%s).", trumpSuit, trumpCard); + } + } else if (observerMessage instanceof TrickMessage trick) { + this.stack.clear(); + client.printfln("This trick %s goes to %s.", trick.getCards(), nameOf(trick.getPlayer())); + } else if (observerMessage instanceof CardMessage card) { + this.stack.add(Pair.of(card.getPlayer(), card.getCard())); + + var hand = this.hands.get(card.getPlayer()); + if (hand != null) hand.remove(card.getCard()); + + if (card.getPlayer().equals(self)) { + client.printfln("You played %s.", card.getCard()); + } else { + client.printfln("%s played %s.", nameOf(card.getPlayer()), card.getCard()); + } + } else if (observerMessage instanceof ScoreMessage score) { + score.getPoints().forEach((player, points) -> scores.merge(player, points, Integer::sum)); + StringBuilder builder = new StringBuilder("The scores are as follows:\n"); + scores.forEach((player, points) -> builder.append(nameOf(player)).append('\t').append(points).append('\n')); + client.println(builder.toString()); + } else if (observerMessage instanceof UserInputMessage input) { + if (input.getPlayer().equals(self)) { + client.printfln("It is your turn to %s. You have time until %s.", switch (input.getAction()) { + case CHANGE_PREDICTION -> "change your prediction"; + case JUGGLE_CARD -> "juggle a card"; + case PLAY_CARD -> "play a card"; + case PICK_TRUMP -> "pick the trump suit"; + case MAKE_PREDICTION -> "make a prediction"; + }, LocalDateTime.ofInstant(Instant.ofEpochMilli(input.getTimeout()), ZoneId.systemDefault())); + } else { + client.printfln( + "Waiting for input %s from %s. (times out at %s)", + input.getAction(), + nameOf(input.getPlayer()), + LocalDateTime.ofInstant(Instant.ofEpochMilli(input.getTimeout()), ZoneId.systemDefault()) + ); + } + } else { + throw new AssertionError("Unknown observer message " + observerMessage.getClass().getSimpleName() + ""); + } + return Optional.empty(); + } else { + return unexpectedMessage(client, message); + } + } + + public String nameOf(UUID player) { + return players.get(player); + } + + @Override + public Object getCommand() { + return GameCommand.class; + } + + private Optional returnToSession() { + return Optional.of(new Session( + self, + session, + false, + players.entrySet().stream() + .collect(Collectors.toMap( + Map.Entry::getKey, + entry -> new PlayerData(entry.getKey(), entry.getValue(), false) + )) + )); + } +} diff --git a/wizard-client/wizard-client-cli/src/main/java/eu/jonahbauer/wizard/client/cli/state/Lobby.java b/wizard-client/wizard-client-cli/src/main/java/eu/jonahbauer/wizard/client/cli/state/Lobby.java new file mode 100644 index 0000000..3f716eb --- /dev/null +++ b/wizard-client/wizard-client-cli/src/main/java/eu/jonahbauer/wizard/client/cli/state/Lobby.java @@ -0,0 +1,55 @@ +package eu.jonahbauer.wizard.client.cli.state; + +import eu.jonahbauer.wizard.client.cli.Client; +import eu.jonahbauer.wizard.client.cli.commands.LobbyCommand; +import eu.jonahbauer.wizard.common.messages.data.SessionData; +import eu.jonahbauer.wizard.common.messages.server.*; +import lombok.Getter; + +import java.util.*; + +public final class Lobby extends BaseState { + @Getter + private final Set sessions = new HashSet<>(); + + public Lobby(SessionListMessage list) { + sessions.addAll(list.getSessions()); + } + + @Override + public Optional onEnter(Client client) { + if (sessions.size() == 1) { + client.println("Successfully joined the server. There is one open session."); + } else { + client.printfln("Successfully joined the server. There are %d open sessions.", sessions.size()); + } + return super.onEnter(client); + } + + @Override + public Optional onMessage(Client client, ServerMessage message) { + if (message instanceof SessionCreatedMessage created) { + var session = created.getSession(); + sessions.add(session); + return Optional.empty(); + } else if (message instanceof SessionRemovedMessage removed) { + sessions.remove(new SessionData(removed.getSession(), null, 0, null)); + return Optional.empty(); + } else if (message instanceof SessionModifiedMessage modified) { + sessions.remove(modified.getSession()); + sessions.add(modified.getSession()); + return Optional.empty(); + } else if (message instanceof SessionListMessage list) { + sessions.clear(); + sessions.addAll(list.getSessions()); + return Optional.empty(); + } else { + return unexpectedMessage(client, message); + } + } + + @Override + public Object getCommand() { + return LobbyCommand.class; + } +} diff --git a/wizard-client/wizard-client-cli/src/main/java/eu/jonahbauer/wizard/client/cli/state/Menu.java b/wizard-client/wizard-client-cli/src/main/java/eu/jonahbauer/wizard/client/cli/state/Menu.java new file mode 100644 index 0000000..c5a89d3 --- /dev/null +++ b/wizard-client/wizard-client-cli/src/main/java/eu/jonahbauer/wizard/client/cli/state/Menu.java @@ -0,0 +1,40 @@ +package eu.jonahbauer.wizard.client.cli.state; + +import eu.jonahbauer.wizard.client.cli.Client; +import eu.jonahbauer.wizard.client.cli.commands.MenuCommand; +import eu.jonahbauer.wizard.common.messages.server.ServerMessage; +import lombok.SneakyThrows; +import org.java_websocket.framing.CloseFrame; + +import java.util.Optional; + +public final class Menu extends BaseState { + + @Override + @SneakyThrows + public Optional onEnter(Client client) { + if (client.getSocket() != null && client.getSocket().isOpen()) { + client.getSocket().close(CloseFrame.GOING_AWAY); + client.waitForReady(); + return Optional.empty(); + } + return super.onEnter(client); + } + + @Override + public Optional onMessage(Client client, ServerMessage message) { + throw new AssertionError("Received a ServerMessage while not connected."); + } + + @Override + public Optional onClose(Client client, int code, String reason, boolean remote) { + client.ready(); + super.onClose(client, code, reason, remote); + return Optional.empty(); + } + + @Override + public Object getCommand() { + return MenuCommand.class; + } +} diff --git a/wizard-client/wizard-client-cli/src/main/java/eu/jonahbauer/wizard/client/cli/state/Session.java b/wizard-client/wizard-client-cli/src/main/java/eu/jonahbauer/wizard/client/cli/state/Session.java new file mode 100644 index 0000000..b5767b7 --- /dev/null +++ b/wizard-client/wizard-client-cli/src/main/java/eu/jonahbauer/wizard/client/cli/state/Session.java @@ -0,0 +1,80 @@ +package eu.jonahbauer.wizard.client.cli.state; + +import eu.jonahbauer.wizard.client.cli.Client; +import eu.jonahbauer.wizard.client.cli.commands.SessionCommand; +import eu.jonahbauer.wizard.common.messages.data.PlayerData; +import eu.jonahbauer.wizard.common.messages.server.*; +import lombok.Getter; +import picocli.CommandLine.Model.CommandSpec; + +import java.util.HashMap; +import java.util.Map; +import java.util.Optional; +import java.util.UUID; +import java.util.stream.Collectors; + +@Getter +public final class Session extends BaseState { + private static final CommandSpec COMMAND_SPEC = CommandSpec.forAnnotatedObject(new SessionCommand(null, null)); + + private final UUID self; + private final UUID session; + private final boolean ready; + private final Map players = new HashMap<>(); + + public Session(SessionJoinedMessage joined) { + this.self = joined.getPlayer(); + this.session = joined.getSession(); + this.ready = false; + joined.getPlayers().forEach(player -> players.put(player.getUuid(), player)); + } + + public Session(UUID self, UUID session, boolean ready, Map players) { + this.self = self; + this.session = session; + this.players.putAll(players); + this.ready = ready; + } + + @Override + public Optional onEnter(Client client) { + if (players.size() - 1 == 1) { + client.printfln("Successfully joined session %s. There is one other player.", session); + } else { + client.printfln("Successfully joined session %s. There are %d other players.", session, players.size() - 1); + } + return super.onEnter(client); + } + + @Override + public Optional onMessage(Client client, ServerMessage message) { + if (message instanceof PlayerJoinedMessage join) { + var player = join.getPlayer(); + players.put(player.getUuid(), player); + client.printfln("Player \"%s\" joined the session.", player.getName()); + return Optional.empty(); + } else if (message instanceof PlayerLeftMessage leave) { + var player = players.remove(leave.getPlayer()); + client.printfln("Player \"%s\" left the session.", player.getName()); + return Optional.empty(); + } else if (message instanceof PlayerModifiedMessage modified) { + var player = modified.getPlayer(); + players.put(player.getUuid(), player); + client.printfln("Player \"%s\" was modified.", player.getName()); + return Optional.empty(); + } else if (message instanceof StartingGameMessage) { + return Optional.of(new Game( + self, + session, + players.values().stream().collect(Collectors.toMap(PlayerData::getUuid, PlayerData::getName)) + )); + } else { + return unexpectedMessage(client, message); + } + } + + @Override + public Object getCommand() { + return SessionCommand.class; + } +} diff --git a/wizard-client/wizard-client-cli/src/main/java/eu/jonahbauer/wizard/client/cli/util/DelegateCompleter.java b/wizard-client/wizard-client-cli/src/main/java/eu/jonahbauer/wizard/client/cli/util/DelegateCompleter.java new file mode 100644 index 0000000..43d1bcb --- /dev/null +++ b/wizard-client/wizard-client-cli/src/main/java/eu/jonahbauer/wizard/client/cli/util/DelegateCompleter.java @@ -0,0 +1,13 @@ +package eu.jonahbauer.wizard.client.cli.util; + +import lombok.Getter; +import lombok.Setter; +import lombok.experimental.Delegate; +import org.jline.reader.Completer; + +@Getter +@Setter +public class DelegateCompleter implements Completer { + @Delegate + private Completer delegate; +} diff --git a/wizard-client/wizard-client-cli/src/main/java/eu/jonahbauer/wizard/client/cli/util/Pair.java b/wizard-client/wizard-client-cli/src/main/java/eu/jonahbauer/wizard/client/cli/util/Pair.java new file mode 100644 index 0000000..9050e1b --- /dev/null +++ b/wizard-client/wizard-client-cli/src/main/java/eu/jonahbauer/wizard/client/cli/util/Pair.java @@ -0,0 +1,24 @@ +package eu.jonahbauer.wizard.client.cli.util; + +import java.util.Map; + +public record Pair(F first, S second) implements Map.Entry { + public static Pair of(F first, S second) { + return new Pair<>(first, second); + } + + @Override + public F getKey() { + return first(); + } + + @Override + public S getValue() { + return second(); + } + + @Override + public S setValue(S value) { + throw new UnsupportedOperationException(); + } +} diff --git a/wizard-client/wizard-client-cli/src/main/java/eu/jonahbauer/wizard/client/cli/util/StateAwareFactory.java b/wizard-client/wizard-client-cli/src/main/java/eu/jonahbauer/wizard/client/cli/util/StateAwareFactory.java new file mode 100644 index 0000000..ca1553c --- /dev/null +++ b/wizard-client/wizard-client-cli/src/main/java/eu/jonahbauer/wizard/client/cli/util/StateAwareFactory.java @@ -0,0 +1,50 @@ +package eu.jonahbauer.wizard.client.cli.util; + + +import eu.jonahbauer.wizard.client.cli.Client; +import eu.jonahbauer.wizard.client.cli.state.ClientState; +import picocli.CommandLine; + +import java.lang.reflect.Constructor; +import java.lang.reflect.Parameter; + +public class StateAwareFactory implements CommandLine.IFactory { + private final CommandLine.IFactory defaultFactory = CommandLine.defaultFactory(); + private final Client client; + private final ClientState clientState; + + public StateAwareFactory(Client client, ClientState clientState) { + this.client = client; + this.clientState = clientState; + } + + @Override + @SuppressWarnings("unchecked") + public K create(Class cls) throws Exception { + try { + return defaultFactory.create(cls); + } catch (Exception e) { + c: for (Constructor constructor : (Constructor[]) cls.getDeclaredConstructors()) { + Object[] values = new Object[constructor.getParameterCount()]; + Parameter[] parameters = constructor.getParameters(); + for (int i = 0; i < parameters.length; i++) { + Parameter parameter = parameters[i]; + if (ClientState.class.isAssignableFrom(parameter.getType())) { + values[i] = clientState; + } else if (Client.class.isAssignableFrom(parameter.getType())) { + values[i] = client; + } else { + continue c; + } + } + + constructor.setAccessible(true); + return constructor.newInstance(values); + } + + var e2 = new NoSuchMethodException("Could not find a suitable constructor for class " + cls.getName()); + e2.addSuppressed(e); + throw e2; + } + } +} diff --git a/wizard-common/src/main/java/eu/jonahbauer/wizard/common/messages/client/ClientMessage.java b/wizard-common/src/main/java/eu/jonahbauer/wizard/common/messages/client/ClientMessage.java index b11da20..54d0833 100644 --- a/wizard-common/src/main/java/eu/jonahbauer/wizard/common/messages/client/ClientMessage.java +++ b/wizard-common/src/main/java/eu/jonahbauer/wizard/common/messages/client/ClientMessage.java @@ -3,7 +3,7 @@ package eu.jonahbauer.wizard.common.messages.client; import com.google.gson.Gson; import com.google.gson.GsonBuilder; import com.google.gson.JsonParseException; -import eu.jonahbauer.wizard.common.messages.observer.ObserverMessage; +import eu.jonahbauer.wizard.common.messages.player.PlayerMessage; import eu.jonahbauer.wizard.common.util.SealedClassTypeAdapterFactory; import lombok.EqualsAndHashCode; @@ -11,7 +11,7 @@ import lombok.EqualsAndHashCode; public abstract sealed class ClientMessage permits CreateSessionMessage, InteractionMessage, JoinSessionMessage, LeaveSessionMessage, ReadyMessage, RejoinMessage { private static final Gson GSON = new GsonBuilder() .registerTypeAdapterFactory(SealedClassTypeAdapterFactory.of(ClientMessage.class, "Message")) - .registerTypeAdapterFactory(SealedClassTypeAdapterFactory.of(ObserverMessage.class, "Message")) + .registerTypeAdapterFactory(SealedClassTypeAdapterFactory.of(PlayerMessage.class, "Message")) .create(); public static ClientMessage parse(String json) throws JsonParseException { diff --git a/wizard-common/src/main/java/eu/jonahbauer/wizard/common/messages/data/PlayerData.java b/wizard-common/src/main/java/eu/jonahbauer/wizard/common/messages/data/PlayerData.java index 00e186f..6f7387c 100644 --- a/wizard-common/src/main/java/eu/jonahbauer/wizard/common/messages/data/PlayerData.java +++ b/wizard-common/src/main/java/eu/jonahbauer/wizard/common/messages/data/PlayerData.java @@ -3,12 +3,14 @@ package eu.jonahbauer.wizard.common.messages.data; import lombok.EqualsAndHashCode; import lombok.Getter; import lombok.RequiredArgsConstructor; +import lombok.ToString; import java.util.UUID; @Getter @RequiredArgsConstructor @EqualsAndHashCode(of = "uuid") +@ToString public class PlayerData { /** * UUID of the player diff --git a/wizard-common/src/main/java/eu/jonahbauer/wizard/common/messages/data/SessionData.java b/wizard-common/src/main/java/eu/jonahbauer/wizard/common/messages/data/SessionData.java index 0e8be35..565b24d 100644 --- a/wizard-common/src/main/java/eu/jonahbauer/wizard/common/messages/data/SessionData.java +++ b/wizard-common/src/main/java/eu/jonahbauer/wizard/common/messages/data/SessionData.java @@ -4,12 +4,14 @@ import eu.jonahbauer.wizard.common.model.Configuration; import lombok.EqualsAndHashCode; import lombok.Getter; import lombok.RequiredArgsConstructor; +import lombok.ToString; import java.util.UUID; @Getter @RequiredArgsConstructor @EqualsAndHashCode(of = "uuid") +@ToString public class SessionData { /** * UUID of the session diff --git a/wizard-common/src/main/java/eu/jonahbauer/wizard/common/messages/server/NackMessage.java b/wizard-common/src/main/java/eu/jonahbauer/wizard/common/messages/server/NackMessage.java index 6e52ac6..56ec398 100644 --- a/wizard-common/src/main/java/eu/jonahbauer/wizard/common/messages/server/NackMessage.java +++ b/wizard-common/src/main/java/eu/jonahbauer/wizard/common/messages/server/NackMessage.java @@ -7,8 +7,19 @@ import org.intellij.lang.annotations.MagicConstant; @Getter @EqualsAndHashCode(callSuper = true) public final class NackMessage extends ServerMessage implements Response { - public static final int BAD_REQUEST = 400; - public static final int NOT_FOUND = 404; + public static final int MALFORMED_MESSAGE = 100; + public static final int UNEXPECTED_MESSAGE = 101; + + public static final int ILLEGAL_ARGUMENT = 200; + public static final int NAME_TAKEN = 201; + public static final int NOT_FOUND = 210; + public static final int SESSION_NOT_FOUND = 211; + public static final int PLAYER_NOT_FOUND = 212; + + public static final int ILLEGAL_STATE = 300; + public static final int GAME_ALREADY_STARTED = 301; + public static final int GAME_NOT_YET_STARTED = 302; + public static final int SESSION_FULL = 303; private final int code; private final String message;