diff --git a/src/main/java/eu/mhsl/craftattack/loginServer/Api.java b/src/main/java/eu/mhsl/craftattack/loginServer/Api.java index 63f50d2..7f55e4e 100644 --- a/src/main/java/eu/mhsl/craftattack/loginServer/Api.java +++ b/src/main/java/eu/mhsl/craftattack/loginServer/Api.java @@ -16,10 +16,12 @@ import java.util.UUID; public final class Api { private static final HttpClient client = HttpClient.newHttpClient(); + private static final boolean offline = false; private Api() {} public static boolean isRegistered(UUID uuid) { + if (offline) return false; HttpResponse response = call("GET", "/api/users/" + uuid, null, true); int status = response.statusCode(); if (status == 200) return true; @@ -44,6 +46,12 @@ public final class Api { UUID uuid, String edition ) { + if (offline) { + System.out.println("[Api offline] signup: " + firstname + " " + lastname + ", " + birthdayIso + + ", " + phone + ", " + username + ", " + uuid + ", " + edition); + return; + } + JsonObject body = new JsonObject(); body.addProperty("firstname", firstname); body.addProperty("lastname", lastname); diff --git a/src/main/java/eu/mhsl/craftattack/loginServer/Dialog.java b/src/main/java/eu/mhsl/craftattack/loginServer/Dialog.java index db95682..0c656ae 100644 --- a/src/main/java/eu/mhsl/craftattack/loginServer/Dialog.java +++ b/src/main/java/eu/mhsl/craftattack/loginServer/Dialog.java @@ -22,11 +22,15 @@ public final class Dialog { private static final Key nextKey = Key.key(namespace, "next"); private static final Key backKey = Key.key(namespace, "back"); private static final Key finishKey = Key.key(namespace, "finish"); + private static final Key cancelKey = Key.key(namespace, "cancel"); + private static final Key disconnectKey = Key.key(namespace, "disconnect"); private static final String privacyUrl = "https://mhsl.eu/id.html"; private static final String rulesUrl = "https://mhsl.eu/craftattack/rules"; private static final int rulesWidth = 300; + private static final int buttonWidth = 150; + private static final int cancelWidth = 2 * buttonWidth + 4; private static final String rulesPreamble = "Alle Teilnehmer müssen die Regeln lesen. Sie sorgen für einen reibungslosen, geordneten Ablauf von CraftAttack 9 und sind wörtlich zu nehmen. Sie gehen bewusst nicht tief ins Detail und umreißen manche Themen nur grob. Auf Wunsch der Spieler entscheiden die Administratoren und orientieren sich dabei an den Regeln. Für Vollständigkeit, Richtigkeit und Aktualität aller Angaben wird keine Gewähr übernommen. Ob und wie Regeln durchgesetzt werden, entscheiden die Administratoren, nachdem sie sich mit dem Geschädigten abgesprochen und eine zur Situation passende Maßnahme gewählt haben."; @@ -49,11 +53,58 @@ public final class Dialog { return key.namespace().equals(namespace); } + public static boolean isCancelKey(Key key) { + return key.equals(cancelKey); + } + + public static boolean isDisconnectKey(Key key) { + return key.equals(disconnectKey); + } + public static void open(Player player, DialogState state) { - showPage(player, state, null); + if (state.isDone()) showDone(player, state.doneMessage()); + else if (state.submitting) showSubmitting(player); + else showPage(player, state, state.pendingError); + } + + public static void showError(Player player, DialogState state, String message) { + state.pendingError = error(message); + showPage(player, state, state.pendingError); + } + + private static void showSubmitting(Player player) { + player.showDialog(new net.minestom.server.dialog.Dialog.Notice( + new DialogMetadata( + Component.text("Anmeldung wird gesendet …"), + null, + false, + false, + DialogAfterAction.NONE, + List.of(plain(Component.text("Einen Moment bitte, deine Anmeldung wird übermittelt."), rulesWidth)), + List.of() + ), + new DialogActionButton(Component.text("Bitte warten …"), null, cancelWidth, null) + )); + } + + private static void showDone(Player player, String message) { + player.showDialog(new net.minestom.server.dialog.Dialog.Notice( + new DialogMetadata( + Component.text("Anmeldung abgeschlossen"), + null, + false, + false, + DialogAfterAction.NONE, + List.of(plain(Component.text(message), rulesWidth)), + List.of() + ), + button("Verbindung trennen", disconnectKey, cancelWidth) + )); } public static boolean handleClick(Player player, DialogState state, Key key, CompoundBinaryTag payload) { + state.pendingError = null; + if (key.equals(backKey)) { state.previousPage(); showPage(player, state, null); @@ -86,9 +137,9 @@ public final class Dialog { case data -> { title = "Profildaten"; body.add(plain("Bitte fülle deine Profildaten aus.")); - inputs.add(text("name", "Vorname", state.firstName, 50)); - inputs.add(text("secondName", "Nachname", state.secondName, 50)); - inputs.add(text("birthday", "Geburtstag (TT.MM.JJJJ)", state.birthday, 10)); + inputs.add(text("name", "Vorname (*)", state.firstName, 50)); + inputs.add(text("secondName", "Nachname (*)", state.secondName, 50)); + inputs.add(text("birthday", "Geburtstag (TT.MM.JJJJ) (*)", state.birthday, 10)); inputs.add(text("phoneNumber", "Telefonnummer (optional)", state.phoneNumber, 30)); } case consent -> { @@ -109,7 +160,7 @@ public final class Dialog { body.add(plain(Component.text( "Ich bin mit der Speicherung meiner in der Anmeldung angegebenen, persönlichen Daten einverstanden.", NamedTextColor.GRAY), rulesWidth)); - inputs.add(checkbox("privacy", "Ich stimme zu *", state.privacyAccepted)); + inputs.add(checkbox("privacy", "Datenspeicherung zustimmen (*)", state.privacyAccepted)); } case logs -> { title = "Einverständnis (2/3)"; @@ -119,14 +170,14 @@ public final class Dialog { "Die Log-Speicherung betrifft jede Interaktion im Spiel und zugehörige Daten, " + "z.B. Chatnachrichten, welche vom Minecraft-Client an den Server übermittelt werden.", NamedTextColor.GRAY), rulesWidth)); - inputs.add(checkbox("logs", "Ich stimme zu *", state.logsAccepted)); + inputs.add(checkbox("logs", "Log-Speicherung zustimmen (*)", state.logsAccepted)); } case rules -> { title = "Einverständnis (3/3)"; body.add(plain(Component.text( "Ich bin mit den Regeln einverstanden und achte sie.", NamedTextColor.GRAY), rulesWidth)); - inputs.add(checkbox("rules", "Ich stimme zu *", state.rulesAccepted)); + inputs.add(checkbox("rules", "Regeln zustimmen (*)", state.rulesAccepted)); } default -> throw new IllegalStateException(); } @@ -138,7 +189,8 @@ public final class Dialog { List buttons = new ArrayList<>(); if (!state.isFirstPage()) buttons.add(button("← Zurück", backKey)); if (state.isLastPage()) buttons.add(button("Anmeldung abschließen", finishKey)); - else buttons.add(button("Weiter →", nextKey)); + else buttons.add(button("Weiter →", nextKey, state.isFirstPage() ? cancelWidth : buttonWidth)); + buttons.add(button("Anmeldung abbrechen", cancelKey, cancelWidth)); player.showDialog(new net.minestom.server.dialog.Dialog.MultiAction( new DialogMetadata( @@ -152,7 +204,7 @@ public final class Dialog { ), buttons, null, - 2 + state.isFirstPage() ? 1 : 2 )); } @@ -206,10 +258,14 @@ public final class Dialog { } private static DialogActionButton button(String label, Key key) { + return button(label, key, buttonWidth); + } + + private static DialogActionButton button(String label, Key key, int width) { return new DialogActionButton( Component.text(label), null, - 150, + width, new DialogAction.DynamicCustom(key, CompoundBinaryTag.builder().build()) ); } diff --git a/src/main/java/eu/mhsl/craftattack/loginServer/DialogState.java b/src/main/java/eu/mhsl/craftattack/loginServer/DialogState.java index 8128a92..418c876 100644 --- a/src/main/java/eu/mhsl/craftattack/loginServer/DialogState.java +++ b/src/main/java/eu/mhsl/craftattack/loginServer/DialogState.java @@ -1,5 +1,7 @@ package eu.mhsl.craftattack.loginServer; +import net.kyori.adventure.text.Component; + import java.util.List; public final class DialogState { @@ -16,12 +18,30 @@ public final class DialogState { boolean logsAccepted = false; boolean rulesAccepted = false; + private String doneMessage = null; + volatile boolean submitting = false; + Component pendingError = null; + private DialogState() {} public static DialogState create() { return new DialogState(); } + public static DialogState done(String message) { + DialogState state = new DialogState(); + state.doneMessage = message; + return state; + } + + public boolean isDone() { + return doneMessage != null; + } + + public String doneMessage() { + return doneMessage; + } + public Page currentPage() { return pages.get(pageIndex); } @@ -49,4 +69,8 @@ public final class DialogState { public void previousPage() { pageIndex = Math.max(0, pageIndex - 1); } + + public void goToPage(Page page) { + pageIndex = pages.indexOf(page); + } } diff --git a/src/main/java/eu/mhsl/craftattack/loginServer/PlayerHandler.java b/src/main/java/eu/mhsl/craftattack/loginServer/PlayerHandler.java index 3f64f99..da347c4 100644 --- a/src/main/java/eu/mhsl/craftattack/loginServer/PlayerHandler.java +++ b/src/main/java/eu/mhsl/craftattack/loginServer/PlayerHandler.java @@ -1,13 +1,14 @@ package eu.mhsl.craftattack.loginServer; import net.kyori.adventure.nbt.CompoundBinaryTag; -import net.kyori.adventure.text.Component; import net.minestom.server.MinecraftServer; import net.minestom.server.coordinate.Pos; import net.minestom.server.entity.Player; import net.minestom.server.event.player.AsyncPlayerConfigurationEvent; import net.minestom.server.event.player.PlayerCustomClickEvent; +import net.minestom.server.instance.Instance; import net.minestom.server.instance.InstanceManager; +import net.minestom.server.timer.Task; import java.time.Duration; import java.time.LocalDate; @@ -20,21 +21,31 @@ import java.util.concurrent.ConcurrentHashMap; public final class PlayerHandler { private static final Map states = new ConcurrentHashMap<>(); private static final Map lastLook = new ConcurrentHashMap<>(); + private static final Map cleanupTasks = new ConcurrentHashMap<>(); + private static final Duration stateTimeout = Duration.ofMinutes(30); private PlayerHandler() {} public static void join(AsyncPlayerConfigurationEvent event, InstanceManager instanceManager) { Player player = event.getPlayer(); - event.setSpawningInstance(instanceManager.createInstanceContainer()); + Instance instance = instanceManager.createInstanceContainer(); + instance.defaultClock().time(18000); + instance.defaultClock().pause(); + event.setSpawningInstance(instance); player.setRespawnPoint(new Pos(0, 100, 0)); - if (Api.isRegistered(player.getUuid())) { - player.kick("Du bist bereits auf Craftattack registriert, wir freuen uns auf dich!"); - return; - } + Task cleanup = cleanupTasks.remove(player.getUuid()); + if (cleanup != null) cleanup.cancel(); - DialogState state = states.computeIfAbsent(player.getUuid(), id -> DialogState.create()); + DialogState state; + if (Api.isRegistered(player.getUuid())) { + state = DialogState.done("Du bist bereits auf Craftattack registriert, wir freuen uns auf dich!"); + states.put(player.getUuid(), state); + } else { + state = states.compute(player.getUuid(), (id, existing) -> + existing == null || existing.isDone() ? DialogState.create() : existing); + } MinecraftServer.getSchedulerManager() .buildTask(() -> showDialog(player, state)) @@ -42,6 +53,23 @@ public final class PlayerHandler { .schedule(); } + public static void leave(Player player) { + UUID uuid = player.getUuid(); + lastLook.remove(uuid); + DialogState state = states.get(uuid); + if (state == null) return; + + Task task = MinecraftServer.getSchedulerManager() + .buildTask(() -> { + states.remove(uuid, state); + cleanupTasks.remove(uuid); + }) + .delay(stateTimeout) + .schedule(); + Task previous = cleanupTasks.put(uuid, task); + if (previous != null) previous.cancel(); + } + public static void onMove(Player player, float newYaw, float newPitch) { DialogState state = states.get(player.getUuid()); if (state == null) return; @@ -64,62 +92,102 @@ public final class PlayerHandler { DialogState state = states.get(player.getUuid()); if (state == null) return; + if (Dialog.isDisconnectKey(event.getKey())) { + disconnect(player); + return; + } + if (state.isDone() || state.submitting) return; + + if (Dialog.isCancelKey(event.getKey())) { + cancel(player); + return; + } + if (!(event.getPayload() instanceof CompoundBinaryTag payload)) return; boolean finished = Dialog.handleClick(player, state, event.getKey(), payload); if (finished) complete(player, state); } - private static void complete(Player player, DialogState state) { + private static void cancel(Player player) { states.remove(player.getUuid()); lastLook.remove(player.getUuid()); + player.kick("Anmeldung abgebrochen. Du kannst dich jederzeit erneut anmelden."); + } + private static void disconnect(Player player) { + states.remove(player.getUuid()); + lastLook.remove(player.getUuid()); + player.kick("Wir freuen uns auf dich!"); + } + + private static void complete(Player player, DialogState state) { String birthdayIso; try { birthdayIso = LocalDate .parse(state.birthday, DateTimeFormatter.ofPattern("dd.MM.uuuu", Locale.GERMANY)) .format(DateTimeFormatter.ISO_LOCAL_DATE); } catch (RuntimeException e) { - player.kick(Component.text("Geburtsdatum ungültig.")); + state.goToPage(DialogState.Page.data); + Dialog.showError(player, state, "Bitte gib ein gültiges Geburtsdatum an."); return; } - try { - Api.signup( - state.firstName, - state.secondName, - birthdayIso, - state.phoneNumber, - player.getUsername(), - player.getUuid(), - isBedrockUuid(player.getUuid()) ? "bedrock" : "java" - ); - } catch (Api.SignupError e) { - e.printStackTrace(); - player.kick(Component.text(mapSignupError(e))); - return; - } catch (RuntimeException e) { - e.printStackTrace(); - player.kick(Component.text("Anmeldung gerade nicht möglich. Bitte später erneut versuchen.")); - return; - } + state.submitting = true; + showDialog(player, state); - player.sendMessage("Danke für deine Anmeldung."); - player.sendMessage("Du bist für das Projekt Craftattack 9 registriert!"); + Thread.startVirtualThread(() -> { + try { + Api.signup( + state.firstName, + state.secondName, + birthdayIso, + state.phoneNumber, + player.getUsername(), + player.getUuid(), + isBedrockUuid(player.getUuid()) ? "bedrock" : "java" + ); + } catch (Api.SignupError e) { + e.printStackTrace(); + state.submitting = false; + handleSignupError(player, state, e); + return; + } catch (RuntimeException e) { + e.printStackTrace(); + state.submitting = false; + if (player.isOnline()) + Dialog.showError(player, state, "Anmeldung gerade nicht möglich. Bitte versuche es erneut."); + return; + } - MinecraftServer.getSchedulerManager() - .buildTask(() -> player.kick("Anmeldung erfolgreich, wir freuen uns auf dich!")) - .delay(Duration.ofMillis(1000)) - .schedule(); + finish(player, "Danke für deine Anmeldung! Du bist für das Projekt Craftattack 9 registriert, wir freuen uns auf dich!"); + }); } - private static String mapSignupError(Api.SignupError e) { - return switch (e.code) { - case "CONFLICT" -> "Du bist bereits registriert."; - case "UNAUTHORIZED" -> "Server nicht autorisiert. Bitte Admin kontaktieren."; - case "BAD_REQUEST" -> "Deine Angaben sind nicht gültig."; - default -> "Anmeldung fehlgeschlagen. Bitte später erneut versuchen."; - }; + private static void handleSignupError(Player player, DialogState state, Api.SignupError e) { + switch (e.code) { + case "CONFLICT" -> finish(player, "Du bist bereits auf Craftattack registriert, wir freuen uns auf dich!"); + case "BAD_REQUEST" -> { + state.goToPage(DialogState.Page.data); + if (player.isOnline()) Dialog.showError(player, state, "Deine Angaben sind nicht gültig. Bitte überprüfe sie."); + } + case "UNAUTHORIZED" -> { + if (player.isOnline()) Dialog.showError(player, state, "Server nicht autorisiert. Bitte Admin kontaktieren."); + } + default -> { + if (player.isOnline()) Dialog.showError(player, state, "Anmeldung fehlgeschlagen. Bitte versuche es erneut."); + } + } + } + + private static void finish(Player player, String message) { + if (!player.isOnline()) { + states.remove(player.getUuid()); + return; + } + DialogState done = DialogState.done(message); + states.put(player.getUuid(), done); + showDialog(player, done); } public static boolean isBedrockUuid(UUID uuid) { return uuid.getMostSignificantBits() == 0; diff --git a/src/main/java/eu/mhsl/craftattack/loginServer/listeners/Listener.java b/src/main/java/eu/mhsl/craftattack/loginServer/listeners/Listener.java index 75ce5b3..e98da4d 100644 --- a/src/main/java/eu/mhsl/craftattack/loginServer/listeners/Listener.java +++ b/src/main/java/eu/mhsl/craftattack/loginServer/listeners/Listener.java @@ -12,6 +12,7 @@ public interface Listener { static void registerAll(EventNode node, InstanceManager instanceManager) { List listeners = List.of( new PlayerJoinListener(instanceManager), + new PlayerDisconnectListener(), new PlayerMoveListener(), new DialogClickListener(), new ChatFormatHandler() diff --git a/src/main/java/eu/mhsl/craftattack/loginServer/listeners/PlayerDisconnectListener.java b/src/main/java/eu/mhsl/craftattack/loginServer/listeners/PlayerDisconnectListener.java new file mode 100644 index 0000000..bef389e --- /dev/null +++ b/src/main/java/eu/mhsl/craftattack/loginServer/listeners/PlayerDisconnectListener.java @@ -0,0 +1,13 @@ +package eu.mhsl.craftattack.loginServer.listeners; + +import eu.mhsl.craftattack.loginServer.PlayerHandler; +import net.minestom.server.event.Event; +import net.minestom.server.event.EventNode; +import net.minestom.server.event.player.PlayerDisconnectEvent; + +public class PlayerDisconnectListener implements Listener { + @Override + public void register(EventNode node) { + node.addListener(PlayerDisconnectEvent.class, event -> PlayerHandler.leave(event.getPlayer())); + } +}