diff --git a/game/src/main/java/net/onelitefeather/cygnus/phase/LobbyPhase.java b/game/src/main/java/net/onelitefeather/cygnus/phase/LobbyPhase.java index bcd9ae4..18b1882 100644 --- a/game/src/main/java/net/onelitefeather/cygnus/phase/LobbyPhase.java +++ b/game/src/main/java/net/onelitefeather/cygnus/phase/LobbyPhase.java @@ -1,17 +1,15 @@ package net.onelitefeather.cygnus.phase; +import net.minestom.server.MinecraftServer; +import net.minestom.server.entity.Player; +import net.minestom.server.network.ConnectionManager; import net.minestom.server.event.EventDispatcher; import net.onelitefeather.cygnus.common.config.GameConfig; import net.onelitefeather.cygnus.map.event.GameMapLoadEvent; import net.onelitefeather.cygnus.map.event.GamePrepareEvent; +import net.onelitefeather.cygnus.phase.task.LobbyWaitingTask; import net.theevilreaper.xerus.api.phase.TickDirection; import net.theevilreaper.xerus.api.phase.TimedPhase; -import net.kyori.adventure.text.Component; -import net.kyori.adventure.text.format.NamedTextColor; -import net.minestom.server.MinecraftServer; -import net.minestom.server.entity.Player; -import net.minestom.server.network.ConnectionManager; -import net.minestom.server.timer.Task; import java.time.temporal.ChronoUnit; @@ -19,31 +17,31 @@ /** * Represents the lobby phase of the game. - * - *

During this phase the game waits until enough players joined to start the + *

+ * During this phase, the game waits until enough players have joined to start the * match countdown. The phase updates the player level and experience bar to - * visualize the remaining time until the game starts.

- * - *

The phase can be paused automatically if the required player count is not - * reached anymore. It also supports force starting the game with a reduced - * countdown duration.

- * - *

While the countdown is running, the game map loading and stamina - * initialization are triggered at specific countdown timestamps.

+ * visualize the remaining time until the game starts, and manages the action bar + * waiting display using a tick-aligned scheduler. + *

* * @author theEvilReaper - * @version 1.0.0 + * @version 1.2.0 * @since 1.0.0 */ public final class LobbyPhase extends TimedPhase { private static final ConnectionManager CONNECTION_MANAGER = MinecraftServer.getConnectionManager(); - private final Task waitingTask; + private final int lobbyTime; private final int minPlayers; + private final LobbyWaitingTask waitingDisplay; private boolean forceStarted; - private Component displayComponent; + /** + * Constructs a new LobbyPhase. + * + * @param gameConfig the configuration settings for the game + */ public LobbyPhase(GameConfig gameConfig) { super("Lobby", ChronoUnit.SECONDS, 1); this.lobbyTime = gameConfig.lobbyTime(); @@ -51,39 +49,44 @@ public LobbyPhase(GameConfig gameConfig) { this.setPaused(true); this.setCurrentTicks(lobbyTime); this.setTickDirection(TickDirection.DOWN); - this.updateComponent(); - this.waitingTask = MinecraftServer.getSchedulerManager().buildTask(() -> { - if (isPaused() || isFinished() || CONNECTION_MANAGER.getOnlinePlayers().isEmpty()) - return; - CONNECTION_MANAGER.getOnlinePlayers().forEach( - player -> player.sendActionBar(this.displayComponent) - ); - }).repeat(30, ChronoUnit.SECONDS).schedule(); - } - private void updateComponent() { - int playerDifference = Math.max(0, this.minPlayers - CONNECTION_MANAGER.getOnlinePlayers().size()); - this.displayComponent = Component.text("Need", NamedTextColor.GRAY) - .append(Component.space()) - .append(Component.text(playerDifference, NamedTextColor.RED)) - .append(Component.space()) - .append(Component.text("players to start", NamedTextColor.GRAY)); + // Instantiate the waiting display only once during initialization + this.waitingDisplay = new LobbyWaitingTask(this.minPlayers); + this.waitingDisplay.update(CONNECTION_MANAGER.getOnlinePlayers().size()); } + /** + * Checks if the start condition is met. + * If the online player count reaches the minimum requirement, the countdown starts + * and the waiting action bar display is stopped. + */ public void checkStartCondition() { - this.updateComponent(); - if (isPaused() && CONNECTION_MANAGER.getOnlinePlayers().size() >= this.minPlayers) { + int onlineCount = CONNECTION_MANAGER.getOnlinePlayers().size(); + + if (isPaused() && onlineCount >= this.minPlayers) { this.setPaused(false); + this.waitingDisplay.stop(); // Stop the loop, but keep the final object + } else { + this.waitingDisplay.update(onlineCount); } } + /** + * Checks if the countdown should stop when players leave the server. + * Re-activates the waiting display if the player count drops below the requirement. + */ public void checkStopCondition() { - if (waitingTask.isAlive() && CONNECTION_MANAGER.getOnlinePlayers().size() - 1 <= this.minPlayers) { + int onlineCount = CONNECTION_MANAGER.getOnlinePlayers().size(); + + if (onlineCount - 1 < this.minPlayers) { this.setPaused(true); - this.updateComponent(); this.setCurrentTicks(this.lobbyTime); this.forceStarted = false; - setLevel(); + this.setLevel(); + + // Simply restart the existing display instead of allocating a new one + this.waitingDisplay.start(); + this.waitingDisplay.update(onlineCount); } } @@ -95,7 +98,7 @@ public void start() { @Override protected void onFinish() { - this.waitingTask.cancel(); + this.waitingDisplay.stop(); // Stop the tick-aligned loop } @Override @@ -113,14 +116,20 @@ public void onUpdate() { /** * Checks if the player requirements are met. - * If the player requirements are not met, the phase will be paused. + * Re-creates the display if the player count drops below the threshold. */ public void checkPlayerRequirements() { - if (CONNECTION_MANAGER.getOnlinePlayers().size() - 1 < this.minPlayers) { + int onlineCount = CONNECTION_MANAGER.getOnlinePlayers().size(); + + if (onlineCount - 1 < this.minPlayers) { this.setPaused(true); this.setForceStarted(false); this.setCurrentTicks(this.lobbyTime); this.setLevel(this.lobbyTime); + + // Simply restart the existing display instead of allocating a new one + this.waitingDisplay.start(); + this.waitingDisplay.update(onlineCount); } } @@ -129,11 +138,9 @@ private void setLevel() { } /** - * Sets the level for all online players. - * The level is calculated by the current ticks. - * The experience is calculated by the current ticks divided by the lobby phase time. + * Sets the level and XP progress for all online players. * - * @param amount the amount of the level + * @param amount the current countdown level */ private void setLevel(int amount) { if (amount < 0) return; @@ -146,29 +153,40 @@ private void setLevel(int amount) { } /** - * Sets the level for the given player. + * Sets the level for a specific player (typically called on join). + * Also immediately sends the waiting action bar if the lobby is paused. * - * @param player the player to set the level + * @param player the player to set the level for */ public void setLevel(Player player) { if (getCurrentTicks() < 0) return; player.setLevel(getCurrentTicks()); player.setExp(getCurrentTicks() / (float) this.lobbyTime); + + // Immediately send the action bar to the newly spawned player + if (isPaused()) { + this.waitingDisplay.send(player); + } } /** * Returns if the phase was force started. * - * @return True if the phase was force started otherwise false + * @return true if the phase was force started, otherwise false */ public boolean isForceStarted() { return forceStarted; } + /** + * Sets whether the phase is force started. + * + * @param forceStarted true to force start, otherwise false + */ public void setForceStarted(boolean forceStarted) { if (forceStarted) { this.setCurrentTicks(FORCE_START_TIME); } this.forceStarted = forceStarted; } -} +} \ No newline at end of file diff --git a/game/src/main/java/net/onelitefeather/cygnus/phase/task/LobbyWaitingTask.java b/game/src/main/java/net/onelitefeather/cygnus/phase/task/LobbyWaitingTask.java new file mode 100644 index 0000000..ad9945c --- /dev/null +++ b/game/src/main/java/net/onelitefeather/cygnus/phase/task/LobbyWaitingTask.java @@ -0,0 +1,132 @@ +package net.onelitefeather.cygnus.phase.task; + +import net.kyori.adventure.text.Component; +import net.kyori.adventure.text.format.NamedTextColor; +import net.minestom.server.MinecraftServer; +import net.minestom.server.entity.Player; + +import java.util.Collection; + +/** + * Handles the action bar display logic for players while the lobby is waiting + * for the minimum required player count to start the match countdown. + *

+ * This class is designed to be reusable. The tick-aligned scheduler can be started + * and stopped dynamically without re-instantiating the object. + *

+ * + * @author theEvilReaper + * @version 1.3.0 + * @since 1.0.0 + **/ +public final class LobbyWaitingTask { + + private static final Component PREFIX_NEED = Component.text("Need", NamedTextColor.GRAY); + private static final Component SUFFIX_TO_START = Component.text("players to start", NamedTextColor.GRAY); + private static final Component SPACE = Component.space(); + private static final int DISPLAY_REFRESH_INTERVAL_TICKS = 40; // Approx. 2 seconds + + private final int minPlayers; + private final Component[] cachedComponents; + private final Runnable tickRunnable; + + private Component displayComponent = Component.empty(); + private int tickCounter = 0; + private boolean running = false; + + /** + * Creates a new instance of the waiting display and starts the tick-aligned loop. + * + * @param minPlayers the minimum number of players required to start the game + */ + public LobbyWaitingTask(int minPlayers) { + this.minPlayers = minPlayers; + this.cachedComponents = new Component[minPlayers + 1]; + + // Pre-build all possible text components for player differences [0 ... minPlayers] + for (int diff = 0; diff <= minPlayers; diff++) { + this.cachedComponents[diff] = PREFIX_NEED + .append(SPACE) + .append(Component.text(diff, NamedTextColor.RED)) + .append(SPACE) + .append(SUFFIX_TO_START); + } + + // Define the self-scheduling tick task + this.tickRunnable = new Runnable() { + @Override + public void run() { + if (!running) { + return; // Gracefully exit the loop when stopped + } + + // Send the action bar only at the specified tick interval + if (tickCounter++ % DISPLAY_REFRESH_INTERVAL_TICKS == 0) { + sendToAll(MinecraftServer.getConnectionManager().getOnlinePlayers()); + } + + // Schedule this task again for the next server tick + MinecraftServer.getSchedulerManager().scheduleNextTick(this); + } + }; + + // Start the loop automatically on creation + this.start(); + } + + /** + * Starts the tick-aligned display loop if it is not already running. + */ + public void start() { + if (this.running) { + return; // Prevent duplicate scheduler registrations + } + this.running = true; + this.tickCounter = 0; + MinecraftServer.getSchedulerManager().scheduleNextTick(this.tickRunnable); + } + + /** + * Stops the tick-aligned loop. + * The loop will automatically terminate on the next scheduled tick. + */ + public void stop() { + this.running = false; + } + + /** + * Updates the player count difference and immediately refreshes the action bar + * for all online players using pre-cached components. + * + * @param onlineCount the current number of online players + */ + public void update(int onlineCount) { + int playerDifference = Math.clamp(this.minPlayers - onlineCount, 0, this.minPlayers); + this.displayComponent = this.cachedComponents[playerDifference]; + + if (running) { + sendToAll(MinecraftServer.getConnectionManager().getOnlinePlayers()); + } + } + + /** + * Sends the current action bar message immediately to a specific player + * (e.g., when a player joins the server). + * + * @param player the player to send the message to + */ + public void send(Player player) { + if (running && displayComponent != Component.empty()) { + player.sendActionBar(this.displayComponent); + } + } + + private void sendToAll(Collection players) { + if (players.isEmpty() || displayComponent == Component.empty()) { + return; + } + for (Player player : players) { + player.sendActionBar(this.displayComponent); + } + } +} \ No newline at end of file diff --git a/game/src/main/java/net/onelitefeather/cygnus/phase/task/package-info.java b/game/src/main/java/net/onelitefeather/cygnus/phase/task/package-info.java new file mode 100644 index 0000000..cd97da4 --- /dev/null +++ b/game/src/main/java/net/onelitefeather/cygnus/phase/task/package-info.java @@ -0,0 +1,4 @@ +@NotNullByDefault +package net.onelitefeather.cygnus.phase.task; + +import org.jetbrains.annotations.NotNullByDefault; \ No newline at end of file diff --git a/game/src/test/java/net/onelitefeather/cygnus/phase/LobbyPhasePlayerThresholdIntegrationTest.java b/game/src/test/java/net/onelitefeather/cygnus/phase/LobbyPhasePlayerThresholdIntegrationTest.java new file mode 100644 index 0000000..f78b96f --- /dev/null +++ b/game/src/test/java/net/onelitefeather/cygnus/phase/LobbyPhasePlayerThresholdIntegrationTest.java @@ -0,0 +1,78 @@ +package net.onelitefeather.cygnus.phase; + +import net.kyori.adventure.text.Component; +import net.kyori.adventure.text.serializer.plain.PlainTextComponentSerializer; +import net.minestom.server.entity.Player; +import net.minestom.server.instance.Instance; +import net.minestom.server.network.packet.server.play.ActionBarPacket; +import net.minestom.testing.Collector; +import net.minestom.testing.Env; +import net.minestom.testing.TestConnection; +import net.onelitefeather.cygnus.CygnusPlayerTestBase; +import net.onelitefeather.cygnus.common.config.GameConfig; +import org.jetbrains.annotations.NotNull; +import org.junit.jupiter.api.Test; + +import java.util.List; + +import static org.junit.jupiter.api.Assertions.*; + +/** + * Integration tests verifying the state transitions and player threshold handling + * inside {@link LobbyPhase} and its waiting display. + * + * @author theEvilReaper + * @version 1.0.0 + * @since 2.6.6 + **/ +class LobbyPhasePlayerThresholdIntegrationTest extends CygnusPlayerTestBase { + + @Test + void testLobbyPhaseStateTransitionsAndWaitingDisplay(@NotNull Env env) { + Instance instance = env.createFlatInstance(); + + GameConfig config = GameConfig.builder() + .lobbyTime(30) + .minPlayers(2) + .gameTime(600) + .maxPlayers(10) + .build(); + + LobbyPhase lobbyPhase = new LobbyPhase(config); + + assertTrue(lobbyPhase.isPaused(), "Lobby phase should start paused."); + assertEquals(30, lobbyPhase.getCurrentTicks(), "Lobby ticks should start at configured lobby time."); + + TestConnection connection1 = env.createConnection(); + Player player1 = connection1.connect(instance); + Collector packets1 = connection1.trackIncoming(ActionBarPacket.class); + + lobbyPhase.setLevel(player1); + lobbyPhase.checkStartCondition(); + + assertTrue(lobbyPhase.isPaused(), "Lobby phase should remain paused with only 1 player."); + + List p1List = packets1.collect(); + assertFalse(p1List.isEmpty(), "Player 1 should have received an ActionBarPacket."); + Component lastSentComponent = p1List.getLast().components().stream().findAny().orElse(null); + assertNotNull(lastSentComponent, "ActionBarPacket should contain a component."); + String plainText = PlainTextComponentSerializer.plainText().serialize(lastSentComponent); + assertEquals("Need 1 players to start", plainText, "Action bar should request 1 player."); + + TestConnection connection2 = env.createConnection(); + Player player2 = connection2.connect(instance); + + lobbyPhase.setLevel(player2); + lobbyPhase.checkStartCondition(); + + assertFalse(lobbyPhase.isPaused(), "Lobby phase should unpause when minimum player threshold is met."); + + player2.remove(); // Removes the player from connection manager + lobbyPhase.checkStopCondition(); + + assertTrue(lobbyPhase.isPaused(), "Lobby phase should pause when player count drops below threshold."); + assertEquals(30, lobbyPhase.getCurrentTicks(), "Lobby ticks should reset to lobby time."); + + env.destroyInstance(instance, true); + } +} diff --git a/game/src/test/java/net/onelitefeather/cygnus/phase/task/LobbyWaitingTaskIntegrationTest.java b/game/src/test/java/net/onelitefeather/cygnus/phase/task/LobbyWaitingTaskIntegrationTest.java new file mode 100644 index 0000000..bc45862 --- /dev/null +++ b/game/src/test/java/net/onelitefeather/cygnus/phase/task/LobbyWaitingTaskIntegrationTest.java @@ -0,0 +1,68 @@ +package net.onelitefeather.cygnus.phase.task; + +import net.kyori.adventure.text.Component; +import net.kyori.adventure.text.serializer.plain.PlainTextComponentSerializer; +import net.minestom.server.entity.Player; +import net.minestom.server.instance.Instance; +import net.minestom.server.network.packet.server.play.ActionBarPacket; +import net.minestom.testing.Collector; +import net.minestom.testing.Env; +import net.minestom.testing.TestConnection; +import net.minestom.testing.extension.MicrotusExtension; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; + +import java.util.List; + +import static org.junit.jupiter.api.Assertions.*; + +@ExtendWith(MicrotusExtension.class) +class LobbyWaitingTaskIntegrationTest { + + @Test + void testWaitingDisplayActionBarUpdates(Env env) { + Instance instance = env.createFlatInstance(); + TestConnection connection = env.createConnection(); + Player player = connection.connect(instance); + Collector packetCollector = connection.trackIncoming(ActionBarPacket.class); + LobbyWaitingTask display = new LobbyWaitingTask(2); + + display.update(1); + + List packets = packetCollector.collect(); + assertFalse(packets.isEmpty(), "An ActionBarPacket should have been sent to the player."); + + Component lastSentComponent = packets.getLast().components().stream().findFirst().orElse(null); + assertNotNull(lastSentComponent, "ActionBarPacket should contain a component."); + String plainText = PlainTextComponentSerializer.plainText().serialize(lastSentComponent); + assertEquals("Need 1 players to start", plainText, "Action bar text should request 1 more player."); + + // 2. Send manual display update to the player + display.send(player); + + List newPackets = packetCollector.collect(); + assertEquals(1, newPackets.size(), "An additional packet should be sent immediately."); + + display.stop(); + env.destroyInstance(instance, true); + } + + @Test + void testWaitingDisplayStopPreventsTicking(Env env) { + Instance instance = env.createFlatInstance(); + TestConnection connection = env.createConnection(); + connection.connect(instance); + Collector packetCollector = connection.trackIncoming(ActionBarPacket.class); + LobbyWaitingTask display = new LobbyWaitingTask(2); + display.update(1); + int packetsBeforeStop = packetCollector.collect().size(); + assertTrue(packetsBeforeStop > 0); + display.stop(); + for (int i = 0; i < 50; i++) { + env.tick(); + } + int packetsAfterStop = packetCollector.collect().size(); + assertEquals(packetsBeforeStop, packetsAfterStop, "No more packets should be sent after stop() is called."); + env.destroyInstance(instance, true); + } +}