From a6f297e94c00545ab52286db332b3af0912a7376 Mon Sep 17 00:00:00 2001 From: theEvilReaper Date: Mon, 10 Aug 2026 23:38:02 +0200 Subject: [PATCH 01/13] chore(stamina): improve maxTime handling --- .../net/onelitefeather/cygnus/stamina/StaminaColors.java | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) 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..65ee831f 100644 --- a/game/src/main/java/net/onelitefeather/cygnus/stamina/StaminaColors.java +++ b/game/src/main/java/net/onelitefeather/cygnus/stamina/StaminaColors.java @@ -38,9 +38,10 @@ enum StaminaColors { * @param player the player to send the progress bar * @param tileChar the character to use for the progress bar * @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, int currentTime, int maxTime) { + Component progressBar = Components.getProgressBar(currentTime, maxTime, maxTime, tileChar, this.completeColor, this.emptyColor); player.sendActionBar(progressBar); } From ecd0a7008f0bfa0716e5f5395654b11021d45c64 Mon Sep 17 00:00:00 2001 From: theEvilReaper Date: Mon, 10 Aug 2026 23:38:25 +0200 Subject: [PATCH 02/13] chore(stamina): improve regeneration logic --- .../cygnus/stamina/SlenderBar.java | 24 ++++++++++--------- 1 file changed, 13 insertions(+), 11 deletions(-) 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..9142b058 100644 --- a/game/src/main/java/net/onelitefeather/cygnus/stamina/SlenderBar.java +++ b/game/src/main/java/net/onelitefeather/cygnus/stamina/SlenderBar.java @@ -58,7 +58,7 @@ private void handleDraining() { currentTime -= TIME_STEP; Instance instance = player.getInstance(); applyDamage(instance, player.getUuid(), player.getPosition(), 3, TIME_STEP); - this.colorState.sendProgressBar(player, tileChar, (int) currentTime); + this.colorState.sendProgressBar(player, tileChar, (int) currentTime, time); return; } state = State.REGENERATING; @@ -69,23 +69,22 @@ private void handleDraining() { player.getAttribute(Attribute.MOVEMENT_SPEED).setBaseValue(0.1f); player.sendSpringPackets(); player.setBlockedSprinting(false); - this.colorState.sendProgressBar(player, tileChar, (int) currentTime); + this.colorState.sendProgressBar(player, tileChar, (int) currentTime, time); } 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, (int) currentTime, time); + return; } + state = State.READY; + colorState = StaminaColors.DRAINING; + player.playSound(LEVEL, player.getPosition()); } public boolean changeStatus() { - if (state == State.REGENERATING && this.time <= 10) return false; + if (state == State.REGENERATING && this.currentTime <= 10) return false; switch (state) { case READY -> { state = State.DRAINING; @@ -97,6 +96,7 @@ public boolean changeStatus() { player.setSprinting(false); player.setBlockedSprinting(true); EventDispatcher.call(new StaminaStateChangeEvent(player, state)); + this.colorState.sendProgressBar(player, tileChar, (int) currentTime, time); } case REGENERATING -> { state = State.DRAINING; @@ -109,6 +109,7 @@ public boolean changeStatus() { player.setSprinting(false); player.setBlockedSprinting(true); EventDispatcher.call(new StaminaStateChangeEvent(player, state)); + this.colorState.sendProgressBar(player, tileChar, (int) currentTime, time); } case DRAINING -> { state = State.REGENERATING; @@ -120,6 +121,7 @@ public boolean changeStatus() { player.sendSpringPackets(); player.setBlockedSprinting(false); EventDispatcher.call(new StaminaStateChangeEvent(player, state)); + this.colorState.sendProgressBar(player, tileChar, (int) currentTime, time); } } return true; From 30891335ddc08238168e002b4ecfe1ebcfc6ff5f Mon Sep 17 00:00:00 2001 From: theEvilReaper Date: Mon, 10 Aug 2026 23:39:38 +0200 Subject: [PATCH 03/13] test(stamina): add slender bar integration test --- .../stamina/SlenderBarIntegrationTest.java | 104 ++++++++++++++++++ 1 file changed, 104 insertions(+) create mode 100644 game/src/test/java/net/onelitefeather/cygnus/stamina/SlenderBarIntegrationTest.java 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..52731258 --- /dev/null +++ b/game/src/test/java/net/onelitefeather/cygnus/stamina/SlenderBarIntegrationTest.java @@ -0,0 +1,104 @@ +package net.onelitefeather.cygnus.stamina; + +import net.kyori.adventure.text.Component; +import net.kyori.adventure.text.TextComponent; +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.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; + +/** + * Integration test verifying that the {@link SlenderBar} renders its progress bar correctly. + */ +class SlenderBarIntegrationTest extends CygnusPlayerTestBase { + + @Test + void testActivatingSlenderBarShowsFullProgressBarImmediately(@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 testRegenerationStopsExactlyAtFullBar(@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 testCannotReactivateBeforeSufficientRegeneration(@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); + } +} From a15347f3ca6485196138158606b0ff0fbd246d83 Mon Sep 17 00:00:00 2001 From: theEvilReaper Date: Tue, 11 Aug 2026 10:36:02 +0200 Subject: [PATCH 04/13] chore(stamina): improve code layout from the SlenderBar --- .../cygnus/stamina/SlenderBar.java | 140 ++++++++++++------ 1 file changed, 95 insertions(+), 45 deletions(-) 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 9142b058..db8a80ad 100644 --- a/game/src/main/java/net/onelitefeather/cygnus/stamina/SlenderBar.java +++ b/game/src/main/java/net/onelitefeather/cygnus/stamina/SlenderBar.java @@ -12,18 +12,53 @@ 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; @@ -37,12 +72,18 @@ public non-sealed class SlenderBar extends StaminaBar implements SlenderBarHelpe 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,7 +98,7 @@ private void handleDraining() { if (currentTime >= 0) { currentTime -= TIME_STEP; Instance instance = player.getInstance(); - applyDamage(instance, player.getUuid(), player.getPosition(), 3, TIME_STEP); + applyDamage(instance, player.getUuid(), player.getPosition(), DAMAGE_RANGE, TIME_STEP); this.colorState.sendProgressBar(player, tileChar, (int) currentTime, time); return; } @@ -66,7 +107,7 @@ private void handleDraining() { player.setTag(Tags.HIDDEN, HIDDEN); EventDispatcher.call(new StaminaStateChangeEvent(player, state)); this.applyNightVision(player); - player.getAttribute(Attribute.MOVEMENT_SPEED).setBaseValue(0.1f); + player.getAttribute(Attribute.MOVEMENT_SPEED).setBaseValue(HIDDEN_MOVEMENT_SPEED); player.sendSpringPackets(); player.setBlockedSprinting(false); this.colorState.sendProgressBar(player, tileChar, (int) currentTime, time); @@ -78,52 +119,61 @@ private void handleRegeneration() { this.colorState.sendProgressBar(player, tileChar, (int) currentTime, time); return; } - state = State.READY; - colorState = StaminaColors.DRAINING; - player.playSound(LEVEL, player.getPosition()); + 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.currentTime <= 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)); - this.colorState.sendProgressBar(player, tileChar, (int) currentTime, time); - } - 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)); - this.colorState.sendProgressBar(player, tileChar, (int) currentTime, time); - } - 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)); - this.colorState.sendProgressBar(player, tileChar, (int) currentTime, time); - } + 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, (int) 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, (int) currentTime, time); + } + + private void enterReady() { + state = State.READY; + colorState = StaminaColors.DRAINING; + player.playSound(LEVEL, player.getPosition()); + } } From a09b6408ca20a03fc691a95823bcb2844f55b135 Mon Sep 17 00:00:00 2001 From: theEvilReaper Date: Tue, 11 Aug 2026 10:36:11 +0200 Subject: [PATCH 05/13] chore(stamina): update trigger logic --- .../cygnus/stamina/SlenderBarTrigger.java | 15 ++------------- 1 file changed, 2 insertions(+), 13 deletions(-) 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); } } From 824cc0afa3fe4b0ba9f33004675c138ce909fb51 Mon Sep 17 00:00:00 2001 From: theEvilReaper Date: Tue, 11 Aug 2026 10:36:32 +0200 Subject: [PATCH 06/13] test(stamina): add integration test --- .../SlenderBarTriggerIntegrationTest.java | 99 +++++++++++++++++++ 1 file changed, 99 insertions(+) create mode 100644 game/src/test/java/net/onelitefeather/cygnus/stamina/SlenderBarTriggerIntegrationTest.java 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")); + } +} From e51c079cf6de6e7696c8e15942bf5f6ebd6602ce Mon Sep 17 00:00:00 2001 From: theEvilReaper Date: Tue, 11 Aug 2026 13:13:09 +0200 Subject: [PATCH 07/13] chore(stamina): set execution time to tick end --- .../java/net/onelitefeather/cygnus/stamina/StaminaBar.java | 7 +++++++ 1 file changed, 7 insertions(+) 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..22d55d84 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; @@ -42,12 +43,18 @@ protected StaminaBar(CygnusPlayer player, ChronoUnit chronoUnit, int period) { /** * Creates a new {@link Task} which executes the {@link StaminaBar#consume()} method on each iteration. + *

