From fce00711df0de7bc5608b5b39b09902068210c81 Mon Sep 17 00:00:00 2001 From: Ryan Barlow <7389646+ryanbarlow97@users.noreply.github.com> Date: Mon, 12 Oct 2026 07:10:29 +0000 Subject: [PATCH 1/2] fix: warn without the identity provider and reset food only for who died Log a startup warning when RPCharacters is enabled but registers no character identity (older than 2.16.0). Refused food, cups, milk buckets, bowl soups, hoe harvests and sausage cranks now tell the player once every 3 seconds, and a cup drunk without a character is kept. The admin food command reports when the target switches character mid-change. A permakill switches to the replacement at MONITOR before Cooking saw the death, so the replacement's food was reset. The dying session is now captured at LOWEST and only that session is reset. Drop the join saturation hook and re-checks around calls that cannot switch the character. Co-Authored-By: Claude Opus 5.5 --- .../java/net/tfminecraft/cooking/Cooking.java | 13 +++ .../cooking/cup/DrinkConsumeListener.java | 14 ++- .../cooking/farming/FarmHarvestService.java | 2 +- .../cooking/manager/CommandManager.java | 12 ++- .../cooking/nutrition/BowlEatHandler.java | 2 +- .../nutrition/FoodConsumeListener.java | 2 +- .../nutrition/NutritionAttributeBridge.java | 4 +- .../nutrition/NutritionDisplayService.java | 3 - .../nutrition/NutritionLifecycleListener.java | 22 ++++- .../cooking/nutrition/SaturationGuard.java | 11 --- .../sausagemaker/SausageMakerHandler.java | 2 +- .../cooking/utils/PlayerActionContext.java | 26 ++++++ .../cooking/CookingLifecycleTest.java | 21 +++++ .../cooking/farming/FarmingCoverageTest.java | 3 + .../manager/CommandManagerCoverageTest.java | 5 +- .../NutritionListenersCoverageTest.java | 87 +++++++++++++++++-- .../NutritionRuntimeCoverageTest.java | 20 ----- .../SausageMakerCoverageTest.java | 3 + .../utils/PlayerActionContextTest.java | 27 ++++++ 19 files changed, 221 insertions(+), 58 deletions(-) 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..96c9eae 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,20 @@ 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) { + 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..53db6e5 100644 --- a/src/test/java/net/tfminecraft/cooking/utils/PlayerActionContextTest.java +++ b/src/test/java/net/tfminecraft/cooking/utils/PlayerActionContextTest.java @@ -40,6 +40,33 @@ 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 readyContextCapturesExactTokenAndRejectsSwitchBackGeneration() { UUID characterId=UUID.randomUUID(); CharacterSession original=characters.activate(player,characterId); From b7122e545ab40939bfd0f2617b36b5b4a8522eca Mon Sep 17 00:00:00 2001 From: Ryan Barlow <7389646+ryanbarlow97@users.noreply.github.com> Date: Mon, 12 Oct 2026 07:27:53 +0000 Subject: [PATCH 2/2] fix: forget refusals once their cooldown has passed Co-Authored-By: Claude Opus 5.5 --- .../cooking/utils/PlayerActionContext.java | 2 ++ .../cooking/utils/PlayerActionContextTest.java | 12 ++++++++++++ 2 files changed, 14 insertions(+) diff --git a/src/main/java/net/tfminecraft/cooking/utils/PlayerActionContext.java b/src/main/java/net/tfminecraft/cooking/utils/PlayerActionContext.java index 96c9eae..a9ba6d3 100644 --- a/src/main/java/net/tfminecraft/cooking/utils/PlayerActionContext.java +++ b/src/main/java/net/tfminecraft/cooking/utils/PlayerActionContext.java @@ -34,6 +34,8 @@ public static PlayerActionContext captureOrRefuse(Player player) { 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); } diff --git a/src/test/java/net/tfminecraft/cooking/utils/PlayerActionContextTest.java b/src/test/java/net/tfminecraft/cooking/utils/PlayerActionContextTest.java index 53db6e5..9ad6068 100644 --- a/src/test/java/net/tfminecraft/cooking/utils/PlayerActionContextTest.java +++ b/src/test/java/net/tfminecraft/cooking/utils/PlayerActionContextTest.java @@ -67,6 +67,18 @@ class PlayerActionContextTest { 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);