From 6a239b9252145940aa11aa13f353ef24910e281f Mon Sep 17 00:00:00 2001
From: theEvilReaper
Date: Mon, 13 Jul 2026 11:43:09 +0200
Subject: [PATCH 1/4] chore(phase): overhaul waiting loop
---
.../cygnus/phase/LobbyPhase.java | 120 +++++++++-------
.../cygnus/phase/task/LobbyWaitingTask.java | 132 ++++++++++++++++++
.../cygnus/phase/task/package-info.java | 4 +
3 files changed, 205 insertions(+), 51 deletions(-)
create mode 100644 game/src/main/java/net/onelitefeather/cygnus/phase/task/LobbyWaitingTask.java
create mode 100644 game/src/main/java/net/onelitefeather/cygnus/phase/task/package-info.java
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
From 3c22a239f3ac3fed2cc4b10fd2626f41da9cbefd Mon Sep 17 00:00:00 2001
From: theEvilReaper
Date: Mon, 13 Jul 2026 11:48:43 +0200
Subject: [PATCH 2/4] feat(phase): add waiting test
---
.../task/LobbyWaitingTaskIntegrationTest.java | 67 +++++++++++++++++++
1 file changed, 67 insertions(+)
create mode 100644 game/src/test/java/net/onelitefeather/cygnus/phase/task/LobbyWaitingTaskIntegrationTest.java
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..7dfa335
--- /dev/null
+++ b/game/src/test/java/net/onelitefeather/cygnus/phase/task/LobbyWaitingTaskIntegrationTest.java
@@ -0,0 +1,67 @@
+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().get();
+ 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);
+ }
+}
From d4bc8cf533270a64eaeb2a4b55fe1ed3779fafa1 Mon Sep 17 00:00:00 2001
From: theEvilReaper
Date: Mon, 13 Jul 2026 11:50:10 +0200
Subject: [PATCH 3/4] chore(phase): improve test case
---
.../cygnus/phase/task/LobbyWaitingTaskIntegrationTest.java | 3 ++-
1 file changed, 2 insertions(+), 1 deletion(-)
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
index 7dfa335..bc45862 100644
--- a/game/src/test/java/net/onelitefeather/cygnus/phase/task/LobbyWaitingTaskIntegrationTest.java
+++ b/game/src/test/java/net/onelitefeather/cygnus/phase/task/LobbyWaitingTaskIntegrationTest.java
@@ -32,7 +32,8 @@ void testWaitingDisplayActionBarUpdates(Env env) {
List packets = packetCollector.collect();
assertFalse(packets.isEmpty(), "An ActionBarPacket should have been sent to the player.");
- Component lastSentComponent = packets.getLast().components().stream().findFirst().get();
+ 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.");
From 9450832df2f5d1b5b53248b703cb39e708b10a56 Mon Sep 17 00:00:00 2001
From: theEvilReaper
Date: Mon, 13 Jul 2026 20:56:46 +0200
Subject: [PATCH 4/4] test(phase): add player threshold test
---
...byPhasePlayerThresholdIntegrationTest.java | 78 +++++++++++++++++++
1 file changed, 78 insertions(+)
create mode 100644 game/src/test/java/net/onelitefeather/cygnus/phase/LobbyPhasePlayerThresholdIntegrationTest.java
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);
+ }
+}