+ * Runs 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. */ public void start() { if (task != null) return; this.onStart(); task = MinecraftServer.getSchedulerManager() .buildTask(this::consume) + .executionType(ExecutionType.TICK_END) .repeat(this.period, this.chronoUnit).schedule(); } From 4c08f27268050a874b5ff3573e0247f1b23b6030 Mon Sep 17 00:00:00 2001 From: theEvilReaper Date: Tue, 11 Aug 2026 13:32:58 +0200 Subject: [PATCH 08/13] fix(stamina): improve sound handling --- .../cygnus/stamina/SlenderBar.java | 10 +----- .../stamina/SlenderBarIntegrationTest.java | 36 +++++++++++++++++++ 2 files changed, 37 insertions(+), 9 deletions(-) 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 db8a80ad..676d32cd 100644 --- a/game/src/main/java/net/onelitefeather/cygnus/stamina/SlenderBar.java +++ b/game/src/main/java/net/onelitefeather/cygnus/stamina/SlenderBar.java @@ -102,15 +102,7 @@ private void handleDraining() { this.colorState.sendProgressBar(player, tileChar, (int) 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(HIDDEN_MOVEMENT_SPEED); - player.sendSpringPackets(); - player.setBlockedSprinting(false); - this.colorState.sendProgressBar(player, tileChar, (int) currentTime, time); + enterRegenerating(); } private void handleRegeneration() { diff --git a/game/src/test/java/net/onelitefeather/cygnus/stamina/SlenderBarIntegrationTest.java b/game/src/test/java/net/onelitefeather/cygnus/stamina/SlenderBarIntegrationTest.java index 52731258..08ea34a5 100644 --- a/game/src/test/java/net/onelitefeather/cygnus/stamina/SlenderBarIntegrationTest.java +++ b/game/src/test/java/net/onelitefeather/cygnus/stamina/SlenderBarIntegrationTest.java @@ -2,7 +2,9 @@ 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; @@ -18,6 +20,7 @@ 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. @@ -101,4 +104,37 @@ void testCannotReactivateBeforeSufficientRegeneration(@NotNull Env env) { slenderBar.stop(); env.destroyInstance(instance, true); } + + @Test + void testAutomaticDepletionPlaysTeleportSoundToNearbyPlayers(@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")); + } } From b60e06b6e7a1d6982bd3259cf1f88bfc9e874535 Mon Sep 17 00:00:00 2001 From: theEvilReaper Date: Tue, 11 Aug 2026 17:59:04 +0200 Subject: [PATCH 09/13] chore(stamina): add execution type variable --- .../onelitefeather/cygnus/stamina/FoodBar.java | 3 ++- .../cygnus/stamina/SlenderBar.java | 12 ++++++++++-- .../cygnus/stamina/StaminaBar.java | 18 ++++++++---------- 3 files changed, 20 insertions(+), 13 deletions(-) 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 676d32cd..8e5e4e26 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; @@ -64,8 +65,15 @@ public final class SlenderBar extends StaminaBar implements SlenderBarHelper { 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; @@ -122,7 +130,7 @@ private void handleRegeneration() { * status could not be changed, {@code true} otherwise */ public boolean changeStatus() { - if (state == State.REGENERATING && this.currentTime <= MIN_TIME_TO_REACTIVATE) return false; + if (state == State.REGENERATING && this.currentTime < MIN_TIME_TO_REACTIVATE) return false; switch (state) { case READY -> enterDraining(false); case REGENERATING -> enterDraining(true); 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 22d55d84..7413aaa0 100644 --- a/game/src/main/java/net/onelitefeather/cygnus/stamina/StaminaBar.java +++ b/game/src/main/java/net/onelitefeather/cygnus/stamina/StaminaBar.java @@ -22,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; @@ -29,32 +30,29 @@ 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(); /** * Creates a new {@link Task} which executes the {@link StaminaBar#consume()} method on each iteration. - *

- * Runs 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. */ public void start() { if (task != null) return; this.onStart(); task = MinecraftServer.getSchedulerManager() .buildTask(this::consume) - .executionType(ExecutionType.TICK_END) + .executionType(executionType) .repeat(this.period, this.chronoUnit).schedule(); } From 07c754a5f860175af5f864300d6bd45af0bd9658 Mon Sep 17 00:00:00 2001 From: theEvilReaper Date: Tue, 11 Aug 2026 18:04:06 +0200 Subject: [PATCH 10/13] test(stamina): improve test cases --- .../stamina/FoodBarIntegrationTest.java | 107 ++++++++++++++++++ .../stamina/SlenderBarIntegrationTest.java | 33 +++++- 2 files changed, 136 insertions(+), 4 deletions(-) create mode 100644 game/src/test/java/net/onelitefeather/cygnus/stamina/FoodBarIntegrationTest.java 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 index 08ea34a5..e863ff49 100644 --- a/game/src/test/java/net/onelitefeather/cygnus/stamina/SlenderBarIntegrationTest.java +++ b/game/src/test/java/net/onelitefeather/cygnus/stamina/SlenderBarIntegrationTest.java @@ -28,7 +28,7 @@ class SlenderBarIntegrationTest extends CygnusPlayerTestBase { @Test - void testActivatingSlenderBarShowsFullProgressBarImmediately(@NotNull Env env) { + void testActivationShowsFullBar(@NotNull Env env) { Instance instance = env.createFlatInstance(); TestConnection connection = env.createConnection(); CygnusPlayer player = (CygnusPlayer) connection.connect(instance); @@ -53,7 +53,7 @@ void testActivatingSlenderBarShowsFullProgressBarImmediately(@NotNull Env env) { } @Test - void testRegenerationStopsExactlyAtFullBar(@NotNull Env env) { + void testRegenerationStopsAtFullBar(@NotNull Env env) { Instance instance = env.createFlatInstance(); TestConnection connection = env.createConnection(); CygnusPlayer player = (CygnusPlayer) connection.connect(instance); @@ -83,7 +83,7 @@ void testRegenerationStopsExactlyAtFullBar(@NotNull Env env) { } @Test - void testCannotReactivateBeforeSufficientRegeneration(@NotNull Env env) { + void testCannotReactivateTooEarly(@NotNull Env env) { Instance instance = env.createFlatInstance(); TestConnection connection = env.createConnection(); CygnusPlayer player = (CygnusPlayer) connection.connect(instance); @@ -106,7 +106,32 @@ void testCannotReactivateBeforeSufficientRegeneration(@NotNull Env env) { } @Test - void testAutomaticDepletionPlaysTeleportSoundToNearbyPlayers(@NotNull Env env) { + 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); From 9587344e284ff48fe061b0a27b51b20eb9e0e8d4 Mon Sep 17 00:00:00 2001 From: theEvilReaper Date: Tue, 11 Aug 2026 10:54:47 +0200 Subject: [PATCH 11/13] chore(stamina): remove int cast --- .../net/onelitefeather/cygnus/stamina/SlenderBar.java | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) 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 8e5e4e26..924b9159 100644 --- a/game/src/main/java/net/onelitefeather/cygnus/stamina/SlenderBar.java +++ b/game/src/main/java/net/onelitefeather/cygnus/stamina/SlenderBar.java @@ -107,7 +107,7 @@ private void handleDraining() { currentTime -= TIME_STEP; Instance instance = player.getInstance(); applyDamage(instance, player.getUuid(), player.getPosition(), DAMAGE_RANGE, TIME_STEP); - this.colorState.sendProgressBar(player, tileChar, (int) currentTime, time); + this.colorState.sendProgressBar(player, tileChar, currentTime, time); return; } enterRegenerating(); @@ -116,7 +116,7 @@ private void handleDraining() { private void handleRegeneration() { if (currentTime < time) { currentTime = Math.min(time, currentTime + TIME_STEP); - this.colorState.sendProgressBar(player, tileChar, (int) currentTime, time); + this.colorState.sendProgressBar(player, tileChar, currentTime, time); return; } enterReady(); @@ -155,7 +155,7 @@ private void enterDraining(boolean fromRegenerating) { player.setSprinting(false); player.setBlockedSprinting(true); EventDispatcher.call(new StaminaStateChangeEvent(player, state)); - this.colorState.sendProgressBar(player, tileChar, (int) currentTime, time); + this.colorState.sendProgressBar(player, tileChar, currentTime, time); } private void enterRegenerating() { @@ -168,7 +168,7 @@ private void enterRegenerating() { player.sendSpringPackets(); player.setBlockedSprinting(false); EventDispatcher.call(new StaminaStateChangeEvent(player, state)); - this.colorState.sendProgressBar(player, tileChar, (int) currentTime, time); + this.colorState.sendProgressBar(player, tileChar, currentTime, time); } private void enterReady() { From 8652eec39b045a34c7eb7c1e2c299dea78db6a2a Mon Sep 17 00:00:00 2001 From: theEvilReaper Date: Tue, 11 Aug 2026 10:55:06 +0200 Subject: [PATCH 12/13] chore(colors): improve progressbar creation --- .../cygnus/stamina/StaminaColors.java | 21 ++++++++++++++----- 1 file changed, 16 insertions(+), 5 deletions(-) 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 65ee831f..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,15 +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, int maxTime) { - Component progressBar = Components.getProgressBar(currentTime, maxTime, maxTime, 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); } From 46f5f85e4d52dfde2fb8eb8de6d3727d57d67163 Mon Sep 17 00:00:00 2001 From: theEvilReaper Date: Tue, 11 Aug 2026 10:58:16 +0200 Subject: [PATCH 13/13] test(stamina): update integration test --- .../stamina/SlenderBarIntegrationTest.java | 27 +++++++++++++++++++ 1 file changed, 27 insertions(+) diff --git a/game/src/test/java/net/onelitefeather/cygnus/stamina/SlenderBarIntegrationTest.java b/game/src/test/java/net/onelitefeather/cygnus/stamina/SlenderBarIntegrationTest.java index e863ff49..482a974e 100644 --- a/game/src/test/java/net/onelitefeather/cygnus/stamina/SlenderBarIntegrationTest.java +++ b/game/src/test/java/net/onelitefeather/cygnus/stamina/SlenderBarIntegrationTest.java @@ -82,6 +82,33 @@ void testRegenerationStopsAtFullBar(@NotNull Env env) { 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();