From 92a655e107fa3bb5b368dd83c9dde8359a7baf44 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Elias=20M=C3=BCller?= Date: Sun, 18 Oct 2026 17:50:16 +0200 Subject: [PATCH] arena: full pads wait for the challenger to close the options (max 20 s) instead of closing them --- .../spawn/arena/appliances/arena/Lobby.java | 41 ++++++++++- .../arena/appliances/arena/ModeMenu.java | 12 ++-- .../appliances/arena/ModeMenuListener.java | 9 +++ .../appliances/arena/logic/PadLobby.java | 68 +++++++++++++++---- .../appliances/arena/logic/PadLobbyTest.java | 62 ++++++++++++++++- 5 files changed, 172 insertions(+), 20 deletions(-) diff --git a/arena/src/main/java/eu/mhsl/craftattack/spawn/arena/appliances/arena/Lobby.java b/arena/src/main/java/eu/mhsl/craftattack/spawn/arena/appliances/arena/Lobby.java index eccb546..970a3a5 100644 --- a/arena/src/main/java/eu/mhsl/craftattack/spawn/arena/appliances/arena/Lobby.java +++ b/arena/src/main/java/eu/mhsl/craftattack/spawn/arena/appliances/arena/Lobby.java @@ -31,6 +31,8 @@ import java.util.UUID; final class Lobby implements PadLobby.Listener { /** How far around a taken pad other players get pushed away. */ private static final double PUSH_MARGIN = 0.4; + /** How long a full lobby waits for the challenger to close the options. */ + private static final int CHOOSING_SECONDS = 20; private final Arena arena; private final Region[] regions; @@ -42,7 +44,7 @@ final class Lobby implements PadLobby.Listener { Lobby(Arena arena, Region[] regions) { this.arena = arena; this.regions = regions; - this.pads = new PadLobby(this, arena.settings().countdownSeconds()); + this.pads = new PadLobby(this, arena.settings().countdownSeconds(), CHOOSING_SECONDS); for(int i = 0; i < PadLobby.PADS; i++) { Region region = regions[i]; this.displays[i] = Displays.spawn(new Location(arena.worlds().play(), region.centerX(), region.maxY() + 2.6, region.centerZ())); @@ -81,7 +83,9 @@ final class Lobby implements PadLobby.Listener { this.arena.setState(player, Arena.PlayerState.PAD); this.arena.teams().join(player, Teams.pad(pad)); Style.play(player, Sound.BLOCK_NOTE_BLOCK_PLING, 1.4f); - if(!player.getUniqueId().equals(this.pads.challenger())) { + if(this.pads.isWaitingForChallenger()) { + player.sendActionBar(this.choosingText()); + } else if(!player.getUniqueId().equals(this.pads.challenger())) { player.sendActionBar(Component.text(Style.PAD_NAMES[pad] + " " + this.pads.players(pad).size() + "/" + this.pads.size(pad) + ", Modus: " + this.modeName(), Style.PAD_TEXT_COLORS[pad])); } @@ -148,9 +152,23 @@ final class Lobby implements PadLobby.Listener { int pad = this.pads.challengerPad(); InteractSounds.of(player).open(); ModeMenu.open(player, pad, this.pads.size(pad), this.pads.size(1 - pad), this.pads.mode()); + this.pads.setChoosing(player.getUniqueId(), true); return true; } + /** + * The challenger closed the options: a full lobby starts counting down now. + */ + void menuClosed(Player player) { + this.pads.setChoosing(player.getUniqueId(), false); + } + + private Component choosingText() { + Player challenger = this.pads.challenger() == null ? null : Bukkit.getPlayer(this.pads.challenger()); + String name = challenger == null ? "Der Herausforderer" : challenger.getName(); + return Component.text(name + " wählt noch Teams und Modus", NamedTextColor.YELLOW); + } + private boolean isTeamFight() { return this.pads.size(0) > 1 || this.pads.size(1) > 1; } @@ -201,6 +219,22 @@ final class Lobby implements PadLobby.Listener { this.openMenu(player); } + @Override + public void waitingForChallenger() { + UUID challenger = this.pads.challenger(); + Player player = challenger == null ? null : Bukkit.getPlayer(challenger); + if(player != null && player.getOpenInventory().getTopInventory().getHolder(false) instanceof ModeMenu menu) { + menu.everyoneThere = true; + menu.draw(); + Style.play(player, Sound.BLOCK_NOTE_BLOCK_PLING, 1.8f); + } + for(int pad = 0; pad < PadLobby.PADS; pad++) { + this.playersOn(pad).stream() + .filter(occupant -> !occupant.getUniqueId().equals(challenger)) + .forEach(occupant -> occupant.sendActionBar(this.choosingText())); + } + } + @Override public void countdownStarted() { UUID challenger = this.pads.challenger(); @@ -283,6 +317,9 @@ final class Lobby implements PadLobby.Listener { if(waiting) { body = this.namesOn(pad).appendNewline() .append(Component.text("Alle Arenen belegt, gleich geht es los", NamedTextColor.GRAY)); + } else if(this.pads.isWaitingForChallenger() && challenger != null) { + body = this.namesOn(pad).appendNewline() + .append(Component.text(challenger.getName() + " wählt noch", NamedTextColor.YELLOW)); } else if(this.pads.isCountingDown()) { body = this.namesOn(pad).appendNewline() .append(Component.text("Kampf beginnt in " + this.pads.secondsLeft(), NamedTextColor.GOLD)); diff --git a/arena/src/main/java/eu/mhsl/craftattack/spawn/arena/appliances/arena/ModeMenu.java b/arena/src/main/java/eu/mhsl/craftattack/spawn/arena/appliances/arena/ModeMenu.java index 6c07806..b3d4132 100644 --- a/arena/src/main/java/eu/mhsl/craftattack/spawn/arena/appliances/arena/ModeMenu.java +++ b/arena/src/main/java/eu/mhsl/craftattack/spawn/arena/appliances/arena/ModeMenu.java @@ -48,6 +48,8 @@ final class ModeMenu implements InventoryHolder { int ownSize; int opponentSize; FightMode mode; + /** Both pads are full, only the challenger closing the menu is missing. */ + boolean everyoneThere; private ModeMenu(Player challenger, int ownPad, int ownSize, int opponentSize, FightMode mode) { this.challenger = challenger; @@ -95,10 +97,12 @@ final class ModeMenu implements InventoryHolder { this.inventory.setItem(MODE_SLOTS[i], this.modeItem(mode, teams, mode == this.mode, mode.isAllowed(this.ownSize, this.opponentSize))); } - this.inventory.setItem(DONE_SLOT, ItemBuilder.of(Material.LIME_CONCRETE) - .displayName(Component.text("Fertig", NamedTextColor.GREEN).decoration(TextDecoration.ITALIC, false)) - .lore(this.ownSize + " gegen " + this.opponentSize + ", " + this.mode.displayName(teams) + ". Jetzt fehlen nur noch die anderen auf den Feldern.") - .build()); + ItemBuilder done = ItemBuilder.of(Material.LIME_CONCRETE) + .displayName(Component.text(this.everyoneThere ? "Fertig, Kampf starten" : "Fertig", NamedTextColor.GREEN).decoration(TextDecoration.ITALIC, false)) + .lore(this.ownSize + " gegen " + this.opponentSize + ", " + this.mode.displayName(teams) + ". " + + (this.everyoneThere ? "Alle sind da, der Countdown startet, sobald du fertig bist." : "Jetzt fehlen nur noch die anderen auf den Feldern.")); + if(this.everyoneThere) done.glint(); + this.inventory.setItem(DONE_SLOT, done.build()); } private ItemBuilder label(Material material, String name, int pad, String text) { diff --git a/arena/src/main/java/eu/mhsl/craftattack/spawn/arena/appliances/arena/ModeMenuListener.java b/arena/src/main/java/eu/mhsl/craftattack/spawn/arena/appliances/arena/ModeMenuListener.java index 09b2b2d..d71150c 100644 --- a/arena/src/main/java/eu/mhsl/craftattack/spawn/arena/appliances/arena/ModeMenuListener.java +++ b/arena/src/main/java/eu/mhsl/craftattack/spawn/arena/appliances/arena/ModeMenuListener.java @@ -7,6 +7,7 @@ import org.bukkit.event.block.Action; import org.bukkit.event.player.PlayerInteractEvent; import org.bukkit.inventory.EquipmentSlot; import org.bukkit.event.inventory.InventoryClickEvent; +import org.bukkit.event.inventory.InventoryCloseEvent; class ModeMenuListener extends ApplianceListener { @EventHandler @@ -21,6 +22,14 @@ class ModeMenuListener extends ApplianceListener { if(click != null && lobby != null) lobby.handleMenuClick(player, menu, click); } + @EventHandler + public void onClose(InventoryCloseEvent event) { + if(!(event.getInventory().getHolder(false) instanceof ModeMenu)) return; + if(!(event.getPlayer() instanceof Player player)) return; + Lobby lobby = this.getAppliance().lobby(); + if(lobby != null) lobby.menuClosed(player); + } + @EventHandler public void onRightClick(PlayerInteractEvent event) { if(event.getHand() != EquipmentSlot.HAND) return; diff --git a/arena/src/main/java/eu/mhsl/craftattack/spawn/arena/appliances/arena/logic/PadLobby.java b/arena/src/main/java/eu/mhsl/craftattack/spawn/arena/appliances/arena/logic/PadLobby.java index f7932a8..fa96e01 100644 --- a/arena/src/main/java/eu/mhsl/craftattack/spawn/arena/appliances/arena/logic/PadLobby.java +++ b/arena/src/main/java/eu/mhsl/craftattack/spawn/arena/appliances/arena/logic/PadLobby.java @@ -9,9 +9,9 @@ import java.util.Map; import java.util.UUID; /** - * State of the two start pads. The first player on a pad becomes the challenger and picks team sizes and mode - * while standing there alone. Everyone else joins a team by stepping on a pad. Once both pads hold exactly - * the chosen number of players long enough, the fight starts. + * State of the two start pads. The first player on a pad becomes the challenger and picks team sizes and mode. + * Everyone else joins a team by stepping on a pad. Once both pads hold exactly the chosen number of players + * and the challenger is done choosing (or took too long), the countdown runs and then the fight starts. */ public class PadLobby { public static final int PADS = 2; @@ -24,6 +24,9 @@ public class PadLobby { /** The player should now hold the inventory of {@code kitOwner} (the own one to revert). */ void kitAssigned(UUID player, UUID kitOwner); + /** Both pads are exactly full, but the challenger still has the options open. */ + void waitingForChallenger(); + /** Both pads are exactly full, the options are fixed now. */ void countdownStarted(); @@ -40,6 +43,7 @@ public class PadLobby { private final Listener listener; private final int countdownTicks; + private final int choosingTicks; private final List> pads = List.of(new ArrayList<>(), new ArrayList<>()); /** Everyone on a pad, in the order they stepped on. The first one is the challenger. */ private final List order = new ArrayList<>(); @@ -47,10 +51,17 @@ public class PadLobby { private final int[] sizes = {1, 1}; private FightMode mode = FightMode.OWN; private int ticksLeft = -1; + private boolean choosing; + /** How much longer everyone waits for the challenger to finish choosing; -1 while nobody waits. */ + private int choosingTicksLeft = -1; - public PadLobby(Listener listener, int countdownSeconds) { + /** + * @param choosingSeconds how long a full lobby waits for the challenger to close the options + */ + public PadLobby(Listener listener, int countdownSeconds, int choosingSeconds) { this.listener = listener; this.countdownTicks = countdownSeconds * 20; + this.choosingTicks = choosingSeconds * 20; } /** @@ -73,10 +84,7 @@ public class PadLobby { } this.refreshKits(); - if(this.isFull()) { - this.ticksLeft = this.countdownTicks; - this.listener.countdownStarted(); - } + this.startCountdownIfReady(); return true; } @@ -92,9 +100,11 @@ public class PadLobby { this.listener.kitAssigned(player, player); if(wasFull) { + if(this.isCountingDown()) this.listener.countdownCancelled(); this.ticksLeft = -1; - this.listener.countdownCancelled(); + this.choosingTicksLeft = -1; } + if(wasChallenger) this.choosing = false; this.refreshKits(); // the longest waiting player takes over; team sizes and mode stay as chosen @@ -130,13 +140,40 @@ public class PadLobby { this.sizes[1 - own] = opponentSize; this.mode = mode; this.refreshKits(); - if(this.isFull()) { - this.ticksLeft = this.countdownTicks; - this.listener.countdownStarted(); - } + this.startCountdownIfReady(); return ConfigureResult.OK; } + /** + * The challenger opened or closed the options. While they are open, a full lobby waits instead of counting down. + */ + public void setChoosing(UUID player, boolean choosing) { + if(!this.canConfigure(player) || this.choosing == choosing) return; + this.choosing = choosing; + this.startCountdownIfReady(); + } + + public boolean isWaitingForChallenger() { + return this.choosingTicksLeft >= 0; + } + + private void startCountdownIfReady() { + if(this.isCountingDown()) return; + if(!this.isFull()) { + this.choosingTicksLeft = -1; + return; + } + if(this.choosing) { + if(this.choosingTicksLeft >= 0) return; + this.choosingTicksLeft = this.choosingTicks; + this.listener.waitingForChallenger(); + return; + } + this.choosingTicksLeft = -1; + this.ticksLeft = this.countdownTicks; + this.listener.countdownStarted(); + } + public boolean canConfigure(UUID player) { return player.equals(this.challenger()) && !this.isCountingDown(); } @@ -145,6 +182,10 @@ public class PadLobby { * Called every server tick. The countdown only runs while an arena is free. */ public void tick(boolean arenaAvailable) { + if(this.choosingTicksLeft >= 0 && this.choosingTicksLeft-- == 0) { + this.choosing = false; + this.startCountdownIfReady(); + } if(this.ticksLeft < 0) return; if(!arenaAvailable) { this.ticksLeft = this.countdownTicks; @@ -165,6 +206,7 @@ public class PadLobby { this.sizes[1] = 1; this.mode = FightMode.OWN; this.ticksLeft = -1; + this.choosing = false; this.listener.start(teams, challenger, mode, kits); } diff --git a/arena/src/test/java/eu/mhsl/craftattack/spawn/arena/appliances/arena/logic/PadLobbyTest.java b/arena/src/test/java/eu/mhsl/craftattack/spawn/arena/appliances/arena/logic/PadLobbyTest.java index 5462985..d651ff5 100644 --- a/arena/src/test/java/eu/mhsl/craftattack/spawn/arena/appliances/arena/logic/PadLobbyTest.java +++ b/arena/src/test/java/eu/mhsl/craftattack/spawn/arena/appliances/arena/logic/PadLobbyTest.java @@ -28,6 +28,10 @@ class PadLobbyTest { PadLobbyTest.this.events.add("challenger " + PadLobbyTest.this.name(challenger) + (first ? " first" : "")); } + public void waitingForChallenger() { + PadLobbyTest.this.events.add("waiting"); + } + public void countdownStarted() { PadLobbyTest.this.events.add("countdown started"); } @@ -49,7 +53,7 @@ class PadLobbyTest { PadLobbyTest.this.events.add("start " + PadLobbyTest.this.names(teams.get(0)) + " vs " + PadLobbyTest.this.names(teams.get(1)) + " by " + PadLobbyTest.this.name(challenger) + " " + mode); } - }, 2); + }, 2, 3); } private String name(UUID uuid) { @@ -239,4 +243,60 @@ class PadLobbyTest { this.lobby.leave(this.b); assertEquals(List.of("challenger a first", "kit a<-a"), this.events); } + + @Test + void fullLobbyWaitsWhileChallengerChooses() { + this.lobby.enter(0, this.a); + this.lobby.setChoosing(this.a, true); + this.lobby.enter(1, this.b); + assertEquals("waiting", this.events.getLast()); + assertFalse(this.lobby.isCountingDown()); + assertTrue(this.lobby.isWaitingForChallenger()); + assertEquals(PadLobby.ConfigureResult.OK, this.lobby.configure(this.a, 1, 1, FightMode.CHALLENGER), "the menu stays usable"); + + this.lobby.setChoosing(this.a, false); + assertEquals("countdown started", this.events.getLast()); + assertFalse(this.lobby.isWaitingForChallenger()); + } + + @Test + void choosingTimesOut() { + this.lobby.enter(0, this.a); + this.lobby.setChoosing(this.a, true); + this.lobby.enter(1, this.b); + this.run(60); + assertFalse(this.lobby.isCountingDown()); + this.run(1); + assertTrue(this.lobby.isCountingDown()); + this.lobby.setChoosing(this.a, false); + assertEquals(1, this.events.stream().filter("countdown started"::equals).count()); + } + + @Test + void onlyChallengerChoosingBlocks() { + this.lobby.enter(0, this.a); + this.lobby.setChoosing(this.b, true); + this.lobby.enter(1, this.b); + assertTrue(this.lobby.isCountingDown()); + } + + @Test + void opponentLeavingWhileWaitingIsQuiet() { + this.lobby.enter(0, this.a); + this.lobby.setChoosing(this.a, true); + this.lobby.enter(1, this.b); + this.lobby.leave(this.b); + assertFalse(this.events.contains("cancelled")); + assertFalse(this.lobby.isWaitingForChallenger()); + } + + @Test + void newChallengerIsNotChoosing() { + this.lobby.enter(0, this.a); + this.lobby.setChoosing(this.a, true); + this.lobby.enter(1, this.b); + this.lobby.leave(this.a); + this.lobby.enter(0, this.c); + assertTrue(this.lobby.isCountingDown()); + } }