fix(heights): restore login stature silently (#2)
Release / release (push) Successful in 1m30s
CI / build (push) Successful in 56s

Closes OpenProject #401.
This commit was merged in pull request #2.
This commit is contained in:
dmg committed 2026-10-17 04:59:28 +00:00
commit 83f0ea9a6e
4 files changed
+144 -15

No files matched your search

@@ -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) {
@@ -18,11 +18,11 @@ import org.bukkit.inventory.Recipe;
public final class StatureListener implements Listener {
private final PotionRecipes potions;
private final BiConsumer<Player, StaturePotion> drink;
private final Consumer<Player> resume;
private final BiConsumer<Player, Boolean> resume;
private final Consumer<Runnable> nextTick;
public StatureListener(PotionRecipes potions, BiConsumer<Player, StaturePotion> drink,
Consumer<Player> resume, Consumer<Runnable> nextTick) {
BiConsumer<Player, Boolean> resume, Consumer<Runnable> 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));
}
}
@@ -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<String> 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());
});
}
}
@@ -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<StaturePotion> 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<Boolean> notificationModes = new ArrayList<>();
List<Runnable> 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);
}
}