CLI Client #15

main
Jonah Bauer 3 years ago
parent acad8faa0e
commit 37b31869cd

@ -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

@ -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)
}

@ -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<ClientState, Client> 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);
}
//<editor-fold desc="Socket" defaultstate="collapsed">
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());
}
//</editor-fold>
//<editor-fold desc="Output" defaultstate="collapsed">
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));
}
//</editor-fold>
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<ClientState> execute(CommandSpec spec, ParsedLine line) {
var commandLine = spec.commandLine();
AtomicReference<Exception> 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<ClientState>) result;
} else {
return Optional.empty();
}
} else {
handleError(exception);
return Optional.empty();
}
}
}

@ -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();
}
}

@ -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 = "<card>", 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 = "<prediction>") int prediction
) {
this.client.send(new InteractionMessage(new PredictMessage(prediction)));
return awaitAcknowledgement();
}
@Command(name = "trump")
public AwaitingAcknowledgement trump(
@Parameters(index = "0", paramLabel = "<suit>") Card.Suit suit
) {
this.client.send(new InteractionMessage(new PickTrumpMessage(suit)));
return awaitAcknowledgement();
}
@Command(name = "juggle")
public AwaitingAcknowledgement juggle(
@Parameters(index = "0", paramLabel = "<card>", 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<String> {
private final Game game;
public HandCompletion(Game game) {
this.game = game;
}
@NotNull
@Override
public Iterator<String> 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<String> {
private final Game game;
public JuggleCompletion(Game game) {
this.game = game;
}
@NotNull
@Override
public Iterator<String> iterator() {
return game.getHands().get(game.getSelf()).stream().map(Card::toString).iterator();
}
}
}

@ -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 = "<session>", 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 = "<session>", 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<String> {
private final Lobby lobby;
private SessionCompleter(Lobby lobby) {
this.lobby = lobby;
}
@NotNull
@Override
public Iterator<String> iterator() {
return lobby.getSessions().stream().map(SessionData::getUuid).map(UUID::toString).iterator();
}
}
}

@ -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 = "<uri>", 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()));
}
}

@ -0,0 +1,7 @@
package eu.jonahbauer.wizard.client.cli.commands;
import static picocli.CommandLine.Command;
@Command(name = "quit", aliases = "exit")
public class QuitCommand {
}

@ -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 = "<ready>", 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.");
}
}
}

@ -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<UUID, String> 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<UUID, Card> 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());
}
}

@ -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<ClientState> onMessage(Client client, ServerMessage message) {
return unexpectedMessage(client, message);
}
@Override
@SneakyThrows
public Optional<ClientState> onEnter(Client client) {
client.waitForReady();
client.timeout(this, 10_000);
return Optional.empty();
}
@Override
public Optional<ClientState> onTimeout(Client client) {
client.println("Timed out. Returning to menu");
return Optional.of(new Menu());
}
@Override
public Object getCommand() {
return COMMAND_SPEC;
}
}

@ -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<ClientState> success;
private final Function<ServerMessage, ClientState> failure;
public AwaitingAcknowledgement(Supplier<ClientState> success, Function<ServerMessage, ClientState> failure) {
this.success = success;
this.failure = failure;
}
public AwaitingAcknowledgement(Supplier<ClientState> success) {
this.success = success;
this.failure = null;
}
@Override
public Optional<ClientState> onEnter(Client client) {
client.println("Waiting for acknowledgment...");
return super.onEnter(client);
}
@Override
public Optional<ClientState> 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);
}
}
}

@ -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<ClientState> onEnter(Client client) {
client.println("Awaiting connection...");
return super.onEnter(client);
}
@Override
public Optional<ClientState> onOpen(Client client) {
client.println("Connection established.");
return Optional.of(new AwaitingJoinLobby());
}
@Override
public Optional<ClientState> 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());
}
}

@ -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<ClientState> onEnter(Client client) {
client.println("Waiting for session list...");
return super.onEnter(client);
}
@Override
public Optional<ClientState> onMessage(Client client, ServerMessage message) {
if (message instanceof SessionListMessage list) {
return Optional.of(new Lobby(list));
} else {
return super.onMessage(client, message);
}
}
}

@ -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<ClientState> onEnter(Client client) {
client.println("Waiting for acknowledgment...");
return super.onEnter(client);
}
@Override
public Optional<ClientState> 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);
}
}
}

@ -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<ClientState> onEnter(Client context) {
context.ready();
return ClientState.super.onEnter(context);
}
@Override
public Optional<ClientState> onOpen(Client client) {
throw new IllegalStateException();
}
@Override
public Optional<ClientState> 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<ClientState> 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());
}
}

@ -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<ClientState, Client> {
Optional<ClientState> onOpen(Client client);
Optional<ClientState> onMessage(Client client, ServerMessage message);
Optional<ClientState> onClose(Client client, int code, String reason, boolean remote);
Object getCommand();
}

@ -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<UUID, String> players;
private final Map<UUID, Integer> scores = new HashMap<>();
private int round = -1;
private final Map<UUID, Integer> predictions = new HashMap<>();
private final Map<UUID, List<Card>> hands = new HashMap<>();
private int trick = -1;
private final List<Pair<UUID, Card>> stack = new ArrayList<>();
private Card trumpCard;
private Card.Suit trumpSuit;
public Game(UUID self, UUID session, Map<UUID, String> players) {
this.self = self;
this.session = session;
this.players = players;
}
@Override
public Optional<ClientState> 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<ClientState> 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)
))
));
}
}

@ -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<SessionData> sessions = new HashSet<>();
public Lobby(SessionListMessage list) {
sessions.addAll(list.getSessions());
}
@Override
public Optional<ClientState> 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<ClientState> 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;
}
}

@ -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<ClientState> 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<ClientState> onMessage(Client client, ServerMessage message) {
throw new AssertionError("Received a ServerMessage while not connected.");
}
@Override
public Optional<ClientState> 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;
}
}

@ -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<UUID, PlayerData> 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<UUID, PlayerData> players) {
this.self = self;
this.session = session;
this.players.putAll(players);
this.ready = ready;
}
@Override
public Optional<ClientState> 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<ClientState> 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;
}
}

@ -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;
}

@ -0,0 +1,24 @@
package eu.jonahbauer.wizard.client.cli.util;
import java.util.Map;
public record Pair<F,S>(F first, S second) implements Map.Entry<F, S> {
public static <F,S> Pair<F,S> 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();
}
}

@ -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> K create(Class<K> cls) throws Exception {
try {
return defaultFactory.create(cls);
} catch (Exception e) {
c: for (Constructor<K> constructor : (Constructor<K>[]) 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;
}
}
}

@ -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 {

@ -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

@ -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

@ -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;

Loading…
Cancel
Save