diff --git a/src/main/java/games/dmg/spigotheights/BukkitStatureService.java b/src/main/java/games/dmg/spigotheights/BukkitStatureService.java index 264e347..744c47f 100644 --- a/src/main/java/games/dmg/spigotheights/BukkitStatureService.java +++ b/src/main/java/games/dmg/spigotheights/BukkitStatureService.java @@ -82,6 +82,10 @@ public final class BukkitStatureService implements AutoCloseable { } public void resume(Player player) { + resume(player, true); + } + + public void resume(Player player, boolean notify) { UUID id = player.getUniqueId(); queue.submit(() -> { requireOnline(id); @@ -89,7 +93,7 @@ public final class BukkitStatureService implements AutoCloseable { Instant now = Instant.now(); return () -> stature.resume(id, limits, now); }, (result, error) -> { - if (error == null) { applyRestoration(id, result); } else { report(id, error); } + if (error == null) { applyRestoration(id, result, notify); } else { report(id, error); } }); } @@ -171,8 +175,12 @@ public final class BukkitStatureService implements AutoCloseable { } private void applyRestoration(UUID id, double value) { + applyRestoration(id, value, true); + } + + private void applyRestoration(UUID id, double value, boolean notify) { HeightStore.Sequence pending = store.sequence(id); - if (applyOnline(id, value) && pending != null && !Instant.now().isBefore(pending.expiresAt())) { + if (applyOnline(id, value, notify) && pending != null && !Instant.now().isBefore(pending.expiresAt())) { worker.execute(() -> { try { stature.acknowledgeRestoration(id, pending); @@ -184,16 +192,30 @@ public final class BukkitStatureService implements AutoCloseable { } private boolean applyOnline(UUID id, double value) { + return applyOnline(id, value, true); + } + + private boolean applyOnline(UUID id, double value, boolean notify) { Player player = plugin.getServer().getPlayer(id); - if (plugin.isEnabled() && player != null && player.isOnline() && !player.isDead()) { - AttributeInstance attribute = player.getAttribute(Attribute.SCALE); - if (attribute != null) { - attribute.setBaseValue(value); - player.sendMessage("Your scale is now " + value + "."); - return true; - } + return plugin.isEnabled() && applyToPlayer(player, value, notify); + } + + static boolean applyToPlayer(Player player, double value, boolean notify) { + if (player == null || !player.isOnline() || player.isDead()) { + return false; } - return false; + return applyScale(player, player.getAttribute(Attribute.SCALE), value, notify); + } + + static boolean applyScale(Player player, AttributeInstance attribute, double value, boolean notify) { + if (attribute == null) { + return false; + } + attribute.setBaseValue(value); + if (notify) { + player.sendMessage("Your scale is now " + value + "."); + } + return true; } private void report(UUID id, Exception error) { diff --git a/src/main/java/games/dmg/spigotheights/StatureListener.java b/src/main/java/games/dmg/spigotheights/StatureListener.java index 3963a38..5833284 100644 --- a/src/main/java/games/dmg/spigotheights/StatureListener.java +++ b/src/main/java/games/dmg/spigotheights/StatureListener.java @@ -18,11 +18,11 @@ import org.bukkit.inventory.Recipe; public final class StatureListener implements Listener { private final PotionRecipes potions; private final BiConsumer drink; - private final Consumer resume; + private final BiConsumer resume; private final Consumer nextTick; public StatureListener(PotionRecipes potions, BiConsumer drink, - Consumer resume, Consumer nextTick) { + BiConsumer resume, Consumer nextTick) { this.potions = potions; this.drink = drink; this.resume = resume; @@ -54,11 +54,11 @@ public final class StatureListener implements Listener { @EventHandler public void onJoin(PlayerJoinEvent event) { - resume.accept(event.getPlayer()); + resume.accept(event.getPlayer(), false); } @EventHandler public void onRespawn(PlayerRespawnEvent event) { - nextTick.accept(() -> resume.accept(event.getPlayer())); + nextTick.accept(() -> resume.accept(event.getPlayer(), true)); } } diff --git a/src/test/java/games/dmg/spigotheights/BukkitStatureServiceTest.java b/src/test/java/games/dmg/spigotheights/BukkitStatureServiceTest.java new file mode 100644 index 0000000..c69e968 --- /dev/null +++ b/src/test/java/games/dmg/spigotheights/BukkitStatureServiceTest.java @@ -0,0 +1,84 @@ +package games.dmg.spigotheights; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertTrue; + +import java.nio.file.Path; +import java.time.Instant; +import java.util.ArrayList; +import java.util.List; +import java.util.UUID; +import org.bukkit.attribute.AttributeInstance; +import org.bukkit.entity.Player; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.io.TempDir; + +class BukkitStatureServiceTest { + @TempDir + Path directory; + + @Test + void silentRestorationKeepsStoredClampingAndExpiredBaselineWithoutFeedback() throws Exception { + HeightSettings settings = new HeightSettings(0.4, 2.0, 0.1, 0.5, 1.5, 20); + HeightStore store = new HeightStore(directory.toFile()); + TemporaryStature stature = new TemporaryStature(store); + PlayerFixture player = new PlayerFixture(); + + UUID saved = UUID.randomUUID(); + store.save(saved, 3.0); + assertTrue(BukkitStatureService.applyScale( + player.player, player.attribute, stature.resume(saved, settings, Instant.now()), false)); + assertEquals(2.0, player.scale); + assertEquals(List.of(), player.messages); + + UUID expired = UUID.randomUUID(); + Instant start = Instant.parse("2026-01-01T00:00:00Z"); + stature.apply(expired, 1.4, StaturePotion.DIMINUTION, settings, start, bound -> 0); + assertTrue(BukkitStatureService.applyScale(player.player, player.attribute, + stature.resume(expired, settings, start.plusSeconds(301)), false)); + assertEquals(1.4, player.scale); + assertEquals(List.of(), player.messages); + } + + @Test + void notifyingApplicationPreservesPotionAdministratorAndRespawnFeedback() { + PlayerFixture player = new PlayerFixture(); + + assertTrue(BukkitStatureService.applyScale(player.player, player.attribute, 1.2, true)); + assertEquals(1.2, player.scale); + assertEquals(List.of("Your scale is now 1.2."), player.messages); + } + + @Test + void unavailablePlayerStillRefusesRestorationWithoutFeedback() { + Player offline = BukkitDoubles.proxy(Player.class, (proxy, method, args) -> { + if (method.getName().equals("isOnline")) { + return false; + } + throw new AssertionError("Offline player should not be changed: " + method.getName()); + }); + + assertFalse(BukkitStatureService.applyToPlayer(offline, 0.8, false)); + assertFalse(BukkitStatureService.applyToPlayer(null, 0.8, true)); + assertFalse(BukkitStatureService.applyScale(offline, null, 0.8, true)); + } + + private static final class PlayerFixture { + private double scale = 1.0; + private final List messages = new ArrayList<>(); + private final AttributeInstance attribute = BukkitDoubles.proxy( + AttributeInstance.class, (proxy, method, args) -> switch (method.getName()) { + case "setBaseValue" -> { scale = (double) args[0]; yield null; } + case "getBaseValue" -> scale; + default -> throw new UnsupportedOperationException(method.getName()); + }); + private final Player player = BukkitDoubles.proxy(Player.class, (proxy, method, args) -> { + if (method.getName().equals("sendMessage")) { + messages.add((String) args[0]); + return null; + } + throw new UnsupportedOperationException(method.getName()); + }); + } +} diff --git a/src/test/java/games/dmg/spigotheights/StatureListenerTest.java b/src/test/java/games/dmg/spigotheights/StatureListenerTest.java index f688f0b..4f918d6 100644 --- a/src/test/java/games/dmg/spigotheights/StatureListenerTest.java +++ b/src/test/java/games/dmg/spigotheights/StatureListenerTest.java @@ -3,10 +3,13 @@ package games.dmg.spigotheights; import static org.junit.jupiter.api.Assertions.assertEquals; import java.util.ArrayList; import java.util.List; +import org.bukkit.Location; import org.bukkit.Material; import org.bukkit.NamespacedKey; import org.bukkit.entity.Player; import org.bukkit.event.player.PlayerItemConsumeEvent; +import org.bukkit.event.player.PlayerJoinEvent; +import org.bukkit.event.player.PlayerRespawnEvent; import org.bukkit.inventory.EquipmentSlot; import org.junit.jupiter.api.Test; @@ -16,7 +19,8 @@ class StatureListenerTest { PotionRecipes potions = new PotionRecipes(null, new NamespacedKey("spigotheights", "stature_potion"), BukkitDoubles.Item::new); List drinks = new ArrayList<>(); - StatureListener listener = new StatureListener(potions, (player, kind) -> drinks.add(kind), player -> {}, Runnable::run); + StatureListener listener = new StatureListener(potions, (player, kind) -> drinks.add(kind), + (player, notify) -> {}, Runnable::run); Player player = ThrowableStatureListenerTest.entity(Player.class, null); var cancelled = new PlayerItemConsumeEvent(player, potions.create(StaturePotion.GROWTH), EquipmentSlot.HAND); cancelled.setCancelled(true); @@ -29,4 +33,23 @@ class StatureListenerTest { listener.onConsume(cancelled); assertEquals(List.of(StaturePotion.GROWTH), drinks); } + + @Test + void loginRestorationIsSilentWhileRespawnRestorationStillNotifies() { + PotionRecipes potions = new PotionRecipes(null, new NamespacedKey("spigotheights", "stature_potion"), + BukkitDoubles.Item::new); + List notificationModes = new ArrayList<>(); + List nextTick = new ArrayList<>(); + StatureListener listener = new StatureListener(potions, (player, kind) -> {}, + (player, notify) -> notificationModes.add(notify), nextTick::add); + Player player = ThrowableStatureListenerTest.entity(Player.class, null); + + listener.onJoin(new PlayerJoinEvent(player, "joined")); + assertEquals(List.of(false), notificationModes); + + listener.onRespawn(new PlayerRespawnEvent(player, new Location(null, 0, 0, 0), false)); + assertEquals(1, nextTick.size()); + nextTick.getFirst().run(); + assertEquals(List.of(false, true), notificationModes); + } }