diff --git a/src/main/java/net/tfminecraft/cooking/Cooking.java b/src/main/java/net/tfminecraft/cooking/Cooking.java index d6f2aa6..299d907 100644 --- a/src/main/java/net/tfminecraft/cooking/Cooking.java +++ b/src/main/java/net/tfminecraft/cooking/Cooking.java @@ -1,8 +1,10 @@ package net.tfminecraft.cooking; import java.io.File; +import java.util.logging.Logger; import org.bukkit.Bukkit; +import org.bukkit.plugin.Plugin; import org.bukkit.plugin.java.JavaPlugin; import net.tfminecraft.tlibs.TLibs; @@ -167,6 +169,7 @@ public void onEnable() { butterChurnHandler.resumeLoadedChurns(); liquidContainerHandler.resumeLoadedContainers(); meatHookHandler.resumeLoadedHooks(); + warnIfCharacterIdentityMissing(getLogger()); SaturationGuard.start(); NutritionDrainTask.start(); Bukkit.getOnlinePlayers().forEach(NutritionLifecycleListener::initializeCurrent); @@ -187,6 +190,16 @@ public void onEnable() { } } + /** Runs once every plugin has enabled, when RPCharacters 2.16.0+ has registered its provider. */ + static void warnIfCharacterIdentityMissing(Logger logger) { + Plugin characters = Bukkit.getPluginManager().getPlugin("RPCharacters"); + if (characters == null || !characters.isEnabled() || Characters.available()) return; + logger.warning("RPCharacters " + characters.getPluginMeta().getVersion() + + " provides no character identity; Cooking needs RPCharacters 2.16.0 or newer." + + " Until then food and drink give no nutrition, hunger does not drain and the" + + " hunger bar stays frozen."); + } + @Override public void onDisable() { if (incompatibleCharacterApi) return; diff --git a/src/main/java/net/tfminecraft/cooking/cup/DrinkConsumeListener.java b/src/main/java/net/tfminecraft/cooking/cup/DrinkConsumeListener.java index b4198c9..7f0ac07 100644 --- a/src/main/java/net/tfminecraft/cooking/cup/DrinkConsumeListener.java +++ b/src/main/java/net/tfminecraft/cooking/cup/DrinkConsumeListener.java @@ -34,7 +34,7 @@ public void onMilkBucket(PlayerItemConsumeEvent event) { event.setCancelled(true); Player player = event.getPlayer(); - PlayerActionContext context = PlayerActionContext.capture(player); + PlayerActionContext context = PlayerActionContext.captureOrRefuse(player); if (context == null) { return; } @@ -48,6 +48,18 @@ public void onMilkBucket(PlayerItemConsumeEvent event) { } } + /** Keeps the cup when there is no character to drink it, like refused food. */ + @EventHandler(priority = EventPriority.HIGH, ignoreCancelled = true) + public void onCupWithoutCharacter(PlayerItemConsumeEvent event) { + ItemStack consumed = event.getItem(); + if (consumed == null || !ItemCache.isCupOfWater(consumed) && !ItemCache.isCupOfMilk(consumed)) { + return; + } + if (PlayerActionContext.captureOrRefuse(event.getPlayer()) == null) { + event.setCancelled(true); + } + } + @EventHandler(priority = EventPriority.MONITOR, ignoreCancelled = true) public void onDrink(PlayerItemConsumeEvent event) { ItemStack consumed = event.getItem(); diff --git a/src/main/java/net/tfminecraft/cooking/farming/FarmHarvestService.java b/src/main/java/net/tfminecraft/cooking/farming/FarmHarvestService.java index 393215d..2a5615f 100644 --- a/src/main/java/net/tfminecraft/cooking/farming/FarmHarvestService.java +++ b/src/main/java/net/tfminecraft/cooking/farming/FarmHarvestService.java @@ -39,7 +39,7 @@ public static void harvestArea( if (player == null || tool == null || hoe == null || centre == null) { return; } - PlayerActionContext context = PlayerActionContext.capture(player); + PlayerActionContext context = PlayerActionContext.captureOrRefuse(player); if (context == null) { return; } diff --git a/src/main/java/net/tfminecraft/cooking/manager/CommandManager.java b/src/main/java/net/tfminecraft/cooking/manager/CommandManager.java index db121be..a816031 100644 --- a/src/main/java/net/tfminecraft/cooking/manager/CommandManager.java +++ b/src/main/java/net/tfminecraft/cooking/manager/CommandManager.java @@ -309,9 +309,17 @@ private boolean handleFood(CommandSender sender, String[] args) { + " mappedAfter=" + NutritionDisplayService.toFoodLevel(character.getFoodValue()) + " hudBefore=" + target.getFoodLevel() + " saturation=" + target.getSaturation()); - if (!context.save()) return true; + if (!context.save()) { + sender.sendMessage("§cCould not confirm " + target.getName() + + "'s food was saved: they switched character."); + return true; + } NutritionDisplayService.sync(context, "admin"); - if (!context.isCurrent()) return true; + if (!context.isCurrent()) { + sender.sendMessage("§c" + target.getName() + + "'s food was saved, but they switched character before their hunger bar updated."); + return true; + } NutritionLog.append("SAVE", target, character, "reason=admin sender=" + sender.getName()); sender.sendMessage("§aSet " + target.getName() + "'s food from " + before + " to " diff --git a/src/main/java/net/tfminecraft/cooking/nutrition/BowlEatHandler.java b/src/main/java/net/tfminecraft/cooking/nutrition/BowlEatHandler.java index 5257845..c01c905 100644 --- a/src/main/java/net/tfminecraft/cooking/nutrition/BowlEatHandler.java +++ b/src/main/java/net/tfminecraft/cooking/nutrition/BowlEatHandler.java @@ -65,7 +65,7 @@ public void onInteract(FurnitureInteractEvent event) { player.sendMessage("§cThis needs to be prepared before you can eat it."); return; } - PlayerActionContext context = PlayerActionContext.capture(player); + PlayerActionContext context = PlayerActionContext.captureOrRefuse(player); if (context == null) return; food.updateAge(); if (!context.isCurrent()) return; diff --git a/src/main/java/net/tfminecraft/cooking/nutrition/FoodConsumeListener.java b/src/main/java/net/tfminecraft/cooking/nutrition/FoodConsumeListener.java index c8d6c0f..0750c28 100644 --- a/src/main/java/net/tfminecraft/cooking/nutrition/FoodConsumeListener.java +++ b/src/main/java/net/tfminecraft/cooking/nutrition/FoodConsumeListener.java @@ -38,7 +38,7 @@ public void onConsume(PlayerItemConsumeEvent event) { return; } - PlayerActionContext context = PlayerActionContext.capture(event.getPlayer()); + PlayerActionContext context = PlayerActionContext.captureOrRefuse(event.getPlayer()); if (context == null) { event.setCancelled(true); return; diff --git a/src/main/java/net/tfminecraft/cooking/nutrition/NutritionAttributeBridge.java b/src/main/java/net/tfminecraft/cooking/nutrition/NutritionAttributeBridge.java index a0019c0..0bd50a4 100644 --- a/src/main/java/net/tfminecraft/cooking/nutrition/NutritionAttributeBridge.java +++ b/src/main/java/net/tfminecraft/cooking/nutrition/NutritionAttributeBridge.java @@ -50,19 +50,17 @@ public static void apply(NutritionContext context) { for (int i = 0; i < resources.length; i++) { before[i] = resources[i].getCurrent(data); } - if (!context.isCurrent()) return; + // setBase and setCurrent run MMOCore stat updates and resource events, which other plugins see. instance.setBase(diet); if (!context.isCurrent()) return; double healthCap = Math.min(health, player.getAttribute(Attribute.MAX_HEALTH).getValue()); if (player.getHealth() < healthCap) { - if (!context.isCurrent()) return; player.setHealth(healthCap); } for (int i = 0; i < resources.length; i++) { if (!context.isCurrent()) return; double cap = Math.min(before[i], resources[i].getMax(data)); if (resources[i].getCurrent(data) < cap) { - if (!context.isCurrent()) return; resources[i].setCurrent(data, cap, ResourceUpdateReason.CLAMPING); } } diff --git a/src/main/java/net/tfminecraft/cooking/nutrition/NutritionDisplayService.java b/src/main/java/net/tfminecraft/cooking/nutrition/NutritionDisplayService.java index 9964e4e..ed17d24 100644 --- a/src/main/java/net/tfminecraft/cooking/nutrition/NutritionDisplayService.java +++ b/src/main/java/net/tfminecraft/cooking/nutrition/NutritionDisplayService.java @@ -52,13 +52,10 @@ public static void sync(NutritionContext context, String reason) { int foodBefore = player.getFoodLevel(); float saturationBefore = player.getSaturation(); boolean wroteFood = foodBefore != mapped; - if (!context.isCurrent()) return; if (wroteFood) { player.setFoodLevel(mapped); } - if (!context.isCurrent()) return; player.setSaturation(0f); - if (!context.isCurrent()) return; NutritionLog.append("SYNC", player, character, "reason=" + reason + " mapped=" + mapped diff --git a/src/main/java/net/tfminecraft/cooking/nutrition/NutritionLifecycleListener.java b/src/main/java/net/tfminecraft/cooking/nutrition/NutritionLifecycleListener.java index 04cbd73..a4a296f 100644 --- a/src/main/java/net/tfminecraft/cooking/nutrition/NutritionLifecycleListener.java +++ b/src/main/java/net/tfminecraft/cooking/nutrition/NutritionLifecycleListener.java @@ -1,5 +1,8 @@ package net.tfminecraft.cooking.nutrition; +import java.util.HashMap; +import java.util.Map; + import org.bukkit.entity.Player; import org.bukkit.event.EventHandler; import org.bukkit.event.EventPriority; @@ -12,6 +15,8 @@ public final class NutritionLifecycleListener implements Listener { + private final Map dying = new HashMap<>(); + @EventHandler(priority = EventPriority.MONITOR) public void onCharacterStart(CharacterSessionStartEvent event) { NutritionContext context = NutritionContext.capture(event.getPlayer()); @@ -45,17 +50,28 @@ public void onPlayerRespawn(PlayerRespawnEvent event) { NutritionDisplayService.syncFromPlayer(player, "respawn"); } - @EventHandler(priority = EventPriority.MONITOR, ignoreCancelled = true) + /** A permakill switches to the replacement at MONITOR, so remember who actually died. */ + @EventHandler(priority = EventPriority.LOWEST, ignoreCancelled = true) + public void captureDeath(PlayerDeathEvent event) { + NutritionContext context = NutritionContext.capture(event.getEntity()); + if (context != null) dying.put(event.getEntity(), context); + } + + @EventHandler(priority = EventPriority.MONITOR) public void onPlayerDeath(PlayerDeathEvent event) { Player player = event.getEntity(); - NutritionContext context = NutritionContext.capture(player); + NutritionContext context = dying.remove(player); + if (event.isCancelled()) return; if (context == null) { NutritionLog.append("DEATH", player, null, "action=skip cause=no-active-character"); return; } RPCharacter character = context.character(); boolean inBattle = BattleFoodGate.inStartedBattle(player); - if (!context.isCurrent()) return; + if (!context.isCurrent()) { + NutritionLog.append("DEATH", player, character, "action=skip cause=character-ended"); + return; + } if (inBattle) { NutritionLog.append("DEATH", player, character, "hud=" + player.getFoodLevel() + " saturation=" + player.getSaturation() diff --git a/src/main/java/net/tfminecraft/cooking/nutrition/SaturationGuard.java b/src/main/java/net/tfminecraft/cooking/nutrition/SaturationGuard.java index dd6b3c3..2dc507a 100644 --- a/src/main/java/net/tfminecraft/cooking/nutrition/SaturationGuard.java +++ b/src/main/java/net/tfminecraft/cooking/nutrition/SaturationGuard.java @@ -6,7 +6,6 @@ import org.bukkit.event.EventPriority; import org.bukkit.event.Listener; import org.bukkit.event.entity.FoodLevelChangeEvent; -import org.bukkit.event.player.PlayerJoinEvent; import net.tfminecraft.rpcharacters.objects.RPCharacter; import net.tfminecraft.cooking.utils.PlayerActionContext; @@ -41,16 +40,6 @@ public static void stop() { taskId = -1; } - @EventHandler - public void onJoin(PlayerJoinEvent event) { - Player player = event.getPlayer(); - if (PlayerActionContext.capture(player) == null) return; - float before = player.getSaturation(); - player.setSaturation(0f); - NutritionLog.append("SATURATION", player, null, - "reason=join before=" + before + " after=" + player.getSaturation()); - } - @EventHandler(priority = EventPriority.MONITOR, ignoreCancelled = true) public void onFoodLevelChange(FoodLevelChangeEvent event) { if (!(event.getEntity() instanceof Player player)) { diff --git a/src/main/java/net/tfminecraft/cooking/sausagemaker/SausageMakerHandler.java b/src/main/java/net/tfminecraft/cooking/sausagemaker/SausageMakerHandler.java index fdce215..757fc6b 100644 --- a/src/main/java/net/tfminecraft/cooking/sausagemaker/SausageMakerHandler.java +++ b/src/main/java/net/tfminecraft/cooking/sausagemaker/SausageMakerHandler.java @@ -67,7 +67,7 @@ public void onInteract(FurnitureInteractEvent event) { } Player player = event.getPlayer(); - PlayerActionContext context = PlayerActionContext.capture(player); + PlayerActionContext context = PlayerActionContext.captureOrRefuse(player); if (context == null) { event.setCancelled(true); return; diff --git a/src/main/java/net/tfminecraft/cooking/utils/PlayerActionContext.java b/src/main/java/net/tfminecraft/cooking/utils/PlayerActionContext.java index 4cd4360..a9ba6d3 100644 --- a/src/main/java/net/tfminecraft/cooking/utils/PlayerActionContext.java +++ b/src/main/java/net/tfminecraft/cooking/utils/PlayerActionContext.java @@ -1,5 +1,10 @@ package net.tfminecraft.cooking.utils; +import java.lang.ref.WeakReference; +import java.util.HashMap; +import java.util.Map; +import java.util.UUID; + import org.bukkit.Bukkit; import org.bukkit.entity.Player; import net.tfminecraft.tlibs.character.CharacterSession; @@ -7,6 +12,13 @@ /** One player action, including standalone play without a character provider. */ public record PlayerActionContext(Player player, CharacterSession session) { + public static final String NO_CHARACTER_MESSAGE = "§cYou need an active character to do that."; + private static final int REFUSAL_COOLDOWN_TICKS = 60; + private static final Map lastRefusal = new HashMap<>(); + + /** Per connection, so a rejoin is told again; weak so it never holds an offline player. */ + private record Refusal(WeakReference player, int tick) {} + public static PlayerActionContext capture(Player player) { if (player == null) return null; CharacterSession session = Characters.currentSession(player).orElse(null); @@ -15,6 +27,22 @@ public static PlayerActionContext capture(Player player) { return context.isCurrent() ? context : null; } + /** Captures for an action the player started, telling them (at most every 3 s) when it is refused. */ + public static PlayerActionContext captureOrRefuse(Player player) { + PlayerActionContext context = capture(player); + if (context == null && player != null) { + int now = Bukkit.getCurrentTick(); + Refusal last = lastRefusal.get(player.getUniqueId()); + if (last == null || last.player().get() != player || now - last.tick() >= REFUSAL_COOLDOWN_TICKS) { + // Only refusals still inside the cooldown matter, so the map holds at most a few seconds' worth. + lastRefusal.values().removeIf(old -> now - old.tick() >= REFUSAL_COOLDOWN_TICKS); + lastRefusal.put(player.getUniqueId(), new Refusal(new WeakReference<>(player), now)); + player.sendMessage(NO_CHARACTER_MESSAGE); + } + } + return context; + } + public boolean isCurrent() { return player.isOnline() && Bukkit.getPlayer(player.getUniqueId()) == player && (session == null ? !Characters.available() diff --git a/src/test/java/net/tfminecraft/cooking/CookingLifecycleTest.java b/src/test/java/net/tfminecraft/cooking/CookingLifecycleTest.java index 38dd462..c946510 100644 --- a/src/test/java/net/tfminecraft/cooking/CookingLifecycleTest.java +++ b/src/test/java/net/tfminecraft/cooking/CookingLifecycleTest.java @@ -33,6 +33,7 @@ import net.tfminecraft.cooking.oven.*; import net.tfminecraft.cooking.sausagemaker.SausageMakerHandler; import net.tfminecraft.cooking.trough.*; +import net.tfminecraft.cooking.testsupport.TestCharacters; import net.tfminecraft.tlibs.TLibs; import net.tfminecraft.tlibs.itemscan.ItemScanService; import net.tfminecraft.tlibs.objects.api.ItemAPI; @@ -154,6 +155,26 @@ class CookingLifecycleTest { verify(built(PlateManager.class), never()).start(); } + @Test void startupWarnsWhenAnEnabledRPCharactersRegistersNoCharacterIdentity() { + java.util.logging.Logger logger = mock(java.util.logging.Logger.class); + Cooking.warnIfCharacterIdentityMissing(logger); + var characters = MockBukkit.createMockPlugin("RPCharacters"); + server.getPluginManager().disablePlugin(characters); + Cooking.warnIfCharacterIdentityMissing(logger); + verifyNoInteractions(logger); + + server.getPluginManager().enablePlugin(characters); + Cooking.warnIfCharacterIdentityMissing(logger); + verify(logger).warning(argThat((String message) -> message.startsWith("RPCharacters " + + characters.getPluginMeta().getVersion() + " provides no character identity") + && message.contains("RPCharacters 2.16.0 or newer") && message.contains("hunger bar stays frozen"))); + + try (TestCharacters identities = new TestCharacters(characters)) { + Cooking.warnIfCharacterIdentityMissing(logger); + } + verifyNoMoreInteractions(logger); + } + @Test void nextTickResumesLoadedStateAndShutdownReleasesScansTasksDatabaseAndPathHandler() { ItemScanService scan = mock(ItemScanService.class); scans.when(ItemScanService::get).thenReturn(scan); diff --git a/src/test/java/net/tfminecraft/cooking/farming/FarmingCoverageTest.java b/src/test/java/net/tfminecraft/cooking/farming/FarmingCoverageTest.java index 599c35c..48ac9ff 100644 --- a/src/test/java/net/tfminecraft/cooking/farming/FarmingCoverageTest.java +++ b/src/test/java/net/tfminecraft/cooking/farming/FarmingCoverageTest.java @@ -1,5 +1,7 @@ package net.tfminecraft.cooking.farming; +import net.tfminecraft.cooking.utils.PlayerActionContext; + import static org.junit.jupiter.api.Assertions.*; import static org.mockito.ArgumentMatchers.*; import static org.mockito.Mockito.*; @@ -140,6 +142,7 @@ public class FarmingCoverageTest { f.on(BlockBreakEvent.class, event -> breaks.incrementAndGet()); FarmHarvestService.harvestArea(f.player, f.tool(), hoe(), crop); assertEquals(Material.WHEAT, crop.getType()); + assertEquals(PlayerActionContext.NO_CHARACTER_MESSAGE, f.player.nextMessage()); assertEquals(0, breaks.get()); assertEquals(0, ((Damageable) f.tool().getItemMeta()).getDamage()); assertEquals(0, f.inventoryCount(Material.WHEAT)); diff --git a/src/test/java/net/tfminecraft/cooking/manager/CommandManagerCoverageTest.java b/src/test/java/net/tfminecraft/cooking/manager/CommandManagerCoverageTest.java index 0c6010d..5592db8 100644 --- a/src/test/java/net/tfminecraft/cooking/manager/CommandManagerCoverageTest.java +++ b/src/test/java/net/tfminecraft/cooking/manager/CommandManagerCoverageTest.java @@ -200,14 +200,15 @@ class CommandManagerCoverageTest { }).when(manager).savePlayer(player); run("food", "Admin", "100"); assertEquals(100, character.getFoodValue()); - assertTrue(messages().isEmpty()); + assertEquals(List.of("§cCould not confirm Admin's food was saved: they switched character."), messages()); display.verify(() -> NutritionDisplayService.sync(any(net.tfminecraft.cooking.nutrition.NutritionContext.class), anyString()), never()); doNothing().when(manager).savePlayer(player); display.when(() -> NutritionDisplayService.sync(any(net.tfminecraft.cooking.nutrition.NutritionContext.class), eq("admin"))) .thenAnswer(call -> { identities.set(player, null); return null; }); run("food", "Admin", "120"); assertEquals(120, character.getFoodValue()); - assertTrue(messages().isEmpty()); + assertEquals(List.of("§cAdmin's food was saved, but they switched character before their hunger bar updated."), + messages()); run("food", "Admin", "130"); assertEquals(120, character.getFoodValue()); assertEquals(List.of("§cThat player has no active character."), messages()); diff --git a/src/test/java/net/tfminecraft/cooking/nutrition/NutritionListenersCoverageTest.java b/src/test/java/net/tfminecraft/cooking/nutrition/NutritionListenersCoverageTest.java index 1a095eb..9f5a782 100644 --- a/src/test/java/net/tfminecraft/cooking/nutrition/NutritionListenersCoverageTest.java +++ b/src/test/java/net/tfminecraft/cooking/nutrition/NutritionListenersCoverageTest.java @@ -25,6 +25,7 @@ import net.tfminecraft.cooking.loader.TrackLoader; import net.tfminecraft.cooking.manager.PlateManager; import net.tfminecraft.cooking.testsupport.TestCharacters; +import net.tfminecraft.cooking.utils.PlayerActionContext; import net.tfminecraft.cooking.utils.Keys; import net.tfminecraft.interactiblefurniture.InteractibleFurniture; import net.tfminecraft.interactiblefurniture.events.FurnitureInteractEvent; @@ -50,7 +51,6 @@ import org.bukkit.event.entity.FoodLevelChangeEvent; import org.bukkit.event.entity.PlayerDeathEvent; 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.bukkit.inventory.ItemStack; @@ -125,15 +125,15 @@ class NutritionListenersCoverageTest { listener.onCharacterStart(new CharacterSessionStartEvent(player,characters.activate(player,character.getId()))); assertEquals(20,character.getDietScore()); assertNotNull(character.getLastDietTierId()); PlayerDeathEvent death=mock(PlayerDeathEvent.class); when(death.getEntity()).thenReturn(player); - rp.when(()->RPCharacters.getActiveCharacter(player)).thenReturn(null); listener.onPlayerDeath(death); + rp.when(()->RPCharacters.getActiveCharacter(player)).thenReturn(null); die(listener,death); assertEquals(50,character.getFoodValue()); rp.when(()->RPCharacters.getActiveCharacter(player)).thenReturn(character); - listener.onPlayerDeath(death); assertEquals(80,character.getFoodValue()); + die(listener,death); assertEquals(80,character.getFoodValue()); MockBukkit.createMockPlugin("SimpleFactions"); Battle battle=mock(Battle.class); when(battle.hasStarted()).thenReturn(true); when(battle.getSideByMemberId(player.getUniqueId())).thenReturn(mock(BattleSide.class)); BattleManager.addBattle(battle); - character.setFoodValue(15); listener.onPlayerDeath(death); assertEquals(15,character.getFoodValue()); + character.setFoodValue(15); die(listener,death); assertEquals(15,character.getFoodValue()); } @Test void battleLookupRequiresAnEnabledStartedBattleAndToleratesUnavailableIntegrations() { @@ -152,10 +152,8 @@ class NutritionListenersCoverageTest { server.getPluginManager().disablePlugin(factions); assertFalse(BattleFoodGate.inStartedBattle(player)); } - @Test void saturationGuardsHandleJoinEventsAndOnePeriodicTask() { + @Test void saturationGuardsHandleFoodEventsAndOnePeriodicTask() { SaturationGuard guard=new SaturationGuard(); - player.setSaturation(5); guard.onJoin(new PlayerJoinEvent(player,"joined")); - assertEquals(0,player.getSaturation()); guard.onFoodLevelChange(new FoodLevelChangeEvent(mock(HumanEntity.class),4)); player.setSaturation(4); guard.onFoodLevelChange(new FoodLevelChangeEvent(player,4)); assertEquals(4,player.getSaturation()); server.getScheduler().performOneTick(); assertEquals(0,player.getSaturation()); @@ -354,7 +352,7 @@ class NutritionListenersCoverageTest { PlayerDeathEvent death = mock(PlayerDeathEvent.class); when(death.getEntity()).thenReturn(player); PlayerRespawnEvent respawn = mock(PlayerRespawnEvent.class); when(respawn.getPlayer()).thenReturn(player); - listener.onPlayerDeath(death); listener.onPlayerRespawn(respawn); + die(listener, death); listener.onPlayerRespawn(respawn); assertEquals(15, character.getFoodValue()); assertEquals(17, player.getFoodLevel()); assertEquals(6, player.getSaturation()); verifyNoInteractions(players); @@ -370,11 +368,47 @@ class NutritionListenersCoverageTest { characters.activate(player, character.getId()); return false; }); - new NutritionLifecycleListener().onPlayerDeath(death); + die(new NutritionLifecycleListener(), death); } assertEquals(15, character.getFoodValue()); verifyNoInteractions(players); } + @Test void permakillResetsNeitherTheDeadCharacterNorItsReplacement() { + MockBukkit.createMockPlugin("RPCharacters"); + character.setFoodValue(15); + RPCharacter replacement = new RPCharacter(player); replacement.setFoodValue(90); + NutritionLifecycleListener listener = new NutritionLifecycleListener(); + PlayerDeathEvent death = mock(PlayerDeathEvent.class); when(death.getEntity()).thenReturn(player); + + listener.captureDeath(death); + // RPCharacters' permakill switches to the replacement at MONITOR, before Cooking's handler. + rp.when(() -> RPCharacters.getActiveCharacter(player)).thenReturn(replacement); + characters.activate(player, replacement.getId()); + listener.onPlayerDeath(death); + + assertEquals(15, character.getFoodValue()); assertEquals(90, replacement.getFoodValue()); + verifyNoInteractions(players); + // The next ordinary death resets the replacement as usual. + die(listener, death); + assertEquals(80, replacement.getFoodValue()); verify(players).savePlayer(player); + } + + @Test void cancelledDeathsAndUncapturedDeathsLeaveFoodAlone() { + MockBukkit.createMockPlugin("RPCharacters"); + character.setFoodValue(15); + NutritionLifecycleListener listener = new NutritionLifecycleListener(); + PlayerDeathEvent death = mock(PlayerDeathEvent.class); when(death.getEntity()).thenReturn(player); + + listener.captureDeath(death); when(death.isCancelled()).thenReturn(true); listener.onPlayerDeath(death); + when(death.isCancelled()).thenReturn(false); listener.onPlayerDeath(death); + + assertEquals(15, character.getFoodValue()); verifyNoInteractions(players); + } + + private static void die(NutritionLifecycleListener listener, PlayerDeathEvent death) { + listener.captureDeath(death); listener.onPlayerDeath(death); + } + @Test void pendingIdentityDoesNotQueueFoodOrSaturationWrites() { MockBukkit.createMockPlugin("RPCharacters"); characters.set(player, null); player.setFoodLevel(17); player.setSaturation(6); @@ -594,6 +628,7 @@ void cancelledOrStaleMilkCupCallbacksCannotGrantNutritionOrReplaceItems(boolean listener.onConsume(pending); assertTrue(pending.isCancelled()); + assertEquals(PlayerActionContext.NO_CHARACTER_MESSAGE, player.nextMessage()); assertEquals(2, player.getInventory().getItemInMainHand().getAmount()); assertEquals(100, character.getFoodValue()); verifyNoInteractions(players); characters.activate(player, character.getId()); @@ -620,6 +655,7 @@ void cancelledOrStaleMilkCupCallbacksCannotGrantNutritionOrReplaceItems(boolean listener.onMilkBucket(pending); assertTrue(pending.isCancelled()); + assertEquals(PlayerActionContext.NO_CHARACTER_MESSAGE, player.nextMessage()); assertEquals(Material.MILK_BUCKET, player.getInventory().getItemInOffHand().getType()); assertEquals(100, character.getFoodValue()); verifyNoInteractions(players); characters.activate(player, character.getId()); @@ -631,6 +667,39 @@ void cancelledOrStaleMilkCupCallbacksCannotGrantNutritionOrReplaceItems(boolean assertEquals(110, character.getFoodValue()); verify(players).savePlayer(player); } + @ParameterizedTest + @ValueSource(strings = {"cup.water", "cup_of_milk"}) + void pendingCupIsKeptWithAMessageLikeRefusedFood(String cup) { + MockBukkit.createMockPlugin("RPCharacters"); + ItemStack drink = cup.equals("cup.water") ? new ItemStack(Material.POTION) : stack(cup, true, "drink", Material.POTION); + when(api.getChecker().checkItemWithPath(drink, "cup.water")).thenReturn(cup.equals("cup.water")); + when(api.getChecker().checkItemWithPath(drink, "cup.milk")).thenReturn(!cup.equals("cup.water")); + player.getInventory().setItemInMainHand(drink); + characters.set(player, null); + DrinkConsumeListener listener = new DrinkConsumeListener(); + PlayerItemConsumeEvent pending = consume(drink, EquipmentSlot.HAND); + + listener.onCupWithoutCharacter(pending); + + assertTrue(pending.isCancelled()); + assertEquals(PlayerActionContext.NO_CHARACTER_MESSAGE, player.nextMessage()); + assertEquals(drink, player.getInventory().getItemInMainHand()); + characters.activate(player, character.getId()); + PlayerItemConsumeEvent ready = consume(drink, EquipmentSlot.HAND); + listener.onCupWithoutCharacter(ready); + assertFalse(ready.isCancelled()); assertNull(player.nextMessage()); + } + + @Test void cupRefusalIgnoresOtherItems() { + characters.set(player, null); + PlayerItemConsumeEvent bread = consume(new ItemStack(Material.BREAD), EquipmentSlot.HAND); + PlayerItemConsumeEvent nothing = mock(PlayerItemConsumeEvent.class); + new DrinkConsumeListener().onCupWithoutCharacter(bread); + new DrinkConsumeListener().onCupWithoutCharacter(nothing); + assertFalse(bread.isCancelled()); verify(nothing, never()).setCancelled(anyBoolean()); + assertNull(player.nextMessage()); + } + @Test void pendingMilkCupIntakeDoesNotQueueWorkForTheNextReadySession() { MockBukkit.createMockPlugin("RPCharacters"); ItemStack milk = stack("cup_of_milk", true, "drink", Material.POTION); diff --git a/src/test/java/net/tfminecraft/cooking/nutrition/NutritionRuntimeCoverageTest.java b/src/test/java/net/tfminecraft/cooking/nutrition/NutritionRuntimeCoverageTest.java index 6e128da..876fa1c 100644 --- a/src/test/java/net/tfminecraft/cooking/nutrition/NutritionRuntimeCoverageTest.java +++ b/src/test/java/net/tfminecraft/cooking/nutrition/NutritionRuntimeCoverageTest.java @@ -343,26 +343,6 @@ class NutritionRuntimeCoverageTest { verifyNoInteractions(players); } - @Test void hudReentryCannotClearReplacementSaturationOrCarryItsSyncGuard() { - MockBukkit.createMockPlugin("RPCharacters"); - NutritionContext old = NutritionContext.capture(player); - character.setFoodValue(50); - doAnswer(call -> { - assertTrue(NutritionDisplayService.isSyncing(player)); - call.callRealMethod(); - characters.activate(player, UUID.randomUUID()); - characters.activate(player, character.getId()); - player.setSaturation(4); - assertFalse(NutritionDisplayService.isSyncing(player)); - return null; - }).when(player).setFoodLevel(anyInt()); - - NutritionDisplayService.sync(old, "reentrant"); - - assertEquals(4, player.getSaturation()); - assertFalse(NutritionDisplayService.isSyncing(player)); - } - @Test void nestedHudSyncKeepsTheOuterGuardUntilItsOwnFinallyCompletes() { MockBukkit.createMockPlugin("RPCharacters"); NutritionContext context = NutritionContext.capture(player); diff --git a/src/test/java/net/tfminecraft/cooking/sausagemaker/SausageMakerCoverageTest.java b/src/test/java/net/tfminecraft/cooking/sausagemaker/SausageMakerCoverageTest.java index 8bf2c3c..21537b4 100644 --- a/src/test/java/net/tfminecraft/cooking/sausagemaker/SausageMakerCoverageTest.java +++ b/src/test/java/net/tfminecraft/cooking/sausagemaker/SausageMakerCoverageTest.java @@ -1,5 +1,7 @@ package net.tfminecraft.cooking.sausagemaker; +import net.tfminecraft.cooking.utils.PlayerActionContext; + import static org.junit.jupiter.api.Assertions.*; import static org.mockito.ArgumentMatchers.*; import static org.mockito.Mockito.*; @@ -255,6 +257,7 @@ void pendingCharacterCannotCrankButAReadyCharacterCanFinish() { var pending = station.interact(); handler.onInteract(pending); assertTrue(pending.isCancelled()); + assertEquals(List.of(PlayerActionContext.NO_CHARACTER_MESSAGE), env.messages); assertEquals(2, casing.getAmount()); assertEquals(3, station.active.size()); assertTrue(env.outputs().isEmpty()); diff --git a/src/test/java/net/tfminecraft/cooking/utils/PlayerActionContextTest.java b/src/test/java/net/tfminecraft/cooking/utils/PlayerActionContextTest.java index 1a1110c..9ad6068 100644 --- a/src/test/java/net/tfminecraft/cooking/utils/PlayerActionContextTest.java +++ b/src/test/java/net/tfminecraft/cooking/utils/PlayerActionContextTest.java @@ -40,6 +40,45 @@ class PlayerActionContextTest { assertNull(PlayerActionContext.capture(player)); } + @Test void refusedActionsTellThePlayerAtMostEveryThreeSeconds() { + assertNull(PlayerActionContext.captureOrRefuse(null)); + assertNull(PlayerActionContext.captureOrRefuse(player)); + assertNull(PlayerActionContext.captureOrRefuse(player)); + assertEquals(PlayerActionContext.NO_CHARACTER_MESSAGE, player.nextMessage()); + assertNull(player.nextMessage()); + server.getScheduler().performTicks(59); + assertNull(PlayerActionContext.captureOrRefuse(player)); + assertNull(player.nextMessage()); + server.getScheduler().performOneTick(); + assertNull(PlayerActionContext.captureOrRefuse(player)); + assertEquals(PlayerActionContext.NO_CHARACTER_MESSAGE, player.nextMessage()); + server.getPlayerList().disconnectPlayer(player); + PlayerMock rejoined=new PlayerMock(server,player.getName(),player.getUniqueId()); + server.addPlayer(rejoined); + assertNull(PlayerActionContext.captureOrRefuse(rejoined)); + assertEquals(PlayerActionContext.NO_CHARACTER_MESSAGE, rejoined.nextMessage()); + player=rejoined; + + characters.activate(player,UUID.randomUUID()); + assertNotNull(PlayerActionContext.captureOrRefuse(player)); + assertNull(player.nextMessage()); + characters.close(); + assertNotNull(PlayerActionContext.captureOrRefuse(player)); + assertNull(player.nextMessage()); + } + + @Test void expiredRefusalsAreForgottenWhenAnotherPlayerIsRefused() throws Exception { + java.lang.reflect.Field field=PlayerActionContext.class.getDeclaredField("lastRefusal"); + field.setAccessible(true); + java.util.Map refusals=(java.util.Map) field.get(null); + refusals.clear(); + PlayerMock other=server.addPlayer(); + assertNull(PlayerActionContext.captureOrRefuse(player)); + server.getScheduler().performTicks(60); + assertNull(PlayerActionContext.captureOrRefuse(other)); + assertEquals(java.util.Set.of(other.getUniqueId()), refusals.keySet()); + } + @Test void readyContextCapturesExactTokenAndRejectsSwitchBackGeneration() { UUID characterId=UUID.randomUUID(); CharacterSession original=characters.activate(player,characterId);