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: + *

+ * 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")); + } +}