diff --git a/game/src/main/java/net/onelitefeather/cygnus/stamina/FoodBar.java b/game/src/main/java/net/onelitefeather/cygnus/stamina/FoodBar.java
index de36789e..d2a89e7e 100644
--- a/game/src/main/java/net/onelitefeather/cygnus/stamina/FoodBar.java
+++ b/game/src/main/java/net/onelitefeather/cygnus/stamina/FoodBar.java
@@ -1,6 +1,7 @@
package net.onelitefeather.cygnus.stamina;
import net.minestom.server.event.EventDispatcher;
+import net.minestom.server.timer.ExecutionType;
import net.onelitefeather.cygnus.movement.PlayerStopSprintingEvent;
import net.onelitefeather.cygnus.player.CygnusPlayer;
@@ -30,7 +31,7 @@ public non-sealed class FoodBar extends StaminaBar {
* @param player who owns the bar
*/
FoodBar(CygnusPlayer player) {
- super(player, ChronoUnit.MILLIS, 1000);
+ super(player, ChronoUnit.MILLIS, 1000, ExecutionType.TICK_START);
state = State.READY;
this.currentSpeedCount = MAX_FOOD;
}
diff --git a/game/src/main/java/net/onelitefeather/cygnus/stamina/SlenderBar.java b/game/src/main/java/net/onelitefeather/cygnus/stamina/SlenderBar.java
index e21eb4ad..924b9159 100644
--- a/game/src/main/java/net/onelitefeather/cygnus/stamina/SlenderBar.java
+++ b/game/src/main/java/net/onelitefeather/cygnus/stamina/SlenderBar.java
@@ -5,6 +5,7 @@
import net.minestom.server.event.EventDispatcher;
import net.minestom.server.instance.Instance;
import net.minestom.server.sound.SoundEvent;
+import net.minestom.server.timer.ExecutionType;
import net.onelitefeather.cygnus.common.Tags;
import net.onelitefeather.cygnus.event.StaminaStateChangeEvent;
import net.onelitefeather.cygnus.player.CygnusPlayer;
@@ -12,37 +13,85 @@
import java.time.temporal.ChronoUnit;
/**
+ * Manages the stamina/stealth ability of the slender player.
+ *
+ * The bar cycles through three {@link State}s:
+ *
+ * - {@link State#READY} - idle, hidden, ability can be activated
+ * - {@link State#DRAINING} - the ability is active: the slender is visible and vulnerable
+ * (blinded, slowed, can't sprint) and damages nearby survivors, while {@link #currentTime} counts down
+ * - {@link State#REGENERATING} - the slender is hidden again and recovers (night vision, normal
+ * speed) while {@link #currentTime} counts back up, reached either by manually cancelling DRAINING or
+ * automatically once it runs out
+ *
+ * Regeneration must reach {@value #MIN_TIME_TO_REACTIVATE} before {@link #changeStatus()} allows draining again.
+ *
* @author theEvilReaper
* @version 1.0.0
* @since 1.0.0
**/
@SuppressWarnings("java:S3252")
-public non-sealed class SlenderBar extends StaminaBar implements SlenderBarHelper {
+public final class SlenderBar extends StaminaBar implements SlenderBarHelper {
private static final Sound LEVEL = Sound.sound(SoundEvent.ENTITY_PLAYER_LEVELUP, Sound.Source.MASTER, 1F, 1F);
- // Constants
+ /**
+ * Upper bound of {@link #currentTime}, in stamina units and also the number of segments rendered in the
+ * action-bar progress display.
+ */
private static final int MAX_TIME = 16;
+
+ /**
+ * Amount {@link #currentTime} changes per tick (the bar ticks every 500ms, see the constructor).
+ */
private static final float TIME_STEP = 0.5f;
+
+ /**
+ * Minimum {@link #currentTime} regeneration must reach before {@link #changeStatus()} allows draining
+ * again, preventing the ability from being immediately re-triggered after it ran dry.
+ */
+ private static final int MIN_TIME_TO_REACTIVATE = 10;
+
+ /**
+ * Movement speed while {@link State#DRAINING} - deliberately slow since the slender is visible then.
+ */
+ private static final double DRAINING_MOVEMENT_SPEED = 0.0669;
+
+ private static final float HIDDEN_MOVEMENT_SPEED = 0.1f;
+ private static final int DAMAGE_RANGE = 3;
+
private final String tileChar;
private final int time;
private double currentTime;
private StaminaColors colorState;
+ /**
+ * Runs its periodic {@link #consume()} at {@link ExecutionType#TICK_END} rather than the default
+ * {@code TICK_START}: incoming player packets (e.g. a manual state change via
+ * {@link net.minestom.server.event.player.PlayerUseItemEvent}) are handled between those two phases,
+ * so this guarantees {@link #consume()} always sees a state already updated by a same-tick manual
+ * transition instead of racing it with stale data.
+ */
SlenderBar(CygnusPlayer player) {
- super(player, ChronoUnit.MILLIS, 500);
+ super(player, ChronoUnit.MILLIS, 500, ExecutionType.TICK_END);
this.tileChar = "▋";
this.time = MAX_TIME;
this.currentTime = time;
this.colorState = StaminaColors.DRAINING;
}
+ /**
+ * {@inheritDoc}
+ */
@Override
protected void onStart() {
this.state = State.READY;
this.player.addEffect(NIGHT_VISION.potion());
}
+ /**
+ * {@inheritDoc}
+ */
@Override
public void consume() {
if (state == State.READY) return;
@@ -57,71 +106,74 @@ private void handleDraining() {
if (currentTime >= 0) {
currentTime -= TIME_STEP;
Instance instance = player.getInstance();
- applyDamage(instance, player.getUuid(), player.getPosition(), 3, TIME_STEP);
- this.colorState.sendProgressBar(player, tileChar, (int) currentTime);
+ applyDamage(instance, player.getUuid(), player.getPosition(), DAMAGE_RANGE, TIME_STEP);
+ this.colorState.sendProgressBar(player, tileChar, currentTime, time);
return;
}
- state = State.REGENERATING;
- colorState = StaminaColors.REGENERATING;
- player.setTag(Tags.HIDDEN, HIDDEN);
- EventDispatcher.call(new StaminaStateChangeEvent(player, state));
- this.applyNightVision(player);
- player.getAttribute(Attribute.MOVEMENT_SPEED).setBaseValue(0.1f);
- player.sendSpringPackets();
- player.setBlockedSprinting(false);
- this.colorState.sendProgressBar(player, tileChar, (int) currentTime);
+ enterRegenerating();
}
private void handleRegeneration() {
- this.colorState.sendProgressBar(player, tileChar, (int) currentTime);
- if (currentTime <= time + TIME_STEP) {
- currentTime += TIME_STEP;
- } else {
- state = State.READY;
- colorState = StaminaColors.DRAINING;
- player.playSound(LEVEL, player.getPosition());
-
+ if (currentTime < time) {
+ currentTime = Math.min(time, currentTime + TIME_STEP);
+ this.colorState.sendProgressBar(player, tileChar, currentTime, time);
+ return;
}
+ enterReady();
}
+ /**
+ * Toggles the ability for the current {@link State}: activates draining from {@link State#READY} or
+ * {@link State#REGENERATING}, or cancels an active drain back into {@link State#REGENERATING}.
+ *
+ * @return {@code false} if regeneration hasn't reached {@link #MIN_TIME_TO_REACTIVATE} yet and the
+ * status could not be changed, {@code true} otherwise
+ */
public boolean changeStatus() {
- if (state == State.REGENERATING && this.time <= 10) return false;
+ if (state == State.REGENERATING && this.currentTime < MIN_TIME_TO_REACTIVATE) return false;
switch (state) {
- case READY -> {
- state = State.DRAINING;
- colorState = StaminaColors.DRAINING;
- player.setTag(Tags.HIDDEN, HIDDEN);
- this.applyBlindness(player);
- player.getAttribute(Attribute.MOVEMENT_SPEED).setBaseValue(0.0669);
- player.sendSpringPackets();
- player.setSprinting(false);
- player.setBlockedSprinting(true);
- EventDispatcher.call(new StaminaStateChangeEvent(player, state));
- }
- case REGENERATING -> {
- state = State.DRAINING;
- colorState = StaminaColors.DRAINING;
- player.setTag(Tags.HIDDEN, HIDDEN);
- this.playSpawnSound(player.getInstance(), player.getPosition(), player.getUuid());
- this.applyBlindness(player);
- player.getAttribute(Attribute.MOVEMENT_SPEED).setBaseValue(0.0669);
- player.sendSpringPackets();
- player.setSprinting(false);
- player.setBlockedSprinting(true);
- EventDispatcher.call(new StaminaStateChangeEvent(player, state));
- }
- case DRAINING -> {
- state = State.REGENERATING;
- colorState = StaminaColors.REGENERATING;
- player.setTag(Tags.HIDDEN, VISIBLE);
- this.playTeleportSound(player.getInstance(), player.getPosition(), player.getUuid());
- this.applyNightVision(player);
- player.getAttribute(Attribute.MOVEMENT_SPEED).setBaseValue(0.1f);
- player.sendSpringPackets();
- player.setBlockedSprinting(false);
- EventDispatcher.call(new StaminaStateChangeEvent(player, state));
- }
+ case READY -> enterDraining(false);
+ case REGENERATING -> enterDraining(true);
+ case DRAINING -> enterRegenerating();
}
return true;
}
+
+ /**
+ * @param fromRegenerating whether this transition interrupts an ongoing regeneration, which plays a spawn sound
+ */
+ private void enterDraining(boolean fromRegenerating) {
+ state = State.DRAINING;
+ colorState = StaminaColors.DRAINING;
+ player.setTag(Tags.HIDDEN, VISIBLE);
+ if (fromRegenerating) {
+ this.playSpawnSound(player.getInstance(), player.getPosition(), player.getUuid());
+ }
+ this.applyBlindness(player);
+ player.getAttribute(Attribute.MOVEMENT_SPEED).setBaseValue(DRAINING_MOVEMENT_SPEED);
+ player.sendSpringPackets();
+ player.setSprinting(false);
+ player.setBlockedSprinting(true);
+ EventDispatcher.call(new StaminaStateChangeEvent(player, state));
+ this.colorState.sendProgressBar(player, tileChar, currentTime, time);
+ }
+
+ private void enterRegenerating() {
+ state = State.REGENERATING;
+ colorState = StaminaColors.REGENERATING;
+ player.setTag(Tags.HIDDEN, HIDDEN);
+ this.playTeleportSound(player.getInstance(), player.getPosition(), player.getUuid());
+ this.applyNightVision(player);
+ player.getAttribute(Attribute.MOVEMENT_SPEED).setBaseValue(HIDDEN_MOVEMENT_SPEED);
+ player.sendSpringPackets();
+ player.setBlockedSprinting(false);
+ EventDispatcher.call(new StaminaStateChangeEvent(player, state));
+ this.colorState.sendProgressBar(player, tileChar, currentTime, time);
+ }
+
+ private void enterReady() {
+ state = State.READY;
+ colorState = StaminaColors.DRAINING;
+ player.playSound(LEVEL, player.getPosition());
+ }
}
diff --git a/game/src/main/java/net/onelitefeather/cygnus/stamina/SlenderBarTrigger.java b/game/src/main/java/net/onelitefeather/cygnus/stamina/SlenderBarTrigger.java
index a64e9e48..3bac4103 100644
--- a/game/src/main/java/net/onelitefeather/cygnus/stamina/SlenderBarTrigger.java
+++ b/game/src/main/java/net/onelitefeather/cygnus/stamina/SlenderBarTrigger.java
@@ -5,7 +5,6 @@
import net.kyori.adventure.sound.Sound;
import net.minestom.server.entity.Player;
import net.minestom.server.sound.SoundEvent;
-import net.onelitefeather.cygnus.common.Tags;
import org.jetbrains.annotations.Nullable;
import java.util.function.Supplier;
@@ -56,19 +55,9 @@ public void trigger(Player player) {
}
lastSoundTimeStamp = System.currentTimeMillis() + COOLDOWN_TIME;
if (slenderBar.changeStatus()) {
- this.changeVisibilityStatus(player);
this.updateRuneFunction.accept(player);
+ return;
}
- }
-
- /**
- * Changes the visibility status of the player.
- *
- * @param player the player to change the visibility status
- */
- private void changeVisibilityStatus(Player player) {
- Byte value = player.getTag(Tags.HIDDEN);
- byte currentValue = value != null ? value : SlenderBarHelper.VISIBLE;
- player.setTag(Tags.HIDDEN, currentValue == SlenderBarHelper.VISIBLE ? SlenderBarHelper.HIDDEN : SlenderBarHelper.VISIBLE);
+ player.playSound(ABORT_SOUND);
}
}
diff --git a/game/src/main/java/net/onelitefeather/cygnus/stamina/StaminaBar.java b/game/src/main/java/net/onelitefeather/cygnus/stamina/StaminaBar.java
index 971ae08b..7413aaa0 100644
--- a/game/src/main/java/net/onelitefeather/cygnus/stamina/StaminaBar.java
+++ b/game/src/main/java/net/onelitefeather/cygnus/stamina/StaminaBar.java
@@ -1,6 +1,7 @@
package net.onelitefeather.cygnus.stamina;
import net.minestom.server.MinecraftServer;
+import net.minestom.server.timer.ExecutionType;
import net.minestom.server.timer.Task;
import net.onelitefeather.cygnus.player.CygnusPlayer;
import org.jetbrains.annotations.Nullable;
@@ -21,6 +22,7 @@ public abstract sealed class StaminaBar implements Runnable permits SlenderBar,
protected final CygnusPlayer player;
private final ChronoUnit chronoUnit;
+ private final ExecutionType executionType;
protected int period;
protected State state;
private @Nullable Task task;
@@ -28,14 +30,16 @@ public abstract sealed class StaminaBar implements Runnable permits SlenderBar,
/**
* Creates a new reference from an {@link StaminaBar}.
*
- * @param player the player who owns the bar
- * @param chronoUnit the tick interval for the bar
- * @param period the tick period for the par
+ * @param player the player who owns the bar
+ * @param chronoUnit the tick interval for the bar
+ * @param period the tick period for the par
+ * @param executionType when in the server tick the periodic {@link #consume()} task runs
*/
- protected StaminaBar(CygnusPlayer player, ChronoUnit chronoUnit, int period) {
+ protected StaminaBar(CygnusPlayer player, ChronoUnit chronoUnit, int period, ExecutionType executionType) {
this.player = player;
this.chronoUnit = chronoUnit;
this.period = period;
+ this.executionType = executionType;
}
protected abstract void onStart();
@@ -48,6 +52,7 @@ public void start() {
this.onStart();
task = MinecraftServer.getSchedulerManager()
.buildTask(this::consume)
+ .executionType(executionType)
.repeat(this.period, this.chronoUnit).schedule();
}
diff --git a/game/src/main/java/net/onelitefeather/cygnus/stamina/StaminaColors.java b/game/src/main/java/net/onelitefeather/cygnus/stamina/StaminaColors.java
index 1cdf44a8..30ceef01 100644
--- a/game/src/main/java/net/onelitefeather/cygnus/stamina/StaminaColors.java
+++ b/game/src/main/java/net/onelitefeather/cygnus/stamina/StaminaColors.java
@@ -1,6 +1,5 @@
package net.onelitefeather.cygnus.stamina;
-import net.theevilreaper.aves.util.Components;
import net.kyori.adventure.text.Component;
import net.kyori.adventure.text.format.NamedTextColor;
import net.minestom.server.entity.Player;
@@ -18,6 +17,8 @@ enum StaminaColors {
DRAINING(NamedTextColor.GOLD, NamedTextColor.GRAY),
REGENERATING(NamedTextColor.GREEN, NamedTextColor.GRAY);
+ private static final String HALF_TILE_CHAR = "▍";
+
private final NamedTextColor completeColor;
private final NamedTextColor emptyColor;
@@ -33,14 +34,25 @@ enum StaminaColors {
}
/**
- * Sends a progress bar to the player
+ * Sends a progress bar to the player. A dangling half stamina unit is rendered as its own half tile
+ * instead of being rounded away, so the bar keeps the same width ({@code maxTime} tiles) while still
+ * reflecting every half-step change.
*
* @param player the player to send the progress bar
- * @param tileChar the character to use for the progress bar
+ * @param tileChar the character to use for a full tile
* @param currentTime the current time to display
+ * @param maxTime the maximum time the bar represents
*/
- public void sendProgressBar(Player player, String tileChar, int currentTime) {
- Component progressBar = Components.getProgressBar(currentTime, 17, 17, tileChar, this.completeColor, this.emptyColor);
+ public void sendProgressBar(Player player, String tileChar, double currentTime, int maxTime) {
+ int fullTiles = (int) currentTime;
+ boolean hasHalfTile = currentTime - fullTiles >= 0.5;
+ int emptyTiles = maxTime - fullTiles - (hasHalfTile ? 1 : 0);
+
+ Component progressBar = Component.text(tileChar.repeat(fullTiles), this.completeColor);
+ if (hasHalfTile) {
+ progressBar = progressBar.append(Component.text(HALF_TILE_CHAR, this.completeColor));
+ }
+ progressBar = progressBar.append(Component.text(tileChar.repeat(emptyTiles), this.emptyColor));
player.sendActionBar(progressBar);
}
diff --git a/game/src/test/java/net/onelitefeather/cygnus/stamina/FoodBarIntegrationTest.java b/game/src/test/java/net/onelitefeather/cygnus/stamina/FoodBarIntegrationTest.java
new file mode 100644
index 00000000..c79dc791
--- /dev/null
+++ b/game/src/test/java/net/onelitefeather/cygnus/stamina/FoodBarIntegrationTest.java
@@ -0,0 +1,107 @@
+package net.onelitefeather.cygnus.stamina;
+
+import net.minestom.server.instance.Instance;
+import net.minestom.testing.Env;
+import net.onelitefeather.cygnus.CygnusPlayerTestBase;
+import net.onelitefeather.cygnus.player.CygnusPlayer;
+import org.jetbrains.annotations.NotNull;
+import org.junit.jupiter.api.Test;
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertFalse;
+import static org.junit.jupiter.api.Assertions.assertTrue;
+
+/**
+ * Integration test verifying the {@link FoodBar}'s drain/regenerate lifecycle.
+ */
+class FoodBarIntegrationTest extends CygnusPlayerTestBase {
+
+ @Test
+ void testStartIsReadyAndFull(@NotNull Env env) {
+ Instance instance = env.createFlatInstance();
+ CygnusPlayer player = (CygnusPlayer) env.createPlayer(instance);
+
+ FoodBar foodBar = (FoodBar) StaminaFactory.createFoodStamina(player);
+ foodBar.start();
+
+ assertEquals(1.0f, player.getExp(), "food should start completely full");
+ assertTrue(foodBar.canConsume(), "a fresh, ready bar should allow starting to sprint");
+
+ foodBar.stop();
+ env.destroyInstance(instance, true);
+ }
+
+ @Test
+ void testDrainingBlocksSprintWhenDepleted(@NotNull Env env) {
+ Instance instance = env.createFlatInstance();
+ CygnusPlayer player = (CygnusPlayer) env.createPlayer(instance);
+
+ FoodBar foodBar = (FoodBar) StaminaFactory.createFoodStamina(player);
+ foodBar.start();
+ foodBar.startConsume();
+
+ foodBar.consume();
+ assertEquals(0.9f, player.getExp(), 0.0001f, "draining once should take 2 of 20 food");
+ assertFalse(player.hasBlockedSprinting(), "sprinting shouldn't be blocked while food remains");
+
+ // 20 food / 2 per tick = 10 ticks to fully deplete
+ for (int i = 0; i < 9; i++) {
+ foodBar.consume();
+ }
+
+ assertTrue(player.hasBlockedSprinting(), "sprinting should be blocked once food is fully depleted");
+ assertEquals(0.0f, player.getExp(), "depleted food should show an empty bar");
+
+ foodBar.stop();
+ env.destroyInstance(instance, true);
+ }
+
+ @Test
+ void testRegenerationUnblocksSprint(@NotNull Env env) {
+ Instance instance = env.createFlatInstance();
+ CygnusPlayer player = (CygnusPlayer) env.createPlayer(instance);
+
+ FoodBar foodBar = (FoodBar) StaminaFactory.createFoodStamina(player);
+ foodBar.start();
+ foodBar.startConsume();
+
+ // fully deplete to enter REGENERATING with sprinting blocked
+ for (int i = 0; i < 10; i++) {
+ foodBar.consume();
+ }
+ assertTrue(player.hasBlockedSprinting());
+
+ // 20 food, +1 per tick, needs 20 ticks to fully regenerate
+ for (int i = 0; i < 20; i++) {
+ foodBar.consume();
+ }
+
+ assertFalse(player.hasBlockedSprinting(), "sprinting should be unblocked once food is fully restored");
+ assertEquals(1.0f, player.getExp(), "fully regenerated food should show a full bar");
+ assertTrue(foodBar.canConsume(), "a fully regenerated, READY bar should allow sprinting again");
+
+ foodBar.stop();
+ env.destroyInstance(instance, true);
+ }
+
+ @Test
+ void testCannotConsumeRightAfterDepletion(@NotNull Env env) {
+ Instance instance = env.createFlatInstance();
+ CygnusPlayer player = (CygnusPlayer) env.createPlayer(instance);
+
+ FoodBar foodBar = (FoodBar) StaminaFactory.createFoodStamina(player);
+ foodBar.start();
+ foodBar.startConsume();
+
+ // fully deplete to enter REGENERATING at its lowest point
+ for (int i = 0; i < 10; i++) {
+ foodBar.consume();
+ }
+
+ assertFalse(foodBar.canConsume(),
+ "should not be able to sprint again immediately after running out of food");
+
+ foodBar.stop();
+ env.destroyInstance(instance, true);
+ }
+}
diff --git a/game/src/test/java/net/onelitefeather/cygnus/stamina/SlenderBarIntegrationTest.java b/game/src/test/java/net/onelitefeather/cygnus/stamina/SlenderBarIntegrationTest.java
new file mode 100644
index 00000000..482a974e
--- /dev/null
+++ b/game/src/test/java/net/onelitefeather/cygnus/stamina/SlenderBarIntegrationTest.java
@@ -0,0 +1,192 @@
+package net.onelitefeather.cygnus.stamina;
+
+import net.kyori.adventure.text.Component;
+import net.kyori.adventure.text.TextComponent;
+import net.minestom.server.entity.Player;
+import net.minestom.server.instance.Instance;
+import net.minestom.server.network.packet.server.ServerPacket;
+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.player.CygnusPlayer;
+import org.jetbrains.annotations.NotNull;
+import org.junit.jupiter.api.Test;
+
+import java.util.List;
+
+import static org.junit.jupiter.api.Assertions.assertDoesNotThrow;
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertFalse;
+import static org.junit.jupiter.api.Assertions.assertInstanceOf;
+import static org.junit.jupiter.api.Assertions.assertTrue;
+
+/**
+ * Integration test verifying that the {@link SlenderBar} renders its progress bar correctly.
+ */
+class SlenderBarIntegrationTest extends CygnusPlayerTestBase {
+
+ @Test
+ void testActivationShowsFullBar(@NotNull Env env) {
+ Instance instance = env.createFlatInstance();
+ TestConnection connection = env.createConnection();
+ CygnusPlayer player = (CygnusPlayer) connection.connect(instance);
+
+ SlenderBar slenderBar = (SlenderBar) StaminaFactory.createSlenderStamina(player);
+ slenderBar.start();
+
+ Collector collector = connection.trackIncoming(ActionBarPacket.class);
+ slenderBar.changeStatus();
+
+ collector.assertSingle(packet -> {
+ Component text = packet.text();
+ TextComponent root = assertInstanceOf(TextComponent.class, text);
+ assertEquals("▋".repeat(16), root.content(), "the bar should be fully filled right when draining starts");
+ assertEquals(1, root.children().size());
+ TextComponent empty = assertInstanceOf(TextComponent.class, root.children().getFirst());
+ assertEquals("", empty.content(), "no segment should be missing right when draining starts");
+ });
+
+ slenderBar.stop();
+ env.destroyInstance(instance, true);
+ }
+
+ @Test
+ void testRegenerationStopsAtFullBar(@NotNull Env env) {
+ Instance instance = env.createFlatInstance();
+ TestConnection connection = env.createConnection();
+ CygnusPlayer player = (CygnusPlayer) connection.connect(instance);
+
+ SlenderBar slenderBar = (SlenderBar) StaminaFactory.createSlenderStamina(player);
+ slenderBar.start();
+ slenderBar.changeStatus();
+
+ Collector collector = connection.trackIncoming(ActionBarPacket.class);
+
+ assertDoesNotThrow(() -> {
+ for (int i = 0; i < 80; i++) {
+ slenderBar.consume();
+ }
+ }, "a full drain-then-regenerate cycle must not overshoot the bar and crash");
+
+ List packets = collector.collect();
+ assertFalse(packets.isEmpty());
+ ActionBarPacket last = packets.get(packets.size() - 1);
+ TextComponent root = assertInstanceOf(TextComponent.class, last.text());
+ assertEquals("▋".repeat(16), root.content(), "regeneration should stop exactly at a full bar");
+ TextComponent empty = assertInstanceOf(TextComponent.class, root.children().getFirst());
+ assertEquals("", empty.content());
+
+ slenderBar.stop();
+ env.destroyInstance(instance, true);
+ }
+
+ @Test
+ void testHalfDrainedTickShowsAHalfTileInsteadOfDroppingAWholeTile(@NotNull Env env) {
+ Instance instance = env.createFlatInstance();
+ TestConnection connection = env.createConnection();
+ CygnusPlayer player = (CygnusPlayer) connection.connect(instance);
+
+ SlenderBar slenderBar = (SlenderBar) StaminaFactory.createSlenderStamina(player);
+ slenderBar.start();
+ slenderBar.changeStatus(); // READY -> DRAINING, currentTime starts at 16.0
+
+ Collector collector = connection.trackIncoming(ActionBarPacket.class);
+ slenderBar.consume(); // one 0.5 tick: currentTime becomes 15.5
+
+ collector.assertSingle(packet -> {
+ TextComponent root = assertInstanceOf(TextComponent.class, packet.text());
+ assertEquals("▋".repeat(15), root.content(), "15 full tiles should stay filled after only half a tile drained");
+ assertEquals(2, root.children().size(), "a half-drained tile needs its own segment next to the empty segment");
+ TextComponent half = assertInstanceOf(TextComponent.class, root.children().get(0));
+ assertEquals("▍", half.content(), "the 16th tile should render as a half tile, not disappear entirely");
+ TextComponent empty = assertInstanceOf(TextComponent.class, root.children().get(1));
+ assertEquals("", empty.content(), "no fully empty tiles yet after just one tick");
+ });
+
+ slenderBar.stop();
+ env.destroyInstance(instance, true);
+ }
+
+ @Test
+ void testCannotReactivateTooEarly(@NotNull Env env) {
+ Instance instance = env.createFlatInstance();
+ TestConnection connection = env.createConnection();
+ CygnusPlayer player = (CygnusPlayer) connection.connect(instance);
+
+ SlenderBar slenderBar = (SlenderBar) StaminaFactory.createSlenderStamina(player);
+ slenderBar.start();
+ slenderBar.changeStatus(); // READY -> DRAINING
+
+ // fully drain so the bar enters REGENERATING at its lowest point
+ for (int i = 0; i < 34; i++) {
+ slenderBar.consume();
+ }
+
+ boolean reactivated = slenderBar.changeStatus();
+
+ assertFalse(reactivated, "should not be able to hide again before stamina has sufficiently regenerated");
+
+ slenderBar.stop();
+ env.destroyInstance(instance, true);
+ }
+
+ @Test
+ void testCanReactivateAtThreshold(@NotNull Env env) {
+ Instance instance = env.createFlatInstance();
+ TestConnection connection = env.createConnection();
+ CygnusPlayer player = (CygnusPlayer) connection.connect(instance);
+
+ SlenderBar slenderBar = (SlenderBar) StaminaFactory.createSlenderStamina(player);
+ slenderBar.start();
+ slenderBar.changeStatus(); // READY -> DRAINING
+
+ // 34 ticks to fully drain and auto-switch to REGENERATING at currentTime == -0.5,
+ // then 21 more ticks of +0.5 regeneration land currentTime exactly on 10.0.
+ for (int i = 0; i < 55; i++) {
+ slenderBar.consume();
+ }
+
+ boolean reactivated = slenderBar.changeStatus();
+
+ assertTrue(reactivated, "the javadoc says regeneration must \"reach\" the threshold - " +
+ "landing exactly on it should be sufficient, not just exceeding it");
+
+ slenderBar.stop();
+ env.destroyInstance(instance, true);
+ }
+
+ @Test
+ void testAutoDepletionPlaysTeleportSound(@NotNull Env env) {
+ Instance instance = env.createFlatInstance();
+ TestConnection connection = env.createConnection();
+ CygnusPlayer player = (CygnusPlayer) connection.connect(instance);
+ TestConnection survivorConnection = env.createConnection();
+ Player nearbySurvivor = survivorConnection.connect(instance);
+ nearbySurvivor.teleport(player.getPosition()).join();
+ env.tick();
+
+ SlenderBar slenderBar = (SlenderBar) StaminaFactory.createSlenderStamina(player);
+ slenderBar.start();
+ slenderBar.changeStatus(); // READY -> DRAINING
+
+ // the teleport sound plays to nearby SURVIVORS, not to the slender player themselves
+ Collector collector = survivorConnection.trackIncoming();
+ // fully drain so the bar automatically switches to REGENERATING
+ for (int i = 0; i < 34; i++) {
+ slenderBar.consume();
+ }
+
+ assertTrue(soundWasSent(collector),
+ "nearby players should hear the teleport sound when draining runs out on its own too, " +
+ "not just when it's manually cancelled");
+
+ slenderBar.stop();
+ env.destroyInstance(instance, true);
+ }
+
+ private static boolean soundWasSent(Collector collector) {
+ return collector.collect().stream().anyMatch(packet -> packet.getClass().getSimpleName().contains("Sound"));
+ }
+}
diff --git a/game/src/test/java/net/onelitefeather/cygnus/stamina/SlenderBarTriggerIntegrationTest.java b/game/src/test/java/net/onelitefeather/cygnus/stamina/SlenderBarTriggerIntegrationTest.java
new file mode 100644
index 00000000..cf96be58
--- /dev/null
+++ b/game/src/test/java/net/onelitefeather/cygnus/stamina/SlenderBarTriggerIntegrationTest.java
@@ -0,0 +1,99 @@
+package net.onelitefeather.cygnus.stamina;
+
+import net.minestom.server.instance.Instance;
+import net.minestom.server.network.packet.server.ServerPacket;
+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.Tags;
+import net.onelitefeather.cygnus.common.config.GameConfig;
+import net.onelitefeather.cygnus.player.CygnusPlayer;
+import net.onelitefeather.cygnus.utils.ViewRuleUpdater;
+import org.jetbrains.annotations.NotNull;
+import org.junit.jupiter.api.Test;
+
+import static org.junit.jupiter.api.Assertions.assertFalse;
+import static org.junit.jupiter.api.Assertions.assertTrue;
+
+/**
+ * Integration test verifying that {@link SlenderBarTrigger} switches the slender's visibility correctly.
+ * DRAINING (actively using the ability) makes the slender visible, REGENERATING (recovering) hides them.
+ */
+class SlenderBarTriggerIntegrationTest extends CygnusPlayerTestBase {
+
+ @Test
+ void testTriggerMakesPlayerVisibleWhenActivatingDraining(@NotNull Env env) {
+ Instance instance = env.createFlatInstance();
+ CygnusPlayer player = (CygnusPlayer) env.createPlayer(instance);
+ player.setTag(Tags.TEAM_KEY, GameConfig.SLENDER_KEY);
+
+ SlenderBar slenderBar = (SlenderBar) StaminaFactory.createSlenderStamina(player);
+ slenderBar.start();
+
+ SlenderBarTrigger trigger = new SlenderBarTrigger(() -> slenderBar, ignored -> {
+ });
+ trigger.trigger(player);
+
+ assertFalse(ViewRuleUpdater.isHidden(player), "player should be visible right after activating draining");
+
+ slenderBar.stop();
+ env.destroyInstance(instance, true);
+ }
+
+ @Test
+ void testRapidTriggerIsBlockedByCooldownAndGivesFeedback(@NotNull Env env) {
+ Instance instance = env.createFlatInstance();
+ TestConnection connection = env.createConnection();
+ CygnusPlayer player = (CygnusPlayer) connection.connect(instance);
+ player.setTag(Tags.TEAM_KEY, GameConfig.SLENDER_KEY);
+
+ SlenderBar slenderBar = (SlenderBar) StaminaFactory.createSlenderStamina(player);
+ slenderBar.start();
+
+ SlenderBarTrigger trigger = new SlenderBarTrigger(() -> slenderBar, ignored -> {
+ });
+ trigger.trigger(player); // READY -> DRAINING
+
+ Collector collector = connection.trackIncoming();
+ trigger.trigger(player); // immediately again, still within the spam cooldown
+
+ assertFalse(ViewRuleUpdater.isHidden(player), "the second, cooldown-blocked trigger must not toggle the status again");
+ assertTrue(soundWasSent(collector), "player should get audible feedback that the trigger is on cooldown");
+
+ slenderBar.stop();
+ env.destroyInstance(instance, true);
+ }
+
+ @Test
+ void testTriggerGivesFeedbackWhenBlockedByInsufficientRegeneration(@NotNull Env env) {
+ Instance instance = env.createFlatInstance();
+ TestConnection connection = env.createConnection();
+ CygnusPlayer player = (CygnusPlayer) connection.connect(instance);
+ player.setTag(Tags.TEAM_KEY, GameConfig.SLENDER_KEY);
+
+ SlenderBar slenderBar = (SlenderBar) StaminaFactory.createSlenderStamina(player);
+ slenderBar.start();
+ slenderBar.changeStatus(); // READY -> DRAINING, bypassing the trigger's own cooldown bookkeeping
+
+ // fully drain so the bar auto-switches to REGENERATING while stamina is still low
+ for (int i = 0; i < 34; i++) {
+ slenderBar.consume();
+ }
+
+ SlenderBarTrigger trigger = new SlenderBarTrigger(() -> slenderBar, ignored -> {
+ });
+ Collector collector = connection.trackIncoming();
+ trigger.trigger(player);
+
+ assertTrue(ViewRuleUpdater.isHidden(player), "player should stay hidden/blocked instead of toggling back to draining");
+ assertTrue(soundWasSent(collector), "player should get audible feedback when blocked by insufficient regeneration");
+
+ slenderBar.stop();
+ env.destroyInstance(instance, true);
+ }
+
+ private static boolean soundWasSent(Collector collector) {
+ return collector.collect().stream().anyMatch(packet -> packet.getClass().getSimpleName().contains("Sound"));
+ }
+}