diff --git a/pom.xml b/pom.xml index 4c4d1ad..d5071c9 100644 --- a/pom.xml +++ b/pom.xml @@ -13,9 +13,9 @@ UTF-8 full 0.2.0 - 2.0.1 + 2.16.0 3.0.1 - 2.1.0 + 2.3.0 diff --git a/src/main/java/net/tfminecraft/cooking/Cooking.java b/src/main/java/net/tfminecraft/cooking/Cooking.java index 0c072b8..d6f2aa6 100644 --- a/src/main/java/net/tfminecraft/cooking/Cooking.java +++ b/src/main/java/net/tfminecraft/cooking/Cooking.java @@ -81,10 +81,12 @@ import net.tfminecraft.cooking.oven.OvenHandler; import net.tfminecraft.cooking.oven.OvenLifecycleHandler; import net.tfminecraft.tlibs.itemscan.ItemScanService; +import net.tfminecraft.tlibs.character.Characters; public class Cooking extends JavaPlugin { public static Cooking plugin; + private boolean incompatibleCharacterApi; private final CommandManager commands = new CommandManager(); @@ -133,6 +135,15 @@ public void onLoad() { @Override public void onEnable() { + try { + // Registration may arrive later; this checks API compatibility only. + Characters.available(); + } catch (LinkageError error) { + incompatibleCharacterApi = true; + getLogger().severe("Cooking requires TLibs 2.3.0 or newer; disabling."); + Bukkit.getPluginManager().disablePlugin(this); + return; + } plugin = this; createFolders(); @@ -158,6 +169,7 @@ public void onEnable() { meatHookHandler.resumeLoadedHooks(); SaturationGuard.start(); NutritionDrainTask.start(); + Bukkit.getOnlinePlayers().forEach(NutritionLifecycleListener::initializeCurrent); HusbandryLifecycleListener.resumeLoadedWorlds(); HusbandryTickTask.start(); HusbandryLocator.scanUnloaded(); @@ -177,6 +189,7 @@ public void onEnable() { @Override public void onDisable() { + if (incompatibleCharacterApi) return; SaturationGuard.stop(); NutritionDrainTask.stop(); HusbandryTickTask.stop(); diff --git a/src/main/java/net/tfminecraft/cooking/crops/CropCustomCropsListener.java b/src/main/java/net/tfminecraft/cooking/crops/CropCustomCropsListener.java index d87addd..d16f34d 100644 --- a/src/main/java/net/tfminecraft/cooking/crops/CropCustomCropsListener.java +++ b/src/main/java/net/tfminecraft/cooking/crops/CropCustomCropsListener.java @@ -5,7 +5,7 @@ import java.util.UUID; import java.util.concurrent.ConcurrentHashMap; -import org.bukkit.Bukkit; +import net.tfminecraft.cooking.utils.PlayerActionContext; import org.bukkit.Location; import org.bukkit.entity.Item; import org.bukkit.entity.Player; @@ -103,17 +103,17 @@ private void armHarvest(Location location, CropDefinition crop, Player player) { } private void scheduleInventoryRewrite(Player player, CropDefinition crop, int quality) { - UUID uuid = player.getUniqueId(); + PlayerActionContext context = PlayerActionContext.capture(player); + if (context == null) return; + Location dropLocation = player.getLocation().clone(); ItemStack[] before = cloneStorage(player.getInventory()); Cooking.plugin.getServer().getScheduler().runTaskLater(Cooking.plugin, () -> { - Player online = Bukkit.getPlayer(uuid); - if (online == null || !online.isOnline()) { - return; - } - PlayerInventory inventory = online.getInventory(); + if (!context.isCurrent()) return; + PlayerInventory inventory = player.getInventory(); ItemStack[] now = inventory.getStorageContents(); int length = Math.min(before.length, now.length); java.util.List additions = new java.util.ArrayList<>(); + Map replacements = new java.util.LinkedHashMap<>(); for (int i = 0; i < length; i++) { if (sameStack(before[i], now[i])) { continue; @@ -129,13 +129,15 @@ private void scheduleInventoryRewrite(Player player, CropDefinition crop, int qu ItemStack rewritten = CropHarvestItems.rewriteCustomDrop(candidate, crop, quality); if (rewritten == null || rewritten == candidate || rewritten.getType().isAir()) continue; // Take the harvest back out and add it as food, so it joins produce that only aged differently. - inventory.setItem(i, grewExisting ? before[i].clone() : null); + replacements.put(i, grewExisting ? before[i].clone() : null); additions.add(rewritten); } + if (!context.isCurrent()) return; + replacements.forEach(inventory::setItem); for (ItemStack addition : additions) { - ItemStack leftover = InventoryAdder.addItem(online, addition); + ItemStack leftover = context.isCurrent() ? InventoryAdder.addItem(player, addition) : addition; if (leftover != null) { - online.getWorld().dropItemNaturally(online.getLocation(), leftover); + dropLocation.getWorld().dropItemNaturally(dropLocation, leftover); } } }, 1L); diff --git a/src/main/java/net/tfminecraft/cooking/cup/DrinkConsumeListener.java b/src/main/java/net/tfminecraft/cooking/cup/DrinkConsumeListener.java index 742247a..b4198c9 100644 --- a/src/main/java/net/tfminecraft/cooking/cup/DrinkConsumeListener.java +++ b/src/main/java/net/tfminecraft/cooking/cup/DrinkConsumeListener.java @@ -1,5 +1,6 @@ package net.tfminecraft.cooking.cup; +import java.util.function.BooleanSupplier; import java.util.function.Supplier; import org.bukkit.Bukkit; @@ -19,6 +20,7 @@ import net.tfminecraft.cooking.item.FoodItem; import net.tfminecraft.cooking.nutrition.NutritionService; import net.tfminecraft.cooking.utils.InventoryAdder; +import net.tfminecraft.cooking.utils.PlayerActionContext; public final class DrinkConsumeListener implements Listener { @@ -32,16 +34,18 @@ public void onMilkBucket(PlayerItemConsumeEvent event) { event.setCancelled(true); Player player = event.getPlayer(); + PlayerActionContext context = PlayerActionContext.capture(player); + if (context == null) { + return; + } FoodItem food = FoodItem.fromItem(consumed); - if (food != null && "milk_bucket".equalsIgnoreCase(food.getId())) { - food.updateAge(); + if (food != null) food.updateAge(); + if (!context.isCurrent()) return; + // This event is cancelled, so spend the bucket before nutrition callbacks. + if (player.getGameMode() != GameMode.CREATIVE && !emptyDrunkBucket(player, event.getHand())) return; + if (context.isCurrent() && food != null && "milk_bucket".equalsIgnoreCase(food.getId())) { NutritionService.tryApplyEat(player, food); } - - if (player.getGameMode() == GameMode.CREATIVE) { - return; - } - emptyDrunkBucket(player, event.getHand()); } @EventHandler(priority = EventPriority.MONITOR, ignoreCancelled = true) @@ -58,19 +62,23 @@ public void onDrink(PlayerItemConsumeEvent event) { } Player player = event.getPlayer(); - - if (milk) { - FoodItem food = FoodItem.fromItem(consumed); - if (food != null) { - food.updateAge(); - NutritionService.tryApplyEat(player, food); - } + PlayerActionContext context = PlayerActionContext.capture(player); + if (context == null) { + return; } + FoodItem food = milk ? FoodItem.fromItem(consumed) : null; + if (food != null) food.updateAge(); + int amountBefore = consumed.getAmount(); EquipmentSlot hand = event.getHand() == EquipmentSlot.OFF_HAND ? EquipmentSlot.OFF_HAND : EquipmentSlot.HAND; int slot = hand == EquipmentSlot.OFF_HAND ? -1 : player.getInventory().getHeldItemSlot(); - Bukkit.getScheduler().runTask(Cooking.plugin, () -> replaceWithEmptyCup(player, amountBefore, slot)); + Bukkit.getScheduler().runTask(Cooking.plugin, () -> { + if (!event.isCancelled() && context.isCurrent()) { + replaceWithEmptyCup(player, amountBefore, slot, CupItems::emptyCup, context::isCurrent); + if (context.isCurrent() && food != null) NutritionService.tryApplyEat(player, food); + } + }); } /** @@ -79,11 +87,12 @@ public void onDrink(PlayerItemConsumeEvent event) { * must neither leave the bottle and add a cup, nor overwrite whatever is held instead. Cups * are potions, so an empty slot means the bottle was moved away, or nothing was used up. */ - static void replaceWithEmptyCup(Player player, int amountBefore, int slot) { - replaceWithEmptyCup(player, amountBefore, slot, CupItems::emptyCup); + static void replaceWithEmptyCup(Player player, int amountBefore, int slot, Supplier emptyCup) { + replaceWithEmptyCup(player, amountBefore, slot, emptyCup, () -> true); } - static void replaceWithEmptyCup(Player player, int amountBefore, int slot, Supplier emptyCup) { + private static void replaceWithEmptyCup(Player player, int amountBefore, int slot, + Supplier emptyCup, BooleanSupplier current) { if (amountBefore > 1) { return; } @@ -93,6 +102,7 @@ static void replaceWithEmptyCup(Player player, int amountBefore, int slot, Suppl return; } ItemStack empty = emptyCup.get(); + if (!current.getAsBoolean()) return; if (slot < 0) { inv.setItemInOffHand(empty); } else { @@ -100,7 +110,7 @@ static void replaceWithEmptyCup(Player player, int amountBefore, int slot, Suppl } } - private static void emptyDrunkBucket(Player player, EquipmentSlot hand) { + private static boolean emptyDrunkBucket(Player player, EquipmentSlot hand) { PlayerInventory inv = player.getInventory(); EquipmentSlot slot = hand != null ? hand : EquipmentSlot.HAND; ItemStack used = inv.getItem(slot); @@ -114,7 +124,7 @@ private static void emptyDrunkBucket(Player player, EquipmentSlot hand) { slot = EquipmentSlot.OFF_HAND; used = off; } else { - return; + return false; } } int amount = used.getAmount(); @@ -124,8 +134,9 @@ private static void emptyDrunkBucket(Player player, EquipmentSlot hand) { if (leftover != null && leftover.getAmount() > 0 && player.getWorld() != null) { player.getWorld().dropItemNaturally(player.getLocation(), leftover); } - return; + return true; } inv.setItem(slot, BucketItems.empty()); + return true; } } diff --git a/src/main/java/net/tfminecraft/cooking/cup/MilkBucketConverter.java b/src/main/java/net/tfminecraft/cooking/cup/MilkBucketConverter.java index f5bc9a4..eeb4535 100644 --- a/src/main/java/net/tfminecraft/cooking/cup/MilkBucketConverter.java +++ b/src/main/java/net/tfminecraft/cooking/cup/MilkBucketConverter.java @@ -15,6 +15,7 @@ import net.tfminecraft.cooking.quality.OriginQualityResolver; import net.tfminecraft.cooking.utils.FoodParser; import net.tfminecraft.cooking.utils.ItemBuilder; +import net.tfminecraft.cooking.utils.PlayerActionContext; public final class MilkBucketConverter implements Listener { @@ -70,13 +71,20 @@ public void onBucketFill(PlayerBucketFillEvent event) { } Player player = event.getPlayer(); + PlayerActionContext context = PlayerActionContext.capture(player); + if (context == null) { + return; + } ItemStack filled = event.getItemStack(); ItemStack converted = convert(player, filled); - if (converted == null || converted.equals(filled)) { + if (converted == null || converted.equals(filled) || !context.isCurrent()) { return; } Bukkit.getScheduler().runTask(Cooking.plugin, () -> { + if (!context.isCurrent()) { + return; + } ItemStack main = player.getInventory().getItemInMainHand(); if (main != null && main.getType() == Material.MILK_BUCKET && FoodItem.fromItem(main) == null) { converted.setAmount(main.getAmount()); diff --git a/src/main/java/net/tfminecraft/cooking/farming/FarmHarvestService.java b/src/main/java/net/tfminecraft/cooking/farming/FarmHarvestService.java index ddc631d..393215d 100644 --- a/src/main/java/net/tfminecraft/cooking/farming/FarmHarvestService.java +++ b/src/main/java/net/tfminecraft/cooking/farming/FarmHarvestService.java @@ -5,6 +5,7 @@ import java.util.concurrent.ThreadLocalRandom; import org.bukkit.GameMode; +import org.bukkit.Location; import org.bukkit.Material; import org.bukkit.block.Block; import org.bukkit.block.BlockState; @@ -24,6 +25,7 @@ import net.tfminecraft.cooking.crops.CropDefinition; import net.tfminecraft.cooking.utils.IngredientConverter; import net.tfminecraft.cooking.utils.InventoryAdder; +import net.tfminecraft.cooking.utils.PlayerActionContext; public final class FarmHarvestService { @@ -37,6 +39,10 @@ public static void harvestArea( if (player == null || tool == null || hoe == null || centre == null) { return; } + PlayerActionContext context = PlayerActionContext.capture(player); + if (context == null) { + return; + } List blocks = FarmAreaScanner.findCropBlocks( centre, @@ -44,6 +50,9 @@ public static void harvestArea( FarmingConfig.cropMaterials()); for (Block block : blocks) { + if (!context.isCurrent()) { + break; + } ItemStack currentTool = player.getInventory().getItemInMainHand(); if (currentTool.getType().isAir() || currentTool.getAmount() <= 0) { break; @@ -54,7 +63,10 @@ public static void harvestArea( continue; } - if (harvestBlock(player, currentTool, hoe, block, cropDef)) { + if (harvestBlock(context, currentTool, hoe, block, cropDef)) { + if (!context.isCurrent()) { + break; + } if (applyToolDamage(player, currentTool)) { break; } @@ -71,15 +83,27 @@ public static boolean harvestBlock( if (player == null || tool == null || hoe == null || block == null || cropDef == null) { return false; } + PlayerActionContext context = PlayerActionContext.capture(player); + return context != null && harvestBlock(context, tool, hoe, block, cropDef); + } + + private static boolean harvestBlock( + PlayerActionContext context, + ItemStack tool, + FarmingToolDefinition hoe, + Block block, + FarmingCropDefinition cropDef) { if (FarmingConfig.onlyHarvestMature() && !isMature(block)) { return false; } + Player player = context.player(); + Location dropLocation = block.getLocation().clone(); BlockBreakEvent notify = new BlockBreakEvent(block, player); notify.setDropItems(false); notify.setExpToDrop(0); Cooking.plugin.getServer().getPluginManager().callEvent(notify); - if (notify.isCancelled()) { + if (!context.isCurrent() || notify.isCancelled()) { return false; } @@ -88,10 +112,17 @@ public static boolean harvestBlock( if (cropQuality != null && CropsConfig.SOURCE_VANILLA.equals(cropQuality.source())) { harvestQuality = rollHarvestQuality(player, block, cropQuality, hoe); } + if (!context.isCurrent()) { + return false; + } BlockState state = block.getState(); List drops = new ArrayList<>(block.getDrops(tool)); + if (!context.isCurrent()) { + return false; + } ItemStack replantSeed = takeOneSeed(drops, cropDef.seed()); + List convertedDrops = new ArrayList<>(); for (ItemStack drop : drops) { if (drop == null || drop.getType().isAir() || drop.getAmount() <= 0) { @@ -100,17 +131,23 @@ public static boolean harvestBlock( ItemStack converted = harvestQuality != null ? CropHarvestItems.convertDrop(drop, cropQuality, harvestQuality) : IngredientConverter.convertHarvestDrop(player, drop, hoe.qualityBonusPercent()); - ItemStack leftover = InventoryAdder.addItem(player, converted); - if (leftover != null) { - block.getWorld().dropItemNaturally(block.getLocation(), leftover); + if (!context.isCurrent()) { + return false; } + convertedDrops.add(converted); } - FarmingEffects.breakCrop(block, state, hoe.radius(), FarmingConfig.harvestParticleMultiplier()); block.setType(Material.AIR); + FarmingEffects.breakCrop(block, state, hoe.radius(), FarmingConfig.harvestParticleMultiplier()); + for (ItemStack converted : convertedDrops) { + ItemStack leftover = context.isCurrent() ? InventoryAdder.addItem(player, converted) : converted; + if (leftover != null) { + dropAt(dropLocation, leftover); + } + } if (replantSeed != null) { - scheduleReplant(player, block, cropDef, replantSeed); + scheduleReplant(context, block, cropDef, replantSeed, dropLocation); } return true; @@ -147,10 +184,11 @@ private static ItemStack takeOneSeed(List drops, Material seedMateria } private static void scheduleReplant( - Player player, + PlayerActionContext context, Block block, FarmingCropDefinition cropDef, - ItemStack seed) { + ItemStack seed, + Location dropLocation) { int min = FarmingConfig.replantDelayMin(); int max = FarmingConfig.replantDelayMax(); int delay = min; @@ -162,12 +200,12 @@ private static void scheduleReplant( ItemStack seedCopy = seed.clone(); Cooking.plugin.getServer().getScheduler().runTaskLater(Cooking.plugin, () -> { - if (!player.isOnline()) { - dropSeed(block, seedCopy); + if (!context.isCurrent()) { + dropAt(dropLocation, seedCopy); return; } if (!block.isEmpty()) { - dropSeed(block, seedCopy); + dropAt(dropLocation, seedCopy); return; } @@ -178,6 +216,11 @@ private static void scheduleReplant( ageable.setAge(0); block.setBlockData(ageable); } + if (!context.isCurrent()) { + replacedState.update(true); + dropAt(dropLocation, seedCopy); + return; + } Block soil = block.getRelative(0, -1, 0); BlockPlaceEvent plantEvent = new BlockPlaceEvent( @@ -185,13 +228,13 @@ private static void scheduleReplant( replacedState, soil, seedCopy, - player, + context.player(), true, EquipmentSlot.HAND); Cooking.plugin.getServer().getPluginManager().callEvent(plantEvent); - if (plantEvent.isCancelled() || !plantEvent.canBuild()) { + if (!context.isCurrent() || plantEvent.isCancelled() || !plantEvent.canBuild()) { replacedState.update(true); - dropSeed(block, seedCopy); + dropAt(dropLocation, seedCopy); return; } @@ -199,9 +242,9 @@ private static void scheduleReplant( }, delay); } - private static void dropSeed(Block block, ItemStack seed) { - if (block.getWorld() != null) { - block.getWorld().dropItemNaturally(block.getLocation(), seed); + private static void dropAt(Location location, ItemStack item) { + if (location.getWorld() != null) { + location.getWorld().dropItemNaturally(location, item); } } diff --git a/src/main/java/net/tfminecraft/cooking/manager/BundleFoodStacker.java b/src/main/java/net/tfminecraft/cooking/manager/BundleFoodStacker.java index 089363e..f44b3b2 100644 --- a/src/main/java/net/tfminecraft/cooking/manager/BundleFoodStacker.java +++ b/src/main/java/net/tfminecraft/cooking/manager/BundleFoodStacker.java @@ -7,6 +7,8 @@ import org.bukkit.Bukkit; import org.bukkit.Tag; import org.bukkit.entity.HumanEntity; +import org.bukkit.entity.Player; +import net.tfminecraft.cooking.utils.PlayerActionContext; import org.bukkit.event.EventHandler; import org.bukkit.event.EventPriority; import org.bukkit.event.Listener; @@ -45,12 +47,16 @@ public BundleFoodStacker() { public void onClick(InventoryClickEvent e) { if (!isBundle(e.getCursor()) && !isBundle(e.getCurrentItem())) return; - HumanEntity player = e.getWhoClicked(); + if (!(e.getWhoClicked() instanceof Player player)) return; + PlayerActionContext context = PlayerActionContext.capture(player); + if (context == null) return; Inventory clicked = e.getClickedInventory(); int slot = e.getSlot(); // The bundle changes after this event, so merge on the next tick. Read the cursor and the // slot again then: the player may have moved things in between. - nextTick.accept(() -> stackBundles(player, clicked, slot)); + nextTick.accept(() -> { + if (context.isCurrent()) stackBundles(player, clicked, slot); + }); } static void stackBundles(HumanEntity player, Inventory clicked, int slot) { diff --git a/src/main/java/net/tfminecraft/cooking/manager/CommandManager.java b/src/main/java/net/tfminecraft/cooking/manager/CommandManager.java index 1828961..db121be 100644 --- a/src/main/java/net/tfminecraft/cooking/manager/CommandManager.java +++ b/src/main/java/net/tfminecraft/cooking/manager/CommandManager.java @@ -21,7 +21,7 @@ import net.tfminecraft.interactiblefurniture.InteractibleFurniture; import net.tfminecraft.rpcharacters.objects.RPCharacter; -import net.tfminecraft.rpcharacters.RPCharacters; +import net.tfminecraft.cooking.nutrition.NutritionContext; import net.tfminecraft.cooking.Cooking; import net.tfminecraft.cooking.baking.BakingTrayRecipe; import net.tfminecraft.cooking.baking.BakingTrayRegistry; @@ -281,11 +281,12 @@ private boolean handleFood(CommandSender sender, String[] args) { sender.sendMessage("§cThat player is not online."); return true; } - RPCharacter character = RPCharacters.getActiveCharacter(target); - if (character == null) { + NutritionContext context = NutritionContext.capture(target); + if (context == null) { sender.sendMessage("§cThat player has no active character."); return true; } + RPCharacter character = context.character(); int value; try { value = Integer.parseInt(args[2]); @@ -308,8 +309,9 @@ private boolean handleFood(CommandSender sender, String[] args) { + " mappedAfter=" + NutritionDisplayService.toFoodLevel(character.getFoodValue()) + " hudBefore=" + target.getFoodLevel() + " saturation=" + target.getSaturation()); - NutritionDisplayService.sync(target, character, "admin"); - RPCharacters.getPlayerManager().savePlayer(target); + if (!context.save()) return true; + NutritionDisplayService.sync(context, "admin"); + if (!context.isCurrent()) 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 0adaa49..5257845 100644 --- a/src/main/java/net/tfminecraft/cooking/nutrition/BowlEatHandler.java +++ b/src/main/java/net/tfminecraft/cooking/nutrition/BowlEatHandler.java @@ -12,6 +12,7 @@ import net.tfminecraft.cooking.cache.FurnitureCache; import net.tfminecraft.cooking.item.FoodItem; import net.tfminecraft.cooking.manager.PlateManager; +import net.tfminecraft.cooking.utils.PlayerActionContext; import net.tfminecraft.interactiblefurniture.events.FurnitureInteractEvent; import net.tfminecraft.interactiblefurniture.furniture.Furniture; import net.tfminecraft.interactiblefurniture.furniture.PlacedSlot; @@ -64,13 +65,17 @@ public void onInteract(FurnitureInteractEvent event) { player.sendMessage("§cThis needs to be prepared before you can eat it."); return; } + PlayerActionContext context = PlayerActionContext.capture(player); + if (context == null) return; food.updateAge(); + if (!context.isCurrent()) return; + // Consume the shared soup before nutrition callbacks can trigger another interaction. + plateManager.clear(furniture); + InteractibleFurniture.getInstance().getFurnitureManager().markDirty(furniture); + if (!context.isCurrent()) return; NutritionService.tryApplyEat(player, food); - + if (!context.isCurrent()) return; player.swingMainHand(); furniture.getLoc().getWorld().playSound(furniture.getLoc(), Sound.ENTITY_GENERIC_DRINK, 1f, 1f); - - plateManager.clear(furniture); - InteractibleFurniture.getInstance().getFurnitureManager().markDirty(furniture); } } diff --git a/src/main/java/net/tfminecraft/cooking/nutrition/DietTierService.java b/src/main/java/net/tfminecraft/cooking/nutrition/DietTierService.java index 68822b0..84361cb 100644 --- a/src/main/java/net/tfminecraft/cooking/nutrition/DietTierService.java +++ b/src/main/java/net/tfminecraft/cooking/nutrition/DietTierService.java @@ -1,20 +1,21 @@ package net.tfminecraft.cooking.nutrition; -import org.bukkit.Bukkit; import org.bukkit.entity.Player; import net.tfminecraft.tlibs.objects.api.subapi.StringFormatter; import net.tfminecraft.rpcharacters.objects.RPCharacter; -import net.tfminecraft.rpcharacters.RPCharacters; public final class DietTierService { private DietTierService() {} public static boolean seedIfAbsent(Player player, RPCharacter character) { - if (character == null || player == null) { - return false; - } + return seedIfAbsent(NutritionContext.capture(player, character)); + } + + public static boolean seedIfAbsent(NutritionContext context) { + if (context == null || !context.isCurrent()) return false; + RPCharacter character = context.character(); String lastTier = character.getLastDietTierId(); if (lastTier != null && !lastTier.isBlank()) { return false; @@ -22,33 +23,34 @@ public static boolean seedIfAbsent(Player player, RPCharacter character) { DietTierDefinition tier = NutritionConfig.resolveTier(character.getDietScore()); character.setLastDietTierId(tier.getId()); - if (Bukkit.getPluginManager().isPluginEnabled("RPCharacters")) { - RPCharacters.getPlayerManager().savePlayer(player); - } + context.save(); return true; } public static void checkAndNotify(Player player, RPCharacter character, VarietyScore variety) { - if (player == null || character == null) { - return; - } + checkAndNotify(NutritionContext.capture(player, character), variety); + } + public static void checkAndNotify(NutritionContext context, VarietyScore variety) { + if (context == null || !context.isCurrent()) return; + Player player = context.player(); + RPCharacter character = context.character(); DietTierDefinition current = NutritionConfig.resolveTier(character.getDietScore()); String lastId = character.getLastDietTierId(); if (lastId != null && lastId.equalsIgnoreCase(current.getId())) { return; } + character.setLastDietTierId(current.getId()); player.sendMessage( StringFormatter.formatHex("#d4ad77Your diet is now ") + StringFormatter.formatHex(current.getLabel())); - if (NutritionConfig.varietyEnabled() && variety != null) { + if (context.isCurrent() && NutritionConfig.varietyEnabled() && variety != null) { DietTierDefinition varietyTier = NutritionConfig.resolveTierPercent(variety.progressPercent()); player.sendMessage( StringFormatter.formatHex("#d4ad77Variety: ") + StringFormatter.formatHex(varietyTier.getLabel()) + StringFormatter.formatHex("#d4ad77 (" + variety.penaltyPercent() + "% nutrition penalty)")); } - character.setLastDietTierId(current.getId()); } } diff --git a/src/main/java/net/tfminecraft/cooking/nutrition/FoodConsumeListener.java b/src/main/java/net/tfminecraft/cooking/nutrition/FoodConsumeListener.java index 51233a8..c8d6c0f 100644 --- a/src/main/java/net/tfminecraft/cooking/nutrition/FoodConsumeListener.java +++ b/src/main/java/net/tfminecraft/cooking/nutrition/FoodConsumeListener.java @@ -1,5 +1,6 @@ package net.tfminecraft.cooking.nutrition; +import org.bukkit.Bukkit; import org.bukkit.Material; import org.bukkit.event.EventHandler; import org.bukkit.event.EventPriority; @@ -7,8 +8,10 @@ import org.bukkit.event.player.PlayerItemConsumeEvent; import org.bukkit.inventory.ItemStack; +import net.tfminecraft.cooking.Cooking; import net.tfminecraft.cooking.cache.ItemCache; import net.tfminecraft.cooking.item.FoodItem; +import net.tfminecraft.cooking.utils.PlayerActionContext; public final class FoodConsumeListener implements Listener { @@ -35,7 +38,17 @@ public void onConsume(PlayerItemConsumeEvent event) { return; } + PlayerActionContext context = PlayerActionContext.capture(event.getPlayer()); + if (context == null) { + event.setCancelled(true); + return; + } food.updateAge(); - NutritionService.tryApplyEat(event.getPlayer(), food); + // Vanilla spends the food after this event; never grant nutrition before that commit. + Bukkit.getScheduler().runTask(Cooking.plugin, () -> { + if (!event.isCancelled() && context.isCurrent()) { + NutritionService.tryApplyEat(context.player(), food); + } + }); } } diff --git a/src/main/java/net/tfminecraft/cooking/nutrition/FoodLevelChangeGuard.java b/src/main/java/net/tfminecraft/cooking/nutrition/FoodLevelChangeGuard.java index 667e8c4..6789682 100644 --- a/src/main/java/net/tfminecraft/cooking/nutrition/FoodLevelChangeGuard.java +++ b/src/main/java/net/tfminecraft/cooking/nutrition/FoodLevelChangeGuard.java @@ -8,7 +8,6 @@ import org.bukkit.event.entity.FoodLevelChangeEvent; import net.tfminecraft.rpcharacters.objects.RPCharacter; -import net.tfminecraft.rpcharacters.RPCharacters; import net.tfminecraft.cooking.Cooking; public final class FoodLevelChangeGuard implements Listener { @@ -18,9 +17,8 @@ public void onFoodLevelChange(FoodLevelChangeEvent event) { if (!(event.getEntity() instanceof Player player)) { return; } - RPCharacter character = Bukkit.getPluginManager().isPluginEnabled("RPCharacters") - ? RPCharacters.getActiveCharacter(player) - : null; + NutritionContext context = NutritionContext.capture(player); + RPCharacter character = context == null ? null : context.character(); if (NutritionDisplayService.isSyncing(player)) { NutritionLog.append("VANILLA_FOOD_EVENT", player, character, "current=" + player.getFoodLevel() @@ -33,7 +31,9 @@ public void onFoodLevelChange(FoodLevelChangeEvent event) { + " proposed=" + event.getFoodLevel() + " action=cancel-and-defer"); event.setCancelled(true); - Bukkit.getScheduler().runTask(Cooking.plugin, - () -> NutritionDisplayService.syncFromPlayer(player, "vanilla-change")); + if (context != null) { + Bukkit.getScheduler().runTask(Cooking.plugin, + () -> NutritionDisplayService.sync(context, "vanilla-change")); + } } } diff --git a/src/main/java/net/tfminecraft/cooking/nutrition/NutritionAttributeBridge.java b/src/main/java/net/tfminecraft/cooking/nutrition/NutritionAttributeBridge.java index d1c7f01..a0019c0 100644 --- a/src/main/java/net/tfminecraft/cooking/nutrition/NutritionAttributeBridge.java +++ b/src/main/java/net/tfminecraft/cooking/nutrition/NutritionAttributeBridge.java @@ -18,10 +18,17 @@ public static void apply(Player player, RPCharacter character) { if (player == null || character == null) { return; } + apply(NutritionContext.capture(player, character)); + } + + public static void apply(NutritionContext context) { + if (context == null || !context.isCurrent()) return; if (!Bukkit.getPluginManager().isPluginEnabled("MMOCore")) { return; } + Player player = context.player(); + RPCharacter character = context.character(); String attrId = NutritionConfig.attributeName(); PlayerData data = PlayerData.get(player); AttributeInstance instance = data.getAttributes().getInstance(attrId); @@ -43,14 +50,19 @@ public static void apply(Player player, RPCharacter character) { for (int i = 0; i < resources.length; i++) { before[i] = resources[i].getCurrent(data); } + if (!context.isCurrent()) return; 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/NutritionContext.java b/src/main/java/net/tfminecraft/cooking/nutrition/NutritionContext.java new file mode 100644 index 0000000..84bf9f7 --- /dev/null +++ b/src/main/java/net/tfminecraft/cooking/nutrition/NutritionContext.java @@ -0,0 +1,38 @@ +package net.tfminecraft.cooking.nutrition; + +import org.bukkit.Bukkit; +import org.bukkit.entity.Player; +import net.tfminecraft.cooking.utils.PlayerActionContext; +import net.tfminecraft.rpcharacters.RPCharacters; +import net.tfminecraft.rpcharacters.objects.RPCharacter; +import net.tfminecraft.tlibs.character.CharacterSession; + +/** Ready shared identity paired with RPCharacters' mutable nutrition fields. */ +public record NutritionContext(PlayerActionContext action, RPCharacter character) { + public static NutritionContext capture(Player player) { + PlayerActionContext action = PlayerActionContext.capture(player); + if (action == null || action.session() == null + || !Bukkit.getPluginManager().isPluginEnabled("RPCharacters")) return null; + RPCharacter character = RPCharacters.getActiveCharacter(player); + if (character == null || character.getOwner() != player + || !action.session().ref().ownerId().equals(player.getUniqueId()) + || !action.session().ref().characterId().toString().equalsIgnoreCase(character.getId()) + || !action.isCurrent()) return null; + return new NutritionContext(action, character); + } + + public static NutritionContext capture(Player player, RPCharacter expected) { + NutritionContext context = capture(player); + return context != null && context.character() == expected ? context : null; + } + + public Player player() { return action.player(); } + public CharacterSession session() { return action.session(); } + public boolean isCurrent() { return action.isCurrent(); } + + public boolean save() { + if (!isCurrent()) return false; + RPCharacters.getPlayerManager().savePlayer(player()); + return isCurrent(); + } +} diff --git a/src/main/java/net/tfminecraft/cooking/nutrition/NutritionDisplayService.java b/src/main/java/net/tfminecraft/cooking/nutrition/NutritionDisplayService.java index b7b1be8..9964e4e 100644 --- a/src/main/java/net/tfminecraft/cooking/nutrition/NutritionDisplayService.java +++ b/src/main/java/net/tfminecraft/cooking/nutrition/NutritionDisplayService.java @@ -1,18 +1,17 @@ package net.tfminecraft.cooking.nutrition; -import java.util.HashSet; -import java.util.Set; -import java.util.UUID; +import java.util.HashMap; +import java.util.Map; -import org.bukkit.Bukkit; import org.bukkit.entity.Player; import net.tfminecraft.rpcharacters.objects.RPCharacter; -import net.tfminecraft.rpcharacters.RPCharacters; +import net.tfminecraft.tlibs.character.CharacterSession; +import net.tfminecraft.tlibs.character.Characters; public final class NutritionDisplayService { - private static final Set syncingPlayers = new HashSet<>(); + private static final Map syncingSessions = new HashMap<>(); private NutritionDisplayService() {} @@ -24,7 +23,8 @@ public static int toFoodLevel(int foodValue) { } public static boolean isSyncing(Player player) { - return player != null && syncingPlayers.contains(player.getUniqueId()); + return player != null && Characters.currentSession(player) + .map(syncingSessions::containsKey).orElse(false); } public static void sync(Player player, RPCharacter character, String reason) { @@ -32,17 +32,33 @@ public static void sync(Player player, RPCharacter character, String reason) { NutritionLog.append("SYNC_SKIP", player, character, "reason=" + reason + " cause=null-input"); return; } - UUID id = player.getUniqueId(); - syncingPlayers.add(id); + NutritionContext context = NutritionContext.capture(player, character); + if (context == null) { + NutritionLog.append("SYNC_SKIP", player, character, + "reason=" + reason + " cause=not-ready-or-character-mismatch"); + return; + } + sync(context, reason); + } + + public static void sync(NutritionContext context, String reason) { + if (context == null || !context.isCurrent()) return; + Player player = context.player(); + RPCharacter character = context.character(); + CharacterSession session = context.session(); + syncingSessions.merge(session, 1, Integer::sum); try { int mapped = toFoodLevel(character.getFoodValue()); 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 @@ -52,7 +68,7 @@ public static void sync(Player player, RPCharacter character, String reason) { + " saturationAfter=" + player.getSaturation() + " wroteFood=" + wroteFood); } finally { - syncingPlayers.remove(id); + syncingSessions.computeIfPresent(session, (key, count) -> count > 1 ? count - 1 : null); } } @@ -61,20 +77,13 @@ public static void sync(Player player, RPCharacter character) { } public static void syncFromPlayer(Player player, String reason) { - if (player == null || !Bukkit.getPluginManager().isPluginEnabled("RPCharacters")) { - NutritionLog.append("SYNC_SKIP", player, null, - "reason=" + reason + " cause=rpcharacters-unavailable"); - return; - } - RPCharacter character = RPCharacters.getActiveCharacter(player); - if (character == null) { + NutritionContext context = NutritionContext.capture(player); + if (context == null) { NutritionLog.append("SYNC_SKIP", player, null, - "reason=" + reason + " cause=no-active-character" - + " hud=" + player.getFoodLevel() - + " saturation=" + player.getSaturation()); + "reason=" + reason + " cause=no-ready-character"); return; } - sync(player, character, reason); + sync(context, reason); } public static void syncFromPlayer(Player player) { diff --git a/src/main/java/net/tfminecraft/cooking/nutrition/NutritionDrainTask.java b/src/main/java/net/tfminecraft/cooking/nutrition/NutritionDrainTask.java index 6c23e6d..0fde6b9 100644 --- a/src/main/java/net/tfminecraft/cooking/nutrition/NutritionDrainTask.java +++ b/src/main/java/net/tfminecraft/cooking/nutrition/NutritionDrainTask.java @@ -4,7 +4,6 @@ import org.bukkit.entity.Player; import net.tfminecraft.rpcharacters.objects.RPCharacter; -import net.tfminecraft.rpcharacters.RPCharacters; import net.tfminecraft.cooking.Cooking; public final class NutritionDrainTask { @@ -57,16 +56,9 @@ private static void tick() { } private static void inspectPlayers(boolean due, int elapsedAtPulse, int interval) { - if (!Bukkit.getPluginManager().isPluginEnabled("RPCharacters")) { - NutritionLog.append("DRAIN_SKIP", null, null, - "elapsed=" + elapsedAtPulse + " interval=" + interval - + " cause=rpcharacters-unavailable"); - return; - } - for (Player player : Bukkit.getOnlinePlayers()) { - RPCharacter character = RPCharacters.getActiveCharacter(player); - if (character == null) { + NutritionContext context = NutritionContext.capture(player); + if (context == null) { NutritionLog.append("DRAIN_SKIP", player, null, "elapsed=" + elapsedAtPulse + " interval=" + interval + " cause=no-active-character" @@ -74,6 +66,7 @@ private static void inspectPlayers(boolean due, int elapsedAtPulse, int interval + " saturation=" + player.getSaturation()); continue; } + RPCharacter character = context.character(); int currentFood = character.getFoodValue(); int mappedBefore = NutritionDisplayService.toFoodLevel(currentFood); if (!due) { @@ -113,8 +106,8 @@ private static void inspectPlayers(boolean due, int elapsedAtPulse, int interval + " mappedAfter=" + NutritionDisplayService.toFoodLevel(character.getFoodValue()) + " hudBefore=" + player.getFoodLevel() + " saturation=" + player.getSaturation()); - NutritionDisplayService.sync(player, character, "drain"); - RPCharacters.getPlayerManager().savePlayer(player); + if (!context.save()) continue; + NutritionDisplayService.sync(context, "drain"); NutritionLog.append("SAVE", player, character, "reason=drain"); } } diff --git a/src/main/java/net/tfminecraft/cooking/nutrition/NutritionLifecycleListener.java b/src/main/java/net/tfminecraft/cooking/nutrition/NutritionLifecycleListener.java index f298c2a..04cbd73 100644 --- a/src/main/java/net/tfminecraft/cooking/nutrition/NutritionLifecycleListener.java +++ b/src/main/java/net/tfminecraft/cooking/nutrition/NutritionLifecycleListener.java @@ -1,50 +1,40 @@ package net.tfminecraft.cooking.nutrition; -import org.bukkit.Bukkit; import org.bukkit.entity.Player; import org.bukkit.event.EventHandler; import org.bukkit.event.EventPriority; import org.bukkit.event.Listener; import org.bukkit.event.entity.PlayerDeathEvent; -import org.bukkit.event.player.PlayerJoinEvent; import org.bukkit.event.player.PlayerRespawnEvent; import net.tfminecraft.rpcharacters.objects.RPCharacter; -import net.tfminecraft.rpcharacters.lifecycle.CharacterActivatedEvent; -import net.tfminecraft.cooking.Cooking; +import net.tfminecraft.tlibs.character.CharacterSessionStartEvent; public final class NutritionLifecycleListener implements Listener { @EventHandler(priority = EventPriority.MONITOR) - public void onJoin(PlayerJoinEvent event) { - Player player = event.getPlayer(); - NutritionLog.append("JOIN", player, null, - "phase=schedule hud=" + player.getFoodLevel() + " saturation=" + player.getSaturation()); - Bukkit.getScheduler().runTaskLater(Cooking.plugin, () -> { - NutritionLog.append("JOIN", player, null, - "phase=execute hud=" + player.getFoodLevel() + " saturation=" + player.getSaturation()); - NutritionDisplayService.syncFromPlayer(player, "join"); - }, 2L); + public void onCharacterStart(CharacterSessionStartEvent event) { + NutritionContext context = NutritionContext.capture(event.getPlayer()); + if (context == null || !context.session().equals(event.getSession())) return; + initialize(context); } - @EventHandler(priority = EventPriority.MONITOR) - public void onCharacterActivated(CharacterActivatedEvent event) { - Player owner = event.getOwner(); - if (owner == null) { - return; - } - RPCharacter character = event.getCharacter(); - if (character == null) { - NutritionLog.append("ACTIVATE_SKIP", owner, null, "cause=null-character"); - return; - } + public static void initializeCurrent(Player player) { + NutritionContext context = NutritionContext.capture(player); + if (context != null) initialize(context); + } + + private static void initialize(NutritionContext context) { + Player owner = context.player(); + RPCharacter character = context.character(); NutritionLog.append("ACTIVATE", owner, character, "hud=" + owner.getFoodLevel() + " saturation=" + owner.getSaturation()); VarietyService.applyEffective(owner, character); - DietTierService.seedIfAbsent(owner, character); - DietTierService.checkAndNotify(owner, character, VarietyService.current(owner)); - NutritionDisplayService.sync(owner, character, "activate"); - NutritionAttributeBridge.apply(owner, character); + DietTierService.seedIfAbsent(context); + if (!context.isCurrent()) return; + DietTierService.checkAndNotify(context, VarietyService.current(owner)); + NutritionDisplayService.sync(context, "activate"); + NutritionAttributeBridge.apply(context); } @EventHandler(priority = EventPriority.MONITOR) @@ -58,12 +48,15 @@ public void onPlayerRespawn(PlayerRespawnEvent event) { @EventHandler(priority = EventPriority.MONITOR, ignoreCancelled = true) public void onPlayerDeath(PlayerDeathEvent event) { Player player = event.getEntity(); - RPCharacter character = net.tfminecraft.rpcharacters.RPCharacters.getActiveCharacter(player); - if (character == null) { + NutritionContext context = NutritionContext.capture(player); + if (context == null) { NutritionLog.append("DEATH", player, null, "action=skip cause=no-active-character"); return; } - if (BattleFoodGate.inStartedBattle(player)) { + RPCharacter character = context.character(); + boolean inBattle = BattleFoodGate.inStartedBattle(player); + if (!context.isCurrent()) return; + if (inBattle) { NutritionLog.append("DEATH", player, character, "hud=" + player.getFoodLevel() + " saturation=" + player.getSaturation() + " food=" + character.getFoodValue() @@ -78,6 +71,6 @@ public void onPlayerDeath(PlayerDeathEvent event) { + " foodBefore=" + before + " foodAfter=" + after + " action=respawn-food"); - net.tfminecraft.rpcharacters.RPCharacters.getPlayerManager().savePlayer(player); + context.save(); } } diff --git a/src/main/java/net/tfminecraft/cooking/nutrition/NutritionService.java b/src/main/java/net/tfminecraft/cooking/nutrition/NutritionService.java index 4279e2f..4d139f1 100644 --- a/src/main/java/net/tfminecraft/cooking/nutrition/NutritionService.java +++ b/src/main/java/net/tfminecraft/cooking/nutrition/NutritionService.java @@ -1,10 +1,8 @@ package net.tfminecraft.cooking.nutrition; -import org.bukkit.Bukkit; import org.bukkit.entity.Player; import net.tfminecraft.rpcharacters.objects.RPCharacter; -import net.tfminecraft.rpcharacters.RPCharacters; import net.tfminecraft.cooking.item.FoodItem; import net.tfminecraft.cooking.item.IngredientLineage; @@ -17,18 +15,13 @@ public static void tryApplyEat(Player player, FoodItem food) { NutritionLog.append("EAT_SKIP", player, null, "cause=null-input"); return; } - if (!Bukkit.getPluginManager().isPluginEnabled("RPCharacters")) { + NutritionContext context = NutritionContext.capture(player); + if (context == null) { NutritionLog.append("EAT_SKIP", player, null, - "food=" + food.getId() + " cause=rpcharacters-unavailable"); - return; - } - - RPCharacter character = RPCharacters.getActiveCharacter(player); - if (character == null) { - NutritionLog.append("EAT_SKIP", player, null, - "food=" + food.getId() + " cause=no-active-character"); + "food=" + food.getId() + " cause=no-ready-character"); return; } + RPCharacter character = context.character(); int gained = (int) Math.ceil(food.getFinalFood()); int foodBefore = character.getFoodValue(); @@ -57,7 +50,7 @@ public static void tryApplyEat(Player player, FoodItem food) { VarietyScore variety = VarietyService.current(player); boolean dietChanged = character.getDietScore() != dietBefore; if (dietChanged) { - DietTierService.checkAndNotify(player, character, variety); + DietTierService.checkAndNotify(context, variety); } NutritionLog.append("EAT", player, character, @@ -78,10 +71,10 @@ public static void tryApplyEat(Player player, FoodItem food) { + " varietyProgress=" + variety.progressPercent() + " varietyDivisor=" + variety.divisor() + " varietyPenalty=" + variety.penaltyPercent()); - RPCharacters.getPlayerManager().savePlayer(player); + if (!context.save()) return; NutritionLog.append("SAVE", player, character, "reason=eat"); - NutritionDisplayService.sync(player, character, "eat"); - NutritionAttributeBridge.apply(player, character); + NutritionDisplayService.sync(context, "eat"); + NutritionAttributeBridge.apply(context); } private static int applyFoodGain(RPCharacter character, int gained) { diff --git a/src/main/java/net/tfminecraft/cooking/nutrition/SaturationGuard.java b/src/main/java/net/tfminecraft/cooking/nutrition/SaturationGuard.java index be4df3b..dd6b3c3 100644 --- a/src/main/java/net/tfminecraft/cooking/nutrition/SaturationGuard.java +++ b/src/main/java/net/tfminecraft/cooking/nutrition/SaturationGuard.java @@ -9,7 +9,7 @@ import org.bukkit.event.player.PlayerJoinEvent; import net.tfminecraft.rpcharacters.objects.RPCharacter; -import net.tfminecraft.rpcharacters.RPCharacters; +import net.tfminecraft.cooking.utils.PlayerActionContext; import net.tfminecraft.cooking.Cooking; public final class SaturationGuard implements Listener { @@ -22,7 +22,8 @@ public static void start() { } taskId = Bukkit.getScheduler().runTaskTimer(Cooking.plugin, () -> { for (Player player : Bukkit.getOnlinePlayers()) { - if (player.getSaturation() != 0f) { + PlayerActionContext context = PlayerActionContext.capture(player); + if (context != null && player.getSaturation() != 0f) { float before = player.getSaturation(); player.setSaturation(0f); NutritionLog.append("SATURATION", player, activeCharacter(player), @@ -43,6 +44,7 @@ public static void stop() { @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, @@ -54,8 +56,11 @@ public void onFoodLevelChange(FoodLevelChangeEvent event) { if (!(event.getEntity() instanceof Player player)) { return; } + PlayerActionContext context = PlayerActionContext.capture(player); + if (context == null) return; float eventSaturation = player.getSaturation(); Bukkit.getScheduler().runTask(Cooking.plugin, () -> { + if (!context.isCurrent()) return; float before = player.getSaturation(); player.setSaturation(0f); NutritionLog.append("SATURATION", player, activeCharacter(player), @@ -65,9 +70,7 @@ public void onFoodLevelChange(FoodLevelChangeEvent event) { } private static RPCharacter activeCharacter(Player player) { - if (!Bukkit.getPluginManager().isPluginEnabled("RPCharacters")) { - return null; - } - return RPCharacters.getActiveCharacter(player); + NutritionContext context = NutritionContext.capture(player); + return context == null ? null : context.character(); } } diff --git a/src/main/java/net/tfminecraft/cooking/sausagemaker/SausageMakerHandler.java b/src/main/java/net/tfminecraft/cooking/sausagemaker/SausageMakerHandler.java index 47f87c0..fdce215 100644 --- a/src/main/java/net/tfminecraft/cooking/sausagemaker/SausageMakerHandler.java +++ b/src/main/java/net/tfminecraft/cooking/sausagemaker/SausageMakerHandler.java @@ -6,6 +6,7 @@ import java.util.Map; import java.util.UUID; +import org.bukkit.Location; import org.bukkit.Material; import org.bukkit.Particle; import org.bukkit.Sound; @@ -20,6 +21,7 @@ import net.tfminecraft.cooking.cache.ItemCache; import net.tfminecraft.cooking.item.FoodItem; import net.tfminecraft.cooking.utils.InventoryAdder; +import net.tfminecraft.cooking.utils.PlayerActionContext; import net.tfminecraft.interactiblefurniture.events.FurnitureBreakEvent; import net.tfminecraft.interactiblefurniture.events.FurnitureInteractEvent; import net.tfminecraft.interactiblefurniture.events.FurnitureSlotItemAddEvent; @@ -65,6 +67,11 @@ public void onInteract(FurnitureInteractEvent event) { } Player player = event.getPlayer(); + PlayerActionContext context = PlayerActionContext.capture(player); + if (context == null) { + event.setCancelled(true); + return; + } ItemStack hand = player.getInventory().getItemInMainHand(); boolean emptyHand = hand == null || hand.getType() == Material.AIR; FoodItem heldFood = emptyHand ? null : FoodItem.fromItem(hand); @@ -108,8 +115,11 @@ public void onInteract(FurnitureInteractEvent event) { } event.setCancelled(true); + if (!context.isCurrent()) { + return; + } player.swingMainHand(); - startCrank(furniture, player); + startCrank(furniture, context); } @EventHandler @@ -121,49 +131,59 @@ public void onBreak(FurnitureBreakEvent event) { } } - private void startCrank(Furniture furniture, Player player) { - furniture.getLoc().getWorld().playSound(furniture.getLoc(), Sound.BLOCK_WET_GRASS_BREAK, 1f, 0.8f); - furniture.getLoc().getWorld().spawnParticle( + private void startCrank(Furniture furniture, PlayerActionContext context) { + Location dropLocation = furniture.getLoc().clone(); + dropLocation.getWorld().playSound(dropLocation, Sound.BLOCK_WET_GRASS_BREAK, 1f, 0.8f); + dropLocation.getWorld().spawnParticle( Particle.ITEM, - furniture.getLoc().clone().add(0, 0.2, 0), + dropLocation.clone().add(0, 0.2, 0), 8, 0.15, 0.05, 0.15, 0.02, new ItemStack(Material.PORKCHOP)); - SausageMakerAnimation.playWobble(furniture, () -> finishCrank(furniture, player)); + SausageMakerAnimation.playWobble(furniture, () -> finishCrank(furniture, context, dropLocation)); startCooldown(furniture, ItemCache.sausageMakerCooldownTicks); } - private void finishCrank(Furniture furniture, Player player) { - if (!player.isOnline()) { + private void finishCrank(Furniture furniture, PlayerActionContext context, Location dropLocation) { + if (!context.isCurrent()) { return; } + Player player = context.player(); List meats = collectMeats(furniture); - if (meats.size() < MEAT_SLOTS.length) { + if (!context.isCurrent() || meats.size() < MEAT_SLOTS.length) { return; } // The paper was checked when the crank started; it could have been put away since. - if (!hasPaper(player)) { + boolean paperAvailable = hasPaper(player); + if (!context.isCurrent()) { + return; + } + if (!paperAvailable) { player.sendMessage("§cHold paper to casing."); return; } ItemStack chain = SausageItems.fromMeats(player, meats); + if (!context.isCurrent()) { + return; + } if (chain == null || chain.getType().isAir()) { player.sendMessage("§cCould not make sausage chain."); return; } - ItemStack leftover = InventoryAdder.addItem(player, chain); - if (leftover != null) { - furniture.getLoc().getWorld().dropItemNaturally(furniture.getLoc(), leftover); + if (!consumePaper(context)) { + return; } - clearMeatSlots(furniture); - consumePaper(player); markDirty(furniture); - furniture.getLoc().getWorld().playSound(furniture.getLoc(), Sound.ENTITY_ITEM_PICKUP, 1f, 1f); + ItemStack leftover = context.isCurrent() ? InventoryAdder.addItem(player, chain) : chain; + if (leftover != null) { + dropLocation.getWorld().dropItemNaturally(dropLocation, leftover); + } + dropLocation.getWorld().playSound(dropLocation, Sound.ENTITY_ITEM_PICKUP, 1f, 1f); } private static boolean tryFillFirstEmpty(Furniture furniture, Player player, ItemStack hand) { @@ -275,17 +295,27 @@ private static boolean hasPaper(Player player) { || CasingPaper.isCasing(inv.getItemInOffHand()); } - private static void consumePaper(Player player) { - PlayerInventory inv = player.getInventory(); + private static boolean consumePaper(PlayerActionContext context) { + PlayerInventory inv = context.player().getInventory(); ItemStack main = inv.getItemInMainHand(); - if (CasingPaper.isCasing(main)) { + boolean mainIsCasing = CasingPaper.isCasing(main); + if (!context.isCurrent()) { + return false; + } + if (mainIsCasing) { main.setAmount(main.getAmount() - 1); - return; + return true; } ItemStack off = inv.getItemInOffHand(); - if (CasingPaper.isCasing(off)) { + boolean offIsCasing = CasingPaper.isCasing(off); + if (!context.isCurrent()) { + return false; + } + if (offIsCasing) { off.setAmount(off.getAmount() - 1); + return true; } + return false; } private static boolean isMeatSlot(String slotId) { diff --git a/src/main/java/net/tfminecraft/cooking/utils/PlayerActionContext.java b/src/main/java/net/tfminecraft/cooking/utils/PlayerActionContext.java new file mode 100644 index 0000000..4cd4360 --- /dev/null +++ b/src/main/java/net/tfminecraft/cooking/utils/PlayerActionContext.java @@ -0,0 +1,23 @@ +package net.tfminecraft.cooking.utils; + +import org.bukkit.Bukkit; +import org.bukkit.entity.Player; +import net.tfminecraft.tlibs.character.CharacterSession; +import net.tfminecraft.tlibs.character.Characters; + +/** One player action, including standalone play without a character provider. */ +public record PlayerActionContext(Player player, CharacterSession session) { + public static PlayerActionContext capture(Player player) { + if (player == null) return null; + CharacterSession session = Characters.currentSession(player).orElse(null); + if (session == null && Characters.available()) return null; + PlayerActionContext context = new PlayerActionContext(player, session); + return context.isCurrent() ? context : null; + } + + public boolean isCurrent() { + return player.isOnline() && Bukkit.getPlayer(player.getUniqueId()) == player + && (session == null ? !Characters.available() + : session.ref().ownerId().equals(player.getUniqueId()) && Characters.isCurrent(session)); + } +} diff --git a/src/test/java/net/tfminecraft/cooking/CookingLifecycleTest.java b/src/test/java/net/tfminecraft/cooking/CookingLifecycleTest.java index a31c511..38dd462 100644 --- a/src/test/java/net/tfminecraft/cooking/CookingLifecycleTest.java +++ b/src/test/java/net/tfminecraft/cooking/CookingLifecycleTest.java @@ -198,6 +198,28 @@ class CookingLifecycleTest { verify(repository, times(1)).close(); } + @Test void startupInitializesPlayersAlreadyOnlineThroughTheReadySessionPath() { + var player = server.addPlayer(); + var lifecycle = staticMock(net.tfminecraft.cooking.nutrition.NutritionLifecycleListener.class); + loadPlugin(); + lifecycle.verifyNoInteractions(); + server.getScheduler().performOneTick(); + lifecycle.verify(() -> net.tfminecraft.cooking.nutrition.NutritionLifecycleListener.initializeCurrent(player)); + } + + @Test void missingCharacterApiDisablesBeforeRegisteringResources() { + var characters = staticMock(net.tfminecraft.tlibs.character.Characters.class); + characters.when(net.tfminecraft.tlibs.character.Characters::available) + .thenThrow(new NoClassDefFoundError("old TLibs")); + Cooking rejected = loadPlugin(); + assertFalse(rejected.isEnabled()); + assertNull(rejected.getHusbandryRepository()); + verify(items, never()).registerPathHandler(anyString(), any()); + repositories.verifyNoInteractions(); + rejected.onDisable(); + saturation.verifyNoInteractions(); + } + @Test void reloadRefreshesConfigStationsAndPeriodicNutritionAndHusbandryTasks() { Cooking plugin = loadPlugin(); server.getScheduler().performOneTick(); diff --git a/src/test/java/net/tfminecraft/cooking/crops/CropsCoverageTest.java b/src/test/java/net/tfminecraft/cooking/crops/CropsCoverageTest.java index 9c70f07..0d391e6 100644 --- a/src/test/java/net/tfminecraft/cooking/crops/CropsCoverageTest.java +++ b/src/test/java/net/tfminecraft/cooking/crops/CropsCoverageTest.java @@ -11,6 +11,7 @@ import net.tfminecraft.cooking.farming.FarmingCoverageTest.Plot; import net.tfminecraft.cooking.item.FoodItem; import net.tfminecraft.cooking.loader.ConversionLoader; +import net.tfminecraft.cooking.testsupport.TestCharacters; import net.tfminecraft.cooking.utils.*; import net.tfminecraft.simplefactions.map.fertility.FertilityProvinceResolver; import net.momirealms.customcrops.api.context.*; @@ -32,7 +33,10 @@ import org.bukkit.plugin.Plugin; import org.junit.jupiter.api.*; import org.junit.jupiter.api.io.TempDir; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.ValueSource; import org.mockbukkit.mockbukkit.MockBukkit; +import org.mockbukkit.mockbukkit.entity.PlayerMock; import org.mockito.MockedStatic; class CropsCoverageTest { @@ -200,7 +204,7 @@ class CropsCoverageTest { @Test void newlyConvertedHarvestDropsOnlyItsOverflowWhenInventoryIsFull(){ configureCustom();f.food("carrot",Material.BREAD);ConversionLoader.conversions.put("v.carrot","vegetable(type=carrot;origin=Carrot)");f.player.getInventory().setItem(0,new ItemStack(Material.CARROT,3));for(int i=1;i<36;i++)f.player.getInventory().setItem(i,new ItemStack(Material.STONE,64));Player full=f.fullStoragePlayer(); listener.onBreak(breakEvent(config("carrot"),new Location(f.world,0,65,0),full));f.player.getInventory().setItem(0,new ItemStack(Material.CARROT,5)); - try(MockedStatic bukkit=mockStatic(Bukkit.class,CALLS_REAL_METHODS)){bukkit.when(()->Bukkit.getPlayer(f.player.getUniqueId())).thenReturn(full);f.server.getScheduler().performOneTick();} + assertSame(full,f.server.getPlayer(full.getUniqueId()));f.server.getScheduler().performOneTick(); assertEquals(3,f.inventoryCount(Material.CARROT));assertEquals(2,f.droppedCount(Material.BREAD));assertEquals(0,f.inventoryCount(Material.BREAD)); } @@ -210,6 +214,102 @@ class CropsCoverageTest { assertEquals(3,f.inventoryCount(Material.CARROT));assertEquals(2,f.inventoryCount(Material.BREAD));assertEquals(1,f.inventoryCount(Material.STONE));assertEquals(1,f.inventoryCount(Material.WHEAT_SEEDS)); } + @ParameterizedTest + @ValueSource(strings={"current","switch-back","pending","provider-lost","provider-arrived","reconnect"}) + void delayedInventoryRewriteRequiresCapturedCharacterAndConnection(String change){ + prepareCarrotConversion();UUID characterId=UUID.randomUUID(); + try(TestCharacters characters=new TestCharacters(f.plugin)){ + characters.activate(f.player,characterId); + if(change.equals("provider-arrived"))characters.close(); + f.player.getInventory().setItem(0,new ItemStack(Material.CARROT,3)); + listener.onBreak(breakEvent(config("carrot"),new Location(f.world,0,65,0),f.player)); + f.player.getInventory().setItem(0,new ItemStack(Material.CARROT,5)); + switch(change){ + case "switch-back"->{characters.activate(f.player,UUID.randomUUID());characters.activate(f.player,characterId);} + case "pending"->characters.set(f.player,null); + case "provider-lost"->characters.close(); + case "provider-arrived"->characters.register(); + case "reconnect"->{ + f.server.getPlayerList().disconnectPlayer(f.player); + PlayerMock replacement=new PlayerMock(f.server,f.player.getName(),f.player.getUniqueId()); + f.server.addPlayer(replacement);replacement.getInventory().setItem(0,new ItemStack(Material.CARROT,5)); + assertTrue(f.player.isOnline());assertSame(replacement,f.server.getPlayer(f.player.getUniqueId())); + } + default->{ } + } + f.server.getScheduler().performOneTick(); + assertEquals(change.equals("current")?3:5,f.inventoryCount(Material.CARROT)); + assertEquals(change.equals("current")?2:0,f.inventoryCount(Material.BREAD)); + assertEquals(0,f.droppedCount(Material.BREAD)); + if(change.equals("reconnect"))assertEquals(new ItemStack(Material.CARROT,5),f.server.getPlayer(f.player.getUniqueId()).getInventory().getItem(0)); + } + } + + @Test void pendingCharacterSkipsInventoryRewriteWhileWorldDropsStillConvert(){ + prepareCarrotConversion();Location location=new Location(f.world,0,65,0); + try(TestCharacters characters=new TestCharacters(f.plugin)){ + listener.onBreak(breakEvent(config("carrot"),location,f.player)); + Item drop=f.world.dropItem(location.clone().add(0.5,0.5,0.5),new ItemStack(Material.CARROT,2)); + listener.onItemSpawn(new ItemSpawnEvent(drop)); + assertEquals(Material.BREAD,drop.getItemStack().getType()); + f.player.getInventory().setItem(0,new ItemStack(Material.CARROT,2)); + characters.activate(f.player,UUID.randomUUID()); + f.server.getScheduler().performOneTick(); + assertEquals(2,f.inventoryCount(Material.CARROT));assertEquals(0,f.inventoryCount(Material.BREAD)); + } + } + + @Test void reentrantConversionStagesAllChangesBeforeTouchingInventory(){ + prepareCarrotConversion();UUID originalId=UUID.randomUUID(); + try(TestCharacters characters=new TestCharacters(f.plugin); + MockedStatic conversion=mockStatic(CropHarvestItems.class)){ + characters.activate(f.player,originalId); + listener.onBreak(breakEvent(config("carrot"),new Location(f.world,0,65,0),f.player)); + f.player.getInventory().setItem(0,new ItemStack(Material.CARROT,2)); + f.player.getInventory().setItem(1,new ItemStack(Material.CARROT,3)); + int[] converted={0}; + conversion.when(()->CropHarvestItems.rewriteCustomDrop(any(),any(),anyInt())).thenAnswer(call->{ + ItemStack source=call.getArgument(0); + if(++converted[0]==2){ + assertEquals(new ItemStack(Material.CARROT,2),f.player.getInventory().getItem(0)); + characters.activate(f.player,UUID.randomUUID());characters.activate(f.player,originalId); + f.player.getInventory().setItem(0,new ItemStack(Material.STONE,7)); + f.player.getInventory().setItem(1,new ItemStack(Material.DIAMOND,4)); + } + return new ItemStack(Material.BREAD,source.getAmount()); + }); + f.server.getScheduler().performOneTick(); + assertEquals(2,converted[0]); + assertEquals(new ItemStack(Material.STONE,7),f.player.getInventory().getItem(0)); + assertEquals(new ItemStack(Material.DIAMOND,4),f.player.getInventory().getItem(1)); + assertEquals(0,f.inventoryCount(Material.BREAD));assertEquals(0,f.droppedCount(Material.BREAD)); + } + } + + @Test void sessionChangeDuringInsertionDropsRemainingHarvestAtCapturedLocation(){ + prepareCarrotConversion();Location originalLocation=f.player.getLocation().clone(); + try(TestCharacters characters=new TestCharacters(f.plugin); + MockedStatic adder=mockStatic(InventoryAdder.class)){ + characters.activate(f.player,UUID.randomUUID()); + listener.onBreak(breakEvent(config("carrot"),new Location(f.world,0,65,0),f.player)); + f.player.getInventory().setItem(0,new ItemStack(Material.CARROT,2)); + f.player.getInventory().setItem(1,new ItemStack(Material.CARROT,3)); + adder.when(()->InventoryAdder.addItem(eq(f.player),any(ItemStack.class))).thenAnswer(call->{ + characters.activate(f.player,UUID.randomUUID()); + f.player.teleport(new Location(f.world,40,70,40)); + f.player.getInventory().setItem(0,new ItemStack(Material.DIAMOND,4)); + return call.getArgument(1); + }); + f.server.getScheduler().performOneTick(); + adder.verify(()->InventoryAdder.addItem(eq(f.player),any(ItemStack.class)),times(1)); + verify(f.world,times(2)).dropItemNaturally(eq(originalLocation),argThat(item->item.getType()==Material.BREAD)); + assertEquals(5,f.droppedCount(Material.BREAD));assertEquals(0,f.inventoryCount(Material.BREAD)); + assertEquals(new ItemStack(Material.DIAMOND,4),f.player.getInventory().getItem(0)); + } + } + + private void prepareCarrotConversion(){configureCustom();f.food("carrot",Material.BREAD);ConversionLoader.conversions.put("v.carrot","vegetable(type=carrot;origin=Carrot)");} + private void configureCustom(){CropsConfig.apply(Map.of(),Map.of(),Map.of("carrot",new CropDefinition("carrot","customcrops","seed",0.5,Material.CARROTS),"wheat",new CropDefinition("wheat","vanilla","v.wheat_seeds",0.5,Material.WHEAT)));} private CropConfig config(String id){CropConfig config=mock(CropConfig.class);when(config.id()).thenReturn(id);return config;} private net.momirealms.customcrops.api.event.CropBreakEvent breakEvent(CropConfig config,Location location,Entity player){return new net.momirealms.customcrops.api.event.CropBreakEvent(player,null,config,"stage",location,null,null);} diff --git a/src/test/java/net/tfminecraft/cooking/cup/CupItemsCoverageTest.java b/src/test/java/net/tfminecraft/cooking/cup/CupItemsCoverageTest.java index dc07638..36da834 100644 --- a/src/test/java/net/tfminecraft/cooking/cup/CupItemsCoverageTest.java +++ b/src/test/java/net/tfminecraft/cooking/cup/CupItemsCoverageTest.java @@ -7,6 +7,7 @@ import java.util.HashMap; import java.util.List; import java.util.Map; +import java.util.UUID; import net.tfminecraft.cooking.Cooking; import net.tfminecraft.cooking.cache.ItemCache; import net.tfminecraft.cooking.item.FoodItem; @@ -17,6 +18,8 @@ import net.tfminecraft.cooking.loader.FoodLoader; import net.tfminecraft.cooking.loader.TrackLoader; import net.tfminecraft.cooking.quality.QualityConfig; +import net.tfminecraft.cooking.quality.OriginQualityResolver; +import net.tfminecraft.cooking.testsupport.TestCharacters; import net.tfminecraft.cooking.utils.ItemBuilder; import net.tfminecraft.tlibs.TLibs; import net.tfminecraft.tlibs.objects.api.ItemAPI; @@ -27,6 +30,8 @@ import org.junit.jupiter.api.AfterEach; import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.Test; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.ValueSource; import org.mockbukkit.mockbukkit.MockBukkit; import org.mockbukkit.mockbukkit.ServerMock; import org.mockbukkit.mockbukkit.entity.PlayerMock; @@ -150,6 +155,90 @@ class CupItemsCoverageTest { assertTrue(player.getInventory().getItemInMainHand().getType().isAir()); } + @ParameterizedTest + @ValueSource(strings={"current", "switch-back", "pending", "provider-lost", "provider-arrived", "reconnect", "offline"}) + void deferredMilkConversionBelongsToCapturedSessionAndConnection(String change) { + UUID originalCharacter=UUID.randomUUID(); + try (TestCharacters characters=new TestCharacters(MockBukkit.createMockPlugin())) { + characters.activate(player,originalCharacter); + if (change.equals("provider-arrived")) characters.close(); + PlayerBucketFillEvent event=milkFill(); + new MilkBucketConverter().onBucketFill(event); + assertNull(FoodItem.fromItem(player.getInventory().getItemInMainHand())); + + switch (change) { + case "switch-back" -> { + characters.activate(player,UUID.randomUUID()); + characters.activate(player,originalCharacter); + } + case "pending" -> characters.set(player,null); + case "provider-lost" -> characters.close(); + case "provider-arrived" -> characters.register(); + case "reconnect" -> { + // Keep the old object's online flag true to exercise connection identity. + server.getPlayerList().disconnectPlayer(player); + PlayerMock replacement=new PlayerMock(server,player.getName(),player.getUniqueId()); + server.addPlayer(replacement); + replacement.getInventory().setItemInMainHand(new ItemStack(Material.MILK_BUCKET,2)); + assertTrue(player.isOnline()); + assertNotSame(player,server.getPlayer(player.getUniqueId())); + } + case "offline" -> player.disconnect(); + default -> { } + } + + server.getScheduler().performOneTick(); + ItemStack held=player.getInventory().getItemInMainHand(); + assertEquals(2,held.getAmount()); + if (change.equals("current")) { + assertNotNull(FoodItem.fromItem(held)); + assertEquals(3,MilkBucketSnapshot.readQuality(held)); + } else { + assertEquals(new ItemStack(Material.MILK_BUCKET,2),held); + } + if (change.equals("reconnect")) { + assertEquals(new ItemStack(Material.MILK_BUCKET,2),server.getPlayer(player.getUniqueId()).getInventory().getItemInMainHand()); + } + } + } + + @Test void pendingCharacterCannotBeginConversionEvenIfReadyBeforeCallback() { + try (TestCharacters characters=new TestCharacters(MockBukkit.createMockPlugin())) { + PlayerBucketFillEvent event=milkFill(); + new MilkBucketConverter().onBucketFill(event); + verify(event,never()).getItemStack(); + characters.activate(player,UUID.randomUUID()); + server.getScheduler().performOneTick(); + assertEquals(new ItemStack(Material.MILK_BUCKET,2),player.getInventory().getItemInMainHand()); + } + } + + @Test void sessionChangeDuringQualityResolutionPreventsQueuedWrite() { + try (TestCharacters characters=new TestCharacters(MockBukkit.createMockPlugin()); + MockedStatic quality=mockStatic(OriginQualityResolver.class)) { + characters.activate(player,UUID.randomUUID()); + PlayerBucketFillEvent event=milkFill(); + quality.when(() -> OriginQualityResolver.resolve(eq(player),any(FoodItem.class))).thenAnswer(call -> { + characters.activate(player,UUID.randomUUID()); + return 3; + }); + new MilkBucketConverter().onBucketFill(event); + quality.verify(() -> OriginQualityResolver.resolve(eq(player),any(FoodItem.class))); + server.getScheduler().performOneTick(); + assertEquals(new ItemStack(Material.MILK_BUCKET,2),player.getInventory().getItemInMainHand()); + } + } + + private PlayerBucketFillEvent milkFill() { + ConversionLoader.conversions.put("v.milk_bucket","ingredient(type=milk_bucket;origin=Cow)"); + player.getInventory().setItemInMainHand(new ItemStack(Material.MILK_BUCKET,2)); + PlayerBucketFillEvent event=mock(PlayerBucketFillEvent.class); + when(event.getPlayer()).thenReturn(player); + when(event.getBucket()).thenReturn(Material.MILK_BUCKET); + when(event.getItemStack()).thenReturn(new ItemStack(Material.MILK_BUCKET)); + return event; + } + private FoodItem food(String id,Material material) { MemoryConfiguration config=new MemoryConfiguration(); config.set("name",id); FoodItem food=new FoodItem(id,config); food.setCategory("ingredient"); food.setModel(new FoodModel(new ItemStack(material))); diff --git a/src/test/java/net/tfminecraft/cooking/farming/FarmingCoverageTest.java b/src/test/java/net/tfminecraft/cooking/farming/FarmingCoverageTest.java index e99bfe1..599c35c 100644 --- a/src/test/java/net/tfminecraft/cooking/farming/FarmingCoverageTest.java +++ b/src/test/java/net/tfminecraft/cooking/farming/FarmingCoverageTest.java @@ -10,6 +10,7 @@ import java.nio.file.Path; import java.util.*; import java.util.concurrent.ThreadLocalRandom; +import java.util.concurrent.atomic.AtomicInteger; import java.util.function.Consumer; import java.util.logging.Logger; import net.tfminecraft.cooking.Cooking; @@ -18,12 +19,14 @@ import net.tfminecraft.cooking.item.model.FoodModel; import net.tfminecraft.cooking.loader.*; import net.tfminecraft.cooking.quality.QualityConfig; +import net.tfminecraft.cooking.testsupport.TestCharacters; import net.tfminecraft.cooking.utils.*; import net.tfminecraft.tlibs.TLibs; import net.tfminecraft.tlibs.objects.api.ItemAPI; import org.bukkit.*; import org.bukkit.block.*; import org.bukkit.block.data.Ageable; +import org.bukkit.block.data.BlockData; import org.bukkit.configuration.MemoryConfiguration; import org.bukkit.configuration.file.YamlConfiguration; import org.bukkit.enchantments.Enchantment; @@ -36,6 +39,8 @@ import org.bukkit.plugin.Plugin; import org.junit.jupiter.api.*; import org.junit.jupiter.api.io.TempDir; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.ValueSource; import org.mockbukkit.mockbukkit.MockBukkit; import org.mockbukkit.mockbukkit.ServerMock; import org.mockbukkit.mockbukkit.block.BlockMock; @@ -126,6 +131,272 @@ public class FarmingCoverageTest { ItemStack bread=Arrays.stream(f.player.getInventory().getStorageContents()).filter(Objects::nonNull).filter(i -> i.getType()==Material.BREAD).findFirst().orElseThrow(); assertEquals(3,bread.getAmount()); assertEquals(3,FoodItem.fromItem(bread).getQualityMin()); assertEquals(1,f.inventoryCount(Material.WHEAT_SEEDS)); } + @Test void pendingCharacterCannotHarvestButReadyCharacterCanCompleteTheSameCrop() { + try (TestCharacters characters = new TestCharacters(f.plugin)) { + BlockMock crop = harvestable(); + crop.setDrops(List.of(new ItemStack(Material.WHEAT, 2), new ItemStack(Material.WHEAT_SEEDS))); + f.holdHoe(); + AtomicInteger breaks = new AtomicInteger(); + f.on(BlockBreakEvent.class, event -> breaks.incrementAndGet()); + FarmHarvestService.harvestArea(f.player, f.tool(), hoe(), crop); + assertEquals(Material.WHEAT, crop.getType()); + assertEquals(0, breaks.get()); + assertEquals(0, ((Damageable) f.tool().getItemMeta()).getDamage()); + assertEquals(0, f.inventoryCount(Material.WHEAT)); + + characters.activate(f.player, UUID.randomUUID()); + FarmHarvestService.harvestArea(f.player, f.tool(), hoe(), crop); + f.server.getScheduler().performTicks(2); + assertEquals(1, breaks.get()); + assertEquals(2, f.inventoryCount(Material.WHEAT)); + assertEquals(1, ((Damageable) f.tool().getItemMeta()).getDamage()); + assertEquals(Material.WHEAT, crop.getType()); + assertEquals(0, ((Ageable) crop.getBlockData()).getAge()); + } + } + + @ParameterizedTest + @ValueSource(strings = {"pending", "aba", "reconnect", "provider-arrival", "provider-loss"}) + void staleReplantRefundsOnlyTheReservedSeedAtTheOriginalBlock(String change) { + try (TestCharacters characters = new TestCharacters(f.plugin)) { + UUID character = UUID.randomUUID(); + var origin = characters.activate(f.player, character); + if (change.equals("provider-arrival")) characters.close(); + BlockMock crop = harvestable(); + Location originalLocation = crop.getLocation().clone(); + crop.setDrops(List.of(new ItemStack(Material.WHEAT_SEEDS))); + f.holdHoe(); + AtomicInteger placements = new AtomicInteger(); + f.on(BlockPlaceEvent.class, event -> placements.incrementAndGet()); + assertTrue(FarmHarvestService.harvestBlock(f.player, f.tool(), hoe(), crop, wheat())); + WorldMock otherWorld = f.server.addSimpleWorld("successor"); + f.player.teleport(new Location(otherWorld, 50, 70, 50)); + PlayerMock current = f.player; + switch (change) { + case "pending" -> characters.set(f.player, null); + case "aba" -> { + characters.activate(f.player, UUID.randomUUID()); + characters.activate(f.player, character); + } + case "reconnect" -> { + f.player.disconnect(); + current = new PlayerMock(f.server, "Farmer", f.player.getUniqueId()); + f.server.addPlayer(current); + characters.set(current, origin); + assertSame(current, Bukkit.getPlayer(f.player.getUniqueId())); + } + case "provider-arrival" -> characters.register(); + case "provider-loss" -> characters.close(); + default -> fail("Unknown identity change"); + } + current.getInventory().setItemInMainHand(new ItemStack(Material.DIAMOND_HOE)); + f.server.getScheduler().performTicks(2); + assertEquals(Material.AIR, crop.getType()); + assertEquals(0, placements.get()); + assertEquals(1, f.droppedCount(Material.WHEAT_SEEDS)); + assertTrue(otherWorld.getEntitiesByClass(Item.class).isEmpty()); + verify(f.world).dropItemNaturally(eq(originalLocation), argThat(item -> item.getType() == Material.WHEAT_SEEDS && item.getAmount() == 1)); + assertEquals(Material.DIAMOND_HOE, current.getInventory().getItemInMainHand().getType()); + assertEquals(0, ((Damageable) current.getInventory().getItemInMainHand().getItemMeta()).getDamage()); + assertFalse(current.getInventory().contains(Material.WHEAT_SEEDS)); + } + } + + @Test void breakCallbackReturningToTheSameCharacterCannotHarvestOrDamageEitherTool() { + try (TestCharacters characters = new TestCharacters(f.plugin)) { + UUID character = UUID.randomUUID(); + characters.activate(f.player, character); + BlockMock crop = harvestable(), neighbour = f.crop(1, 65, 0, Material.WHEAT, true); + crop.setDrops(List.of(new ItemStack(Material.WHEAT, 3))); + f.holdHoe(); + ItemStack originalTool = f.tool(); + AtomicInteger breaks = new AtomicInteger(); + f.on(BlockBreakEvent.class, event -> { + breaks.incrementAndGet(); + characters.activate(f.player, UUID.randomUUID()); + characters.activate(f.player, character); + f.player.getInventory().setItemInMainHand(new ItemStack(Material.DIAMOND_HOE)); + }); + FarmHarvestService.harvestArea(f.player, originalTool, new FarmingToolDefinition("hoe", 1, 0), crop); + assertEquals(1, breaks.get()); + assertEquals(Material.WHEAT, crop.getType()); + assertEquals(Material.WHEAT, neighbour.getType()); + assertEquals(0, f.inventoryCount(Material.WHEAT)); + assertEquals(0, ((Damageable) originalTool.getItemMeta()).getDamage()); + assertEquals(0, ((Damageable) f.tool().getItemMeta()).getDamage()); + } + } + + @Test void conversionCallbackCannotAwardAnEarlierDropFromAnUnconsumedCrop() { + try (TestCharacters characters = new TestCharacters(f.plugin); + MockedStatic conversion = mockStatic(IngredientConverter.class)) { + characters.activate(f.player, UUID.randomUUID()); + BlockMock crop = harvestable(); + crop.setDrops(List.of(new ItemStack(Material.WHEAT, 3), new ItemStack(Material.WHEAT_SEEDS, 2))); + f.holdHoe(); + AtomicInteger conversions = new AtomicInteger(); + conversion.when(() -> IngredientConverter.convertHarvestDrop(eq(f.player), any(), anyDouble())).thenAnswer(call -> { + if (conversions.incrementAndGet() == 2) { + characters.activate(f.player, UUID.randomUUID()); + f.player.getInventory().setItemInMainHand(new ItemStack(Material.DIAMOND_HOE)); + } + return call.getArgument(1); + }); + FarmHarvestService.harvestArea(f.player, f.tool(), hoe(), crop); + f.server.getScheduler().performTicks(2); + assertEquals(2, conversions.get()); + assertEquals(Material.WHEAT, crop.getType()); + assertEquals(0, f.inventoryCount(Material.WHEAT)); + assertEquals(0, f.inventoryCount(Material.WHEAT_SEEDS)); + assertEquals(0, f.droppedCount(Material.WHEAT_SEEDS)); + assertEquals(0, ((Damageable) f.tool().getItemMeta()).getDamage()); + } + } + + @Test void qualityCallbackSwitchPreservesTheCropAndDoesNotChargeEitherTool() { + try (TestCharacters characters = new TestCharacters(f.plugin); + MockedStatic quality = mockStatic(CropHarvestQuality.class)) { + characters.activate(f.player, UUID.randomUUID()); + CropsConfig.apply(Map.of(), Map.of(), Map.of("wheat", + new CropDefinition("wheat", "vanilla", "v.wheat_seeds", 0.5, Material.WHEAT))); + BlockMock crop = harvestable(); + crop.setDrops(List.of(new ItemStack(Material.WHEAT, 3), new ItemStack(Material.WHEAT_SEEDS, 2))); + f.holdHoe(); + ItemStack originalTool = f.tool(); + quality.when(() -> CropHarvestQuality.roll("wheat", crop.getLocation(), f.player)).thenAnswer(call -> { + characters.activate(f.player, UUID.randomUUID()); + f.player.getInventory().setItemInMainHand(new ItemStack(Material.DIAMOND_HOE)); + return 2; + }); + FarmHarvestService.harvestArea(f.player, originalTool, hoe(), crop); + f.server.getScheduler().performTicks(2); + quality.verify(() -> CropHarvestQuality.roll("wheat", crop.getLocation(), f.player)); + assertEquals(Material.WHEAT, crop.getType()); + assertEquals(0, f.inventoryCount(Material.WHEAT)); + assertEquals(0, f.inventoryCount(Material.WHEAT_SEEDS)); + assertEquals(0, f.droppedCount(Material.WHEAT_SEEDS)); + assertEquals(0, ((Damageable) originalTool.getItemMeta()).getDamage()); + assertEquals(0, ((Damageable) f.tool().getItemMeta()).getDamage()); + } + } + + @Test void dropLookupSwitchCannotReserveASeedOrAwardTheSuccessor() { + try (TestCharacters characters = new TestCharacters(f.plugin)) { + characters.activate(f.player, UUID.randomUUID()); + BlockMock originalBlock = harvestable(); + Block crop = mock(Block.class, org.mockito.AdditionalAnswers.delegatesTo(originalBlock)); + ItemStack seeds = new ItemStack(Material.WHEAT_SEEDS, 2); + f.holdHoe(); + doAnswer(call -> { + characters.activate(f.player, UUID.randomUUID()); + f.player.getInventory().setItemInMainHand(new ItemStack(Material.DIAMOND_HOE)); + return List.of(new ItemStack(Material.WHEAT, 3), seeds); + }).when(crop).getDrops(any(ItemStack.class)); + assertFalse(FarmHarvestService.harvestBlock(f.player, f.tool(), hoe(), crop, wheat())); + f.server.getScheduler().performTicks(2); + assertEquals(Material.WHEAT, originalBlock.getType()); + assertEquals(2, seeds.getAmount(), "A stale drop lookup cannot reserve a replant seed"); + assertEquals(0, f.inventoryCount(Material.WHEAT)); + assertEquals(0, f.inventoryCount(Material.WHEAT_SEEDS)); + assertEquals(0, f.droppedCount(Material.WHEAT_SEEDS)); + assertEquals(0, ((Damageable) f.tool().getItemMeta()).getDamage()); + } + } + + @ParameterizedTest + @ValueSource(strings = {"type", "data"}) + void replantPhysicsSwitchRollsBackBeforePlacementAndRefundsTheSeed(String write) { + try (TestCharacters characters = new TestCharacters(f.plugin)) { + characters.activate(f.player, UUID.randomUUID()); + BlockMock originalBlock = harvestable(); + originalBlock.setDrops(List.of(new ItemStack(Material.WHEAT_SEEDS))); + Block crop = mock(Block.class, org.mockito.AdditionalAnswers.delegatesTo(originalBlock)); + Location originalLocation = originalBlock.getLocation().clone(); + AtomicInteger physics = new AtomicInteger(), placements = new AtomicInteger(); + f.on(BlockPhysicsEvent.class, event -> { + physics.incrementAndGet(); + characters.activate(f.player, UUID.randomUUID()); + f.player.getInventory().setItemInMainHand(new ItemStack(Material.DIAMOND_HOE)); + }); + f.on(BlockPlaceEvent.class, event -> placements.incrementAndGet()); + if (write.equals("type")) { + doAnswer(call -> { + Material material = call.getArgument(0); + originalBlock.setType(material); + if (material == Material.WHEAT) { + f.server.getPluginManager().callEvent(new BlockPhysicsEvent(originalBlock, originalBlock.getBlockData())); + } + return null; + }).when(crop).setType(any(Material.class)); + } else { + doAnswer(call -> { + originalBlock.setBlockData(call.getArgument(0)); + f.server.getPluginManager().callEvent(new BlockPhysicsEvent(originalBlock, originalBlock.getBlockData())); + return null; + }).when(crop).setBlockData(any(BlockData.class)); + } + f.holdHoe(); + assertTrue(FarmHarvestService.harvestBlock(f.player, f.tool(), hoe(), crop, wheat())); + f.server.getScheduler().performTicks(2); + assertEquals(1, physics.get()); + assertEquals(0, placements.get()); + assertEquals(Material.AIR, originalBlock.getType()); + assertEquals(1, f.droppedCount(Material.WHEAT_SEEDS)); + verify(f.world).dropItemNaturally(eq(originalLocation), argThat(item -> item.getType() == Material.WHEAT_SEEDS && item.getAmount() == 1)); + assertEquals(0, f.inventoryCount(Material.WHEAT_SEEDS)); + assertEquals(0, ((Damageable) f.tool().getItemMeta()).getDamage()); + } + } + + @Test void awardCallbackDropsRemainingCommittedHarvestWithoutChargingTheSuccessorTool() { + try (TestCharacters characters = new TestCharacters(f.plugin); + MockedStatic delivery = mockStatic(InventoryAdder.class)) { + characters.activate(f.player, UUID.randomUUID()); + BlockMock crop = harvestable(); + crop.setDrops(List.of(new ItemStack(Material.WHEAT, 3), new ItemStack(Material.WHEAT_SEEDS, 2))); + f.holdHoe(); + ItemStack originalTool = f.tool(); + delivery.when(() -> InventoryAdder.addItem(eq(f.player), any())).thenAnswer(call -> { + assertEquals(Material.AIR, crop.getType(), "The crop must be consumed before awarding any produce"); + characters.activate(f.player, UUID.randomUUID()); + f.player.getInventory().setItemInMainHand(new ItemStack(Material.DIAMOND_HOE)); + return call.getArgument(1); + }); + FarmHarvestService.harvestArea(f.player, originalTool, hoe(), crop); + f.server.getScheduler().performTicks(2); + delivery.verify(() -> InventoryAdder.addItem(eq(f.player), any()), times(1)); + assertEquals(Material.AIR, crop.getType()); + assertEquals(3, f.droppedCount(Material.WHEAT)); + assertEquals(2, f.droppedCount(Material.WHEAT_SEEDS)); + assertEquals(0, f.inventoryCount(Material.WHEAT)); + assertEquals(0, ((Damageable) originalTool.getItemMeta()).getDamage()); + assertEquals(0, ((Damageable) f.tool().getItemMeta()).getDamage()); + } + } + + @Test void placementCallbackSwitchRollsBackTheCropAndRefundsTheSeedInItsOriginalWorld() { + try (TestCharacters characters = new TestCharacters(f.plugin)) { + characters.activate(f.player, UUID.randomUUID()); + BlockMock crop = harvestable(); + Location originalLocation = crop.getLocation().clone(); + crop.setDrops(List.of(new ItemStack(Material.WHEAT_SEEDS))); + f.holdHoe(); + WorldMock otherWorld = f.server.addSimpleWorld("successor"); + f.on(BlockPlaceEvent.class, event -> { + characters.activate(f.player, UUID.randomUUID()); + f.player.teleport(new Location(otherWorld, 50, 70, 50)); + f.player.getInventory().setItemInMainHand(new ItemStack(Material.DIAMOND_HOE)); + }); + assertTrue(FarmHarvestService.harvestBlock(f.player, f.tool(), hoe(), crop, wheat())); + f.server.getScheduler().performTicks(2); + assertEquals(Material.AIR, crop.getType()); + assertEquals(1, f.droppedCount(Material.WHEAT_SEEDS)); + verify(f.world).dropItemNaturally(eq(originalLocation), argThat(item -> item.getType() == Material.WHEAT_SEEDS && item.getAmount() == 1)); + assertTrue(otherWorld.getEntitiesByClass(Item.class).isEmpty()); + assertEquals(0, ((Damageable) f.tool().getItemMeta()).getDamage()); + } + } + @Test void harvestRejectsInvalidImmatureAndProtectedInputsWithoutDrops() { BlockMock crop=f.crop(0,65,0,Material.WHEAT,false); f.holdHoe(); assertFalse(FarmHarvestService.harvestBlock(null,f.tool(),hoe(),crop,wheat())); assertFalse(FarmHarvestService.harvestBlock(f.player,null,hoe(),crop,wheat())); assertFalse(FarmHarvestService.harvestBlock(f.player,f.tool(),null,crop,wheat())); assertFalse(FarmHarvestService.harvestBlock(f.player,f.tool(),hoe(),null,wheat())); assertFalse(FarmHarvestService.harvestBlock(f.player,f.tool(),hoe(),crop,null)); assertFalse(FarmHarvestService.harvestBlock(f.player,f.tool(),hoe(),crop,wheat())); @@ -228,7 +499,7 @@ public static final class Plot implements AutoCloseable { public YamlConfiguration config=new YamlConfiguration(); public final ItemAPI items; private final MockedStatic tlibs; private final StaticConfigSnapshot settings; private final List previousFoods; private final List previousModels; private final Map previousConversions; public Plot(Path directory)throws Exception{ - this.directory=directory; server=MockBukkit.mock(); world=spy(new FieldWorld()); world.setName("field"); server.addWorld(world); player=server.addPlayer("Farmer"); player.teleport(new Location(world,0,65,0)); previous=Cooking.plugin; plugin=mock(Cooking.class); when(plugin.getName()).thenReturn("Cooking"); when(plugin.namespace()).thenReturn("cooking"); when(plugin.isEnabled()).thenReturn(true); when(plugin.getServer()).thenReturn(server); when(plugin.getLogger()).thenReturn(Logger.getLogger("Cooking-test")); when(plugin.getPluginLoader()).thenReturn(MockBukkit.createMockPlugin("CookingFixture").getPluginLoader()); Cooking.plugin=plugin; + this.directory=directory; server=MockBukkit.mock(); world=spy(new FieldWorld()); world.setName("field"); server.addWorld(world); player=spy(new PlayerMock(server,"Farmer")); server.addPlayer(player); player.teleport(new Location(world,0,65,0)); previous=Cooking.plugin; plugin=mock(Cooking.class); when(plugin.getName()).thenReturn("Cooking"); when(plugin.namespace()).thenReturn("cooking"); when(plugin.isEnabled()).thenReturn(true); when(plugin.getServer()).thenReturn(server); when(plugin.getLogger()).thenReturn(Logger.getLogger("Cooking-test")); when(plugin.getPluginLoader()).thenReturn(MockBukkit.createMockPlugin("CookingFixture").getPluginLoader()); Cooking.plugin=plugin; settings=new StaticConfigSnapshot(FarmingConfig.class,CropsConfig.class,CropPlantingRule.class,CropCustomCropsBridge.class,QualityConfig.class); QualityConfig.apply(1,5,null,null); CropPlantingRule.configure(null); CropsConfig.apply(Map.of(),Map.of(),Map.of()); previousFoods=FoodLoader.oList; FoodLoader.oList=new ArrayList<>(); previousModels=ModelLoader.models; ModelLoader.models=new ArrayList<>(); previousConversions=ConversionLoader.conversions; ConversionLoader.conversions=new LinkedHashMap<>(); config.loadFromString(""" @@ -253,8 +524,8 @@ public Plot(Path directory)throws Exception{ /** MockBukkit's addItem includes two phantom slots; model Paper's full storage boundary. */ public Player fullStoragePlayer(){ assertTrue(Arrays.stream(player.getInventory().getStorageContents()).allMatch(Objects::nonNull)); - Player wrapped=spy(player);PlayerInventory inventory=spy(player.getInventory());doReturn(inventory).when(wrapped).getInventory(); - doAnswer(call->{ItemStack[] input=(ItemStack[])call.getRawArguments()[0];HashMap remainder=new HashMap<>();for(int i=0;i{ItemStack[] input=(ItemStack[])call.getRawArguments()[0];HashMap remainder=new HashMap<>();for(int i=0;i void on(Class type,Consumer consumer){server.getPluginManager().registerEvent(type,new Listener(){},EventPriority.NORMAL,(listener,event)->consumer.accept(type.cast(event)),plugin);} diff --git a/src/test/java/net/tfminecraft/cooking/manager/BundleFoodStackerTest.java b/src/test/java/net/tfminecraft/cooking/manager/BundleFoodStackerTest.java index a824ce0..8654bec 100644 --- a/src/test/java/net/tfminecraft/cooking/manager/BundleFoodStackerTest.java +++ b/src/test/java/net/tfminecraft/cooking/manager/BundleFoodStackerTest.java @@ -3,14 +3,17 @@ import static org.junit.jupiter.api.Assertions.assertEquals; import static org.junit.jupiter.api.Assertions.assertNull; import static org.junit.jupiter.api.Assertions.assertSame; +import static org.junit.jupiter.api.Assertions.assertTrue; import static org.mockito.Mockito.mock; import static org.mockito.Mockito.when; import java.util.ArrayList; import java.util.List; +import java.util.UUID; import org.bukkit.Material; import org.bukkit.configuration.MemoryConfiguration; +import org.bukkit.entity.HumanEntity; import org.bukkit.event.inventory.ClickType; import org.bukkit.event.inventory.InventoryAction; import org.bukkit.event.inventory.InventoryClickEvent; @@ -21,6 +24,8 @@ import org.junit.jupiter.api.AfterEach; import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.Test; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.ValueSource; import org.mockbukkit.mockbukkit.MockBukkit; import org.mockbukkit.mockbukkit.ServerMock; import org.mockbukkit.mockbukkit.entity.PlayerMock; @@ -31,6 +36,7 @@ import net.tfminecraft.cooking.item.tag.TagTrack; import net.tfminecraft.cooking.loader.FoodLoader; import net.tfminecraft.cooking.loader.TrackLoader; +import net.tfminecraft.cooking.testsupport.TestCharacters; import net.tfminecraft.cooking.utils.ItemBuilder; /** Equal foods put into a bundle stack there as they do in an inventory, keeping the older data. */ @@ -242,4 +248,84 @@ void onlyClicksInvolvingABundleScheduleAMerge() { new BundleFoodStacker(); } + + @ParameterizedTest + @ValueSource(strings={"current", "switch-back", "pending", "provider-lost", "provider-arrived", "reconnect", "offline"}) + void delayedMergesRequireCapturedSessionAndConnection(String change) { + List tasks = new ArrayList<>(); + BundleFoodStacker stacker = new BundleFoodStacker(tasks::add); + Inventory chest = server.createInventory(null, 27); + var view = player.openInventory(chest); + ItemStack cursor = bundle(beef(1, 0, 0), beef(1, 0, 20)); + ItemStack slot = bundle(beef(2, 0, 0), beef(2, 0, 20)); + player.setItemOnCursor(cursor); + chest.setItem(0, slot); + UUID characterId = UUID.randomUUID(); + try (TestCharacters characters = new TestCharacters(MockBukkit.createMockPlugin())) { + characters.activate(player, characterId); + if (change.equals("provider-arrived")) characters.close(); + stacker.onClick(new InventoryClickEvent(view, SlotType.CONTAINER, 0, ClickType.RIGHT, InventoryAction.PICKUP_HALF)); + assertEquals(1, tasks.size()); + switch (change) { + case "switch-back" -> { + characters.activate(player, UUID.randomUUID()); + characters.activate(player, characterId); + } + case "pending" -> characters.set(player, null); + case "provider-lost" -> characters.close(); + case "provider-arrived" -> characters.register(); + case "reconnect" -> { + server.getPlayerList().disconnectPlayer(player); + PlayerMock replacement = new PlayerMock(server, player.getName(), player.getUniqueId()); + server.addPlayer(replacement); + replacement.openInventory(chest); + replacement.setItemOnCursor(cursor); + assertTrue(player.isOnline()); + assertSame(replacement, server.getPlayer(player.getUniqueId())); + } + case "offline" -> player.disconnect(); + default -> { } + } + tasks.forEach(Runnable::run); + if (change.equals("current")) { + assertEquals(1, contents(player.getItemOnCursor()).size()); + assertEquals(2, contents(player.getItemOnCursor()).getFirst().getAmount()); + assertEquals(1, contents(chest.getItem(0)).size()); + assertEquals(4, contents(chest.getItem(0)).getFirst().getAmount()); + } else { + assertEquals(cursor, player.getItemOnCursor()); + assertEquals(slot, chest.getItem(0)); + } + if (change.equals("reconnect")) assertEquals(cursor, server.getPlayer(player.getUniqueId()).getItemOnCursor()); + } + } + + @Test + void pendingPlayerDoesNotScheduleAMergeEvenIfReadyBeforeNextTick() { + List tasks = new ArrayList<>(); + BundleFoodStacker stacker = new BundleFoodStacker(tasks::add); + Inventory chest = server.createInventory(null, 27); + var view = player.openInventory(chest); + ItemStack original = bundle(beef(1, 0, 0), beef(1, 0, 20)); + chest.setItem(0, original); + try (TestCharacters characters = new TestCharacters(MockBukkit.createMockPlugin())) { + stacker.onClick(new InventoryClickEvent(view, SlotType.CONTAINER, 0, ClickType.RIGHT, InventoryAction.PICKUP_HALF)); + characters.activate(player, UUID.randomUUID()); + tasks.forEach(Runnable::run); + assertTrue(tasks.isEmpty()); + assertEquals(original, chest.getItem(0)); + } + } + + @Test + void nonPlayerClickDoesNotScheduleAMerge() { + List tasks = new ArrayList<>(); + InventoryClickEvent event = mock(InventoryClickEvent.class); + HumanEntity actor = mock(HumanEntity.class); + ItemStack cursor = bundle(beef(1, 0, 0), beef(1, 0, 20)); + when(event.getWhoClicked()).thenReturn(actor); + when(event.getCursor()).thenReturn(cursor); + new BundleFoodStacker(tasks::add).onClick(event); + assertTrue(tasks.isEmpty()); + } } diff --git a/src/test/java/net/tfminecraft/cooking/manager/CommandManagerCoverageTest.java b/src/test/java/net/tfminecraft/cooking/manager/CommandManagerCoverageTest.java index e0bd422..0c6010d 100644 --- a/src/test/java/net/tfminecraft/cooking/manager/CommandManagerCoverageTest.java +++ b/src/test/java/net/tfminecraft/cooking/manager/CommandManagerCoverageTest.java @@ -55,6 +55,7 @@ @SuppressWarnings("deprecation") class CommandManagerCoverageTest { + private net.tfminecraft.cooking.testsupport.TestCharacters identities; private final CommandManager commands = new CommandManager(); private final Command command = mock(Command.class); private final List> statics = new ArrayList<>(); @@ -71,6 +72,7 @@ class CommandManagerCoverageTest { previousPlugin = Cooking.plugin; plugin = mock(Cooking.class); Cooking.plugin = plugin; + identities = new net.tfminecraft.cooking.testsupport.TestCharacters(plugin); InteractibleFurniture integration = mock(InteractibleFurniture.class); FurnitureManager manager = mock(FurnitureManager.class); when(integration.getFurnitureManager()).thenReturn(manager); @@ -79,6 +81,7 @@ class CommandManagerCoverageTest { } @AfterEach void tearDown() { + identities.close(); MockBukkit.unmock(); for (int i = statics.size() - 1; i >= 0; i--) statics.get(i).close(); Cooking.plugin = previousPlugin; @@ -144,6 +147,7 @@ class CommandManagerCoverageTest { run("food", "Admin", "2"); assertEquals(List.of("§cThat player has no active character."), messages()); RPCharacter character = mock(RPCharacter.class); + ready(player, character); characters.when(() -> RPCharacters.getActiveCharacter(player)).thenReturn(character); run("food", "Admin", "NaN"); assertEquals(List.of("§cFood must be a whole number."), messages()); @@ -158,6 +162,7 @@ class CommandManagerCoverageTest { PlayerMock target = server.addPlayer("Target"); target.setOp(true); RPCharacter character = mock(RPCharacter.class); + ready(target, character); AtomicInteger food = new AtomicInteger(80); when(character.getFoodValue()).thenAnswer(call -> food.get()); doAnswer(call -> { food.set(call.getArgument(0)); return null; }).when(character).setFoodValue(anyInt()); @@ -172,7 +177,7 @@ class CommandManagerCoverageTest { assertEquals(100, food.get()); verify(manager).savePlayer(target); - display.verify(() -> NutritionDisplayService.sync(target, character, "admin")); + display.verify(() -> NutritionDisplayService.sync(argThat(context -> context.character() == character && context.player() == target), eq("admin"))); assertEquals(List.of("§aSet Target's food from 80 to 100."), messages()); assertEquals("§eYour food was set to 100 by Admin.", target.nextMessage()); run(target, "food", "Target", "0"); @@ -181,6 +186,42 @@ class CommandManagerCoverageTest { assertNull(target.nextMessage()); } + @Test void foodCommandStopsAfterSaveOrDisplaySwitchesTheTargetSession() { + RPCharacter character = new RPCharacter(player); + ready(player, character); + PlayerManager manager = mock(PlayerManager.class); + MockedStatic characters = staticMock(RPCharacters.class); + characters.when(() -> RPCharacters.getActiveCharacter(player)).thenReturn(character); + characters.when(RPCharacters::getPlayerManager).thenReturn(manager); + MockedStatic display = staticMock(NutritionDisplayService.class); + doAnswer(call -> { + identities.activate(player, character.getId()); + return null; + }).when(manager).savePlayer(player); + run("food", "Admin", "100"); + assertEquals(100, character.getFoodValue()); + assertTrue(messages().isEmpty()); + 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()); + run("food", "Admin", "130"); + assertEquals(120, character.getFoodValue()); + assertEquals(List.of("§cThat player has no active character."), messages()); + } + + private void ready(PlayerMock owner, RPCharacter character) { + if (!server.getPluginManager().isPluginEnabled("RPCharacters")) MockBukkit.createMockPlugin("RPCharacters"); + if (mockingDetails(character).isMock()) { + when(character.getOwner()).thenReturn(owner); + when(character.getId()).thenReturn(UUID.randomUUID().toString()); + } + identities.activate(owner, character.getId()); + } + @Test void husbandryRejectsUnauthorizedSendersAndMalformedSpawnArguments() { player.setOp(false); run("husbandry"); diff --git a/src/test/java/net/tfminecraft/cooking/manager/MealManagersCoverageTest.java b/src/test/java/net/tfminecraft/cooking/manager/MealManagersCoverageTest.java index b44a7eb..af9c72f 100644 --- a/src/test/java/net/tfminecraft/cooking/manager/MealManagersCoverageTest.java +++ b/src/test/java/net/tfminecraft/cooking/manager/MealManagersCoverageTest.java @@ -382,13 +382,13 @@ void attemptedPickupWithNoVanillaRoomOffersFoodToItsAgedStack() { conversions.attemptPickup(new PlayerAttemptPickupItemEvent(env.player, item, 1)); assertEquals(3, env.player.getInventory().getItem(4).getAmount(), "Paper fires its own pickup when some of it fits"); Item other = mock(Item.class); when(other.getItemStack()).thenReturn(ground.clone()); when(other.getOwner()).thenReturn(UUID.randomUUID()); - when(env.player.getCanPickupItems()).thenReturn(true); conversions.attemptPickup(new PlayerAttemptPickupItemEvent(env.player, other, 2)); + doReturn(true).when(env.player).getCanPickupItems(); conversions.attemptPickup(new PlayerAttemptPickupItemEvent(env.player, other, 2)); Item stone = mock(Item.class); when(stone.getItemStack()).thenReturn(new ItemStack(Material.STONE)); conversions.attemptPickup(new PlayerAttemptPickupItemEvent(env.player, stone, 1)); - when(env.player.getCanPickupItems()).thenReturn(false); + doReturn(false).when(env.player).getCanPickupItems(); conversions.attemptPickup(new PlayerAttemptPickupItemEvent(env.player, item, 2)); assertEquals(3, env.player.getInventory().getItem(4).getAmount(), "A player who cannot pick items up is left alone"); - when(env.player.getCanPickupItems()).thenReturn(true); conversions.attemptPickup(new PlayerAttemptPickupItemEvent(env.player, item, 2)); + doReturn(true).when(env.player).getCanPickupItems(); conversions.attemptPickup(new PlayerAttemptPickupItemEvent(env.player, item, 2)); assertEquals(5, env.player.getInventory().getItem(4).getAmount()); verify(item).remove(); verify(other, never()).remove(); } diff --git a/src/test/java/net/tfminecraft/cooking/nutrition/NutritionListenersCoverageTest.java b/src/test/java/net/tfminecraft/cooking/nutrition/NutritionListenersCoverageTest.java index 2caf86c..1a095eb 100644 --- a/src/test/java/net/tfminecraft/cooking/nutrition/NutritionListenersCoverageTest.java +++ b/src/test/java/net/tfminecraft/cooking/nutrition/NutritionListenersCoverageTest.java @@ -7,6 +7,7 @@ import java.util.HashMap; import java.util.List; import java.util.Map; +import java.util.UUID; import java.util.logging.Logger; import net.bytebuddy.ByteBuddy; import net.bytebuddy.asm.MemberRemoval; @@ -23,19 +24,21 @@ import net.tfminecraft.cooking.loader.FoodLoader; import net.tfminecraft.cooking.loader.TrackLoader; import net.tfminecraft.cooking.manager.PlateManager; +import net.tfminecraft.cooking.testsupport.TestCharacters; import net.tfminecraft.cooking.utils.Keys; import net.tfminecraft.interactiblefurniture.InteractibleFurniture; import net.tfminecraft.interactiblefurniture.events.FurnitureInteractEvent; import net.tfminecraft.interactiblefurniture.furniture.Furniture; import net.tfminecraft.interactiblefurniture.furniture.PlacedSlot; import net.tfminecraft.rpcharacters.RPCharacters; -import net.tfminecraft.rpcharacters.lifecycle.CharacterActivatedEvent; import net.tfminecraft.rpcharacters.managers.PlayerManager; import net.tfminecraft.rpcharacters.objects.RPCharacter; import net.tfminecraft.simplefactions.war.battle.engine.core.Battle; import net.tfminecraft.simplefactions.war.battle.engine.core.BattleManager; import net.tfminecraft.simplefactions.war.battle.engine.core.BattleSide; import net.tfminecraft.tlibs.TLibs; +import net.tfminecraft.tlibs.character.CharacterSession; +import net.tfminecraft.tlibs.character.CharacterSessionStartEvent; import net.tfminecraft.tlibs.objects.api.ItemAPI; import org.bukkit.GameMode; import org.bukkit.Material; @@ -56,6 +59,8 @@ import org.junit.jupiter.api.AfterEach; import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.Test; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.ValueSource; import org.mockbukkit.mockbukkit.MockBukkit; import org.mockbukkit.mockbukkit.ServerMock; import org.mockbukkit.mockbukkit.entity.PlayerMock; @@ -74,12 +79,16 @@ class NutritionListenersCoverageTest { private List tracks; private List battles; private String previousWater, previousMilk, previousEmpty, previousBowl; + private TestCharacters characters; @BeforeEach void setup() { server=MockBukkit.mock(); previousPlugin=Cooking.plugin; Cooking.plugin=mock(Cooking.class); when(Cooking.plugin.getName()).thenReturn("Cooking"); when(Cooking.plugin.namespace()).thenReturn("cooking"); when(Cooking.plugin.getLogger()).thenReturn(mock(Logger.class)); - player=server.addPlayer(); character=new RPCharacter(player); character.setFoodValue(100); + player=spy(new PlayerMock(server,"Eater")); server.addPlayer(player); + character=new RPCharacter(player); character.setFoodValue(100); + characters=new TestCharacters(MockBukkit.createMockPlugin("CharacterProvider")); + characters.activate(player,character.getId()); players=mock(PlayerManager.class); rp=mockStatic(RPCharacters.class); rp.when(()->RPCharacters.getActiveCharacter(player)).thenReturn(character); rp.when(RPCharacters::getPlayerManager).thenReturn(players); @@ -102,21 +111,18 @@ class NutritionListenersCoverageTest { ItemCache.cupOfWater=previousWater; ItemCache.cupOfMilk=previousMilk; ItemCache.emptyCup=previousEmpty; FurnitureCache.bowl=previousBowl; BattleManager.get().clear(); BattleManager.get().addAll(battles); - rp.close(); tlibs.close(); MockBukkit.unmock(); Cooking.plugin=previousPlugin; + characters.close(); rp.close(); tlibs.close(); MockBukkit.unmock(); Cooking.plugin=previousPlugin; } @Test void lifecycleEventsRestoreHudAndResetDeathFoodOutsideStartedBattles() { MockBukkit.createMockPlugin("RPCharacters"); NutritionLifecycleListener listener=new NutritionLifecycleListener(); - listener.onJoin(new PlayerJoinEvent(player,"joined")); - assertEquals(20,player.getFoodLevel()); server.getScheduler().performTicks(2); + NutritionLifecycleListener.initializeCurrent(player); assertEquals(10,player.getFoodLevel()); PlayerRespawnEvent respawn=mock(PlayerRespawnEvent.class); when(respawn.getPlayer()).thenReturn(player); character.setFoodValue(50); listener.onPlayerRespawn(respawn); assertEquals(5,player.getFoodLevel()); - listener.onCharacterActivated(new CharacterActivatedEvent(null,player.getUniqueId(),character,null)); - listener.onCharacterActivated(new CharacterActivatedEvent(player,player.getUniqueId(),null,null)); character.setRawDietScore(20); - listener.onCharacterActivated(new CharacterActivatedEvent(player,player.getUniqueId(),character,null)); + 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); @@ -131,16 +137,17 @@ class NutritionListenersCoverageTest { } @Test void battleLookupRequiresAnEnabledStartedBattleAndToleratesUnavailableIntegrations() { + UUID playerId = player.getUniqueId(); assertFalse(BattleFoodGate.inStartedBattle(null)); assertFalse(BattleFoodGate.inStartedBattle(player)); Plugin factions=MockBukkit.createMockPlugin("SimpleFactions"); assertFalse(BattleFoodGate.inStartedBattle(player)); Battle battle=mock(Battle.class); - when(battle.getSideByMemberId(player.getUniqueId())).thenReturn(mock(BattleSide.class)); + when(battle.getSideByMemberId(playerId)).thenReturn(mock(BattleSide.class)); BattleManager.addBattle(battle); assertFalse(BattleFoodGate.inStartedBattle(player)); when(battle.hasStarted()).thenReturn(true); assertTrue(BattleFoodGate.inStartedBattle(player)); - doThrow(new NoClassDefFoundError("older dependency")).when(battle).getSideByMemberId(player.getUniqueId()); + doThrow(new NoClassDefFoundError("older dependency")).when(battle).getSideByMemberId(playerId); assertFalse(BattleFoodGate.inStartedBattle(player)); - doThrow(new IllegalStateException("offline")).when(battle).getSideByMemberId(player.getUniqueId()); + doThrow(new IllegalStateException("offline")).when(battle).getSideByMemberId(playerId); assertFalse(BattleFoodGate.inStartedBattle(player)); server.getPluginManager().disablePlugin(factions); assertFalse(BattleFoodGate.inStartedBattle(player)); } @@ -171,7 +178,7 @@ class NutritionListenersCoverageTest { FoodLevelChangeEvent event=new FoodLevelChangeEvent(player,1); guard.onFoodLevelChange(event); assertTrue(event.isCancelled()); server.getScheduler().performOneTick(); assertEquals(10,player.getFoodLevel()); - PlayerMock duringSync=spy(player); + PlayerMock duringSync=player; doAnswer(call->{ FoodLevelChangeEvent nested=new FoodLevelChangeEvent(duringSync,call.getArgument(0)); guard.onFoodLevelChange(nested); assertFalse(nested.isCancelled()); return null; @@ -225,13 +232,23 @@ class NutritionListenersCoverageTest { assertEquals(100,character.getFoodValue()); PlayerItemConsumeEvent raw=consume(stack("raw",false,"meat",Material.BEEF),EquipmentSlot.HAND); listener.onConsume(raw); assertTrue(raw.isCancelled()); assertTrue(player.nextMessage().contains("prepared")); - PlayerItemConsumeEvent cooked=consume(stack("cooked",true,"meat",Material.COOKED_BEEF),EquipmentSlot.HAND); - listener.onConsume(cooked); assertFalse(cooked.isCancelled()); assertEquals(110,character.getFoodValue()); + ItemStack cookedStack=stack("cooked",true,"meat",Material.COOKED_BEEF); cookedStack.setAmount(2); + player.getInventory().setItemInMainHand(cookedStack); + PlayerItemConsumeEvent cooked=consume(cookedStack,EquipmentSlot.HAND); + listener.onConsume(cooked); assertFalse(cooked.isCancelled()); + assertEquals(100,character.getFoodValue()); verifyNoInteractions(players); + assertEquals(2,player.getInventory().getItemInMainHand().getAmount()); + spendOne(EquipmentSlot.HAND); // Vanilla commits consumption after the event returns. + server.getScheduler().performOneTick(); + assertEquals(110,character.getFoodValue()); + assertEquals(1,player.getInventory().getItemInMainHand().getAmount()); + verify(players).savePlayer(player); } @Test void bowlEatingRequiresAnEmptyHandAndConsumesOnlyEdibleSoup() { MockBukkit.createMockPlugin("RPCharacters"); - Furniture bowl=mock(Furniture.class); when(bowl.getId()).thenReturn("bowl"); when(bowl.getLoc()).thenReturn(player.getLocation()); + var bowlLocation=player.getLocation(); + Furniture bowl=mock(Furniture.class); when(bowl.getId()).thenReturn("bowl"); when(bowl.getLoc()).thenReturn(bowlLocation); Map slots=new HashMap<>(); when(bowl.getActiveSlots()).thenReturn(slots); when(bowl.hasActiveSlot(anyString())).thenAnswer(call->slots.containsKey(call.getArgument(0))); PlateManager plates=mock(PlateManager.class); BowlEatHandler listener=new BowlEatHandler(plates); @@ -295,7 +312,10 @@ class NutritionListenersCoverageTest { player.getInventory().setItem(3,new ItemStack(Material.GLASS_BOTTLE)); player.getInventory().setHeldItemSlot(4); server.getScheduler().performOneTick(); assertEquals(Material.BOWL,player.getInventory().getItem(3).getType()); ItemStack milk=stack("cup_of_milk",true,"drink",Material.POTION); - listener.onDrink(consume(milk,EquipmentSlot.OFF_HAND)); player.getInventory().setItemInOffHand(new ItemStack(Material.GLASS_BOTTLE)); + player.getInventory().setItemInOffHand(milk); + listener.onDrink(consume(milk,EquipmentSlot.OFF_HAND)); + assertEquals(100,character.getFoodValue()); verifyNoInteractions(players); + player.getInventory().setItemInOffHand(new ItemStack(Material.GLASS_BOTTLE)); server.getScheduler().performOneTick(); assertEquals(Material.BOWL,player.getInventory().getItemInOffHand().getType()); assertEquals(110,character.getFoodValue()); ItemStack untrackedMilk=new ItemStack(Material.HONEY_BOTTLE); @@ -306,6 +326,336 @@ class NutritionListenersCoverageTest { server.getScheduler().performOneTick(); assertEquals(Material.GLASS_BOTTLE,player.getInventory().getItemInMainHand().getType()); } + @Test void staleStartCannotInitializeAReplacementGenerationOfTheSameCharacter() { + MockBukkit.createMockPlugin("RPCharacters"); + NutritionLifecycleListener listener = new NutritionLifecycleListener(); + CharacterSession original = characters.activate(player, character.getId()); + characters.activate(player, UUID.randomUUID()); + CharacterSession replacement = characters.activate(player, character.getId()); + character.setRawDietScore(40); player.setFoodLevel(17); player.setSaturation(6); + + listener.onCharacterStart(new CharacterSessionStartEvent(player, original)); + assertEquals(17, player.getFoodLevel()); assertEquals(6, player.getSaturation()); + assertNull(character.getLastDietTierId()); verifyNoInteractions(players); + characters.set(player, null); + listener.onCharacterStart(new CharacterSessionStartEvent(player, replacement)); + assertEquals(17, player.getFoodLevel()); verifyNoInteractions(players); + characters.set(player, replacement); + listener.onCharacterStart(new CharacterSessionStartEvent(player, replacement)); + assertEquals(10, player.getFoodLevel()); assertEquals(0, player.getSaturation()); + assertNotNull(character.getLastDietTierId()); verify(players).savePlayer(player); + } + + @Test void deathAndRespawnWaitForSharedReadinessDespiteLoadedRpcData() { + MockBukkit.createMockPlugin("RPCharacters"); + characters.set(player, null); + character.setFoodValue(15); player.setFoodLevel(17); player.setSaturation(6); + NutritionLifecycleListener listener = new NutritionLifecycleListener(); + 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); + + assertEquals(15, character.getFoodValue()); assertEquals(17, player.getFoodLevel()); + assertEquals(6, player.getSaturation()); verifyNoInteractions(players); + } + + @Test void deathBattleLookupReentryCannotResetOrSaveTheReplacementSession() { + MockBukkit.createMockPlugin("RPCharacters"); + character.setFoodValue(15); + PlayerDeathEvent death = mock(PlayerDeathEvent.class); when(death.getEntity()).thenReturn(player); + try (MockedStatic gate = mockStatic(BattleFoodGate.class)) { + gate.when(() -> BattleFoodGate.inStartedBattle(player)).thenAnswer(call -> { + characters.activate(player, UUID.randomUUID()); + characters.activate(player, character.getId()); + return false; + }); + new NutritionLifecycleListener().onPlayerDeath(death); + } + assertEquals(15, character.getFoodValue()); verifyNoInteractions(players); + } + + @Test void pendingIdentityDoesNotQueueFoodOrSaturationWrites() { + MockBukkit.createMockPlugin("RPCharacters"); + characters.set(player, null); player.setFoodLevel(17); player.setSaturation(6); + FoodLevelChangeEvent food = new FoodLevelChangeEvent(player, 4); + new FoodLevelChangeGuard().onFoodLevelChange(food); + new SaturationGuard().onFoodLevelChange(new FoodLevelChangeEvent(player, 4)); + characters.activate(player, character.getId()); + server.getScheduler().performOneTick(); + assertTrue(food.isCancelled()); + assertEquals(17, player.getFoodLevel()); assertEquals(6, player.getSaturation()); + } + + @Test void deferredFoodSaturationAndCupWorkCannotFollowAnActorThroughAtoBtoA() { + MockBukkit.createMockPlugin("RPCharacters"); + ItemStack water = new ItemStack(Material.POTION); + when(api.getChecker().checkItemWithPath(water, "cup.water")).thenReturn(true); + player.getInventory().setHeldItemSlot(3); + new FoodLevelChangeGuard().onFoodLevelChange(new FoodLevelChangeEvent(player, 4)); + new SaturationGuard().onFoodLevelChange(new FoodLevelChangeEvent(player, 4)); + new DrinkConsumeListener().onDrink(consume(water, EquipmentSlot.HAND)); + characters.activate(player, UUID.randomUUID()); + characters.activate(player, character.getId()); + player.setFoodLevel(17); player.setSaturation(6); + player.getInventory().setItem(3, new ItemStack(Material.GLASS_BOTTLE)); + + server.getScheduler().performOneTick(); + + assertEquals(17, player.getFoodLevel()); assertEquals(6, player.getSaturation()); + assertEquals(Material.GLASS_BOTTLE, player.getInventory().getItem(3).getType()); + } + + @Test void standaloneSaturationWorkStopsIfAProviderArrivesBeforeItsCallback() { + characters.close(); + SaturationGuard guard = new SaturationGuard(); + player.setSaturation(5); + guard.onFoodLevelChange(new FoodLevelChangeEvent(player, 4)); + server.getScheduler().performOneTick(); assertEquals(0, player.getSaturation()); + player.setSaturation(5); + guard.onFoodLevelChange(new FoodLevelChangeEvent(player, 4)); + characters.register(); + server.getScheduler().performOneTick(); assertEquals(5, player.getSaturation()); + } + + @Test void milkBucketIsSpentBeforeNutritionCanSwitchToAReplacementBody() { + MockBukkit.createMockPlugin("RPCharacters"); + ItemStack milk = stack("milk_bucket", true, "drink", Material.MILK_BUCKET); + player.getInventory().setItemInOffHand(milk); + doAnswer(call -> { + assertEquals(Material.BUCKET, player.getInventory().getItemInOffHand().getType(), + "The outgoing character must already have spent the milk"); + characters.activate(player, UUID.randomUUID()); + player.getInventory().setItemInOffHand(new ItemStack(Material.MILK_BUCKET, 2)); + return null; + }).when(players).savePlayer(player); + + new DrinkConsumeListener().onMilkBucket(consume(milk, EquipmentSlot.OFF_HAND)); + + assertEquals(110, character.getFoodValue()); + assertEquals(Material.MILK_BUCKET, player.getInventory().getItemInOffHand().getType()); + assertEquals(2, player.getInventory().getItemInOffHand().getAmount()); + assertTrue(player.getInventory().all(Material.BUCKET).isEmpty()); + verify(players).savePlayer(player); + } + + @Test void aMissingMilkBucketCannotGrantNutritionAndCreativeMilkRemainsUnspent() { + MockBukkit.createMockPlugin("RPCharacters"); + DrinkConsumeListener listener = new DrinkConsumeListener(); + ItemStack milk = stack("milk_bucket", true, "drink", Material.MILK_BUCKET); + PlayerItemConsumeEvent missing = consume(milk, EquipmentSlot.OFF_HAND); + listener.onMilkBucket(missing); + assertTrue(missing.isCancelled()); + assertEquals(100, character.getFoodValue()); verifyNoInteractions(players); + + player.setGameMode(GameMode.CREATIVE); + player.getInventory().setItemInOffHand(milk); + listener.onMilkBucket(consume(milk, EquipmentSlot.OFF_HAND)); + assertEquals(110, character.getFoodValue()); + assertEquals(Material.MILK_BUCKET, player.getInventory().getItemInOffHand().getType()); + assertTrue(player.getInventory().all(Material.BUCKET).isEmpty()); + verify(players).savePlayer(player); + } + + @Test void emptyCupReplacementPrecedesNutritionAndCannotOverwriteTheSuccessor() { + MockBukkit.createMockPlugin("RPCharacters"); + ItemStack milk = stack("cup_of_milk", true, "drink", Material.POTION); + player.getInventory().setItemInOffHand(milk); + doAnswer(call -> { + assertEquals(Material.BOWL, player.getInventory().getItemInOffHand().getType(), + "The consumed milk must have become its empty cup before nutrition callbacks"); + characters.activate(player, UUID.randomUUID()); + player.getInventory().setItemInOffHand(new ItemStack(Material.POTION, 2)); + return null; + }).when(players).savePlayer(player); + + new DrinkConsumeListener().onDrink(consume(milk, EquipmentSlot.OFF_HAND)); + assertEquals(100, character.getFoodValue()); verifyNoInteractions(players); + // Simulate vanilla consuming the milk and leaving its bottle after the event. + player.getInventory().setItemInOffHand(new ItemStack(Material.GLASS_BOTTLE)); + server.getScheduler().performOneTick(); + + assertEquals(110, character.getFoodValue()); + assertEquals(Material.POTION, player.getInventory().getItemInOffHand().getType()); + assertEquals(2, player.getInventory().getItemInOffHand().getAmount()); + verify(players).savePlayer(player); + } + + @ParameterizedTest + @ValueSource(booleans = {true, false}) + void cancelledOrStaleFoodCallbacksCannotGrantNutrition(boolean cancelled) { + MockBukkit.createMockPlugin("RPCharacters"); + ItemStack cooked = stack("cooked_callback", true, "meat", Material.COOKED_BEEF); + cooked.setAmount(2); player.getInventory().setItemInMainHand(cooked); + PlayerItemConsumeEvent event = consume(cooked, EquipmentSlot.HAND); + new FoodConsumeListener().onConsume(event); + assertEquals(100, character.getFoodValue()); + if (cancelled) { + event.setCancelled(true); // A later listener vetoes vanilla consumption. + } else { + spendOne(EquipmentSlot.HAND); + characters.activate(player, UUID.randomUUID()); + characters.activate(player, character.getId()); + } + + server.getScheduler().performOneTick(); + + assertEquals(100, character.getFoodValue()); + assertEquals(0, VarietyService.current(player).meals()); + assertEquals(cancelled ? 2 : 1, player.getInventory().getItemInMainHand().getAmount()); + verifyNoInteractions(players); + } + + @ParameterizedTest + @ValueSource(booleans = {true, false}) + void cancelledOrStaleMilkCupCallbacksCannotGrantNutritionOrReplaceItems(boolean cancelled) { + MockBukkit.createMockPlugin("RPCharacters"); + ItemStack milk = stack("cup_of_milk", true, "drink", Material.POTION); + player.getInventory().setItemInOffHand(milk); + PlayerItemConsumeEvent event = consume(milk, EquipmentSlot.OFF_HAND); + new DrinkConsumeListener().onDrink(event); + assertEquals(100, character.getFoodValue()); + if (cancelled) { + event.setCancelled(true); + } else { + player.getInventory().setItemInOffHand(new ItemStack(Material.GLASS_BOTTLE)); + characters.activate(player, UUID.randomUUID()); + characters.activate(player, character.getId()); + } + + server.getScheduler().performOneTick(); + + assertEquals(100, character.getFoodValue()); + assertEquals(0, VarietyService.current(player).meals()); + assertEquals(cancelled ? Material.POTION : Material.GLASS_BOTTLE, + player.getInventory().getItemInOffHand().getType()); + verifyNoInteractions(players); + } + + @Test void bowlDetachesFoodBeforeNutritionCallbacksAndSkipsStaleActorFeedback() { + MockBukkit.createMockPlugin("RPCharacters"); + Furniture bowl = mock(Furniture.class); + var bowlLocation = player.getLocation(); + when(bowl.getId()).thenReturn("bowl"); when(bowl.getLoc()).thenReturn(bowlLocation); + PlacedSlot slot = mock(PlacedSlot.class); + when(slot.getCurrentItem()).thenReturn(stack("soup", true, "soup", Material.MUSHROOM_STEW)); + when(bowl.hasActiveSlot("food_item")).thenReturn(true); + when(bowl.getActiveSlots()).thenReturn(new HashMap<>(Map.of("food_item", slot))); + PlateManager plates = mock(PlateManager.class); + FurnitureInteractEvent event = mock(FurnitureInteractEvent.class); + when(event.getFurniture()).thenReturn(bowl); when(event.getPlayer()).thenReturn(player); + try (MockedStatic furniture = mockStatic(InteractibleFurniture.class)) { + InteractibleFurniture plugin = mock(InteractibleFurniture.class, RETURNS_DEEP_STUBS); + furniture.when(InteractibleFurniture::getInstance).thenReturn(plugin); + doAnswer(call -> { + verify(plates).clear(bowl); + verify(plugin.getFurnitureManager()).markDirty(bowl); + characters.activate(player, UUID.randomUUID()); + return null; + }).when(players).savePlayer(player); + + new BowlEatHandler(plates).onInteract(event); + + verify(plates).clear(bowl); verify(players).savePlayer(player); + verify(player, never()).swingMainHand(); + assertEquals(110, character.getFoodValue()); + } + } + + @Test void emptyCupBuilderReentryCannotOverwriteTheReplacementHandOrApplyNutrition() { + MockBukkit.createMockPlugin("RPCharacters"); + ItemStack milk = stack("cup_of_milk", true, "drink", Material.POTION); + player.getInventory().setItemInOffHand(milk); + when(api.getCreator().getItemFromPath("cup.empty")).thenAnswer(call -> { + characters.activate(player, UUID.randomUUID()); + player.getInventory().setItemInOffHand(new ItemStack(Material.DIAMOND, 2)); + return new ItemStack(Material.BOWL); + }); + try (MockedStatic nutrition = mockStatic(NutritionService.class)) { + new DrinkConsumeListener().onDrink(consume(milk, EquipmentSlot.OFF_HAND)); + player.getInventory().setItemInOffHand(new ItemStack(Material.GLASS_BOTTLE)); + server.getScheduler().performOneTick(); + + assertEquals(Material.DIAMOND, player.getInventory().getItemInOffHand().getType()); + assertEquals(2, player.getInventory().getItemInOffHand().getAmount()); + nutrition.verifyNoInteractions(); + verifyNoInteractions(players); + } + } + + @Test void pendingFoodIntakeIsCancelledAndCanBeRetriedAfterReadiness() { + MockBukkit.createMockPlugin("RPCharacters"); + ItemStack cooked = stack("pending_cooked", true, "meat", Material.COOKED_BEEF); + cooked.setAmount(2); player.getInventory().setItemInMainHand(cooked); + characters.set(player, null); + FoodConsumeListener listener = new FoodConsumeListener(); + PlayerItemConsumeEvent pending = consume(cooked, EquipmentSlot.HAND); + + listener.onConsume(pending); + + assertTrue(pending.isCancelled()); + assertEquals(2, player.getInventory().getItemInMainHand().getAmount()); + assertEquals(100, character.getFoodValue()); verifyNoInteractions(players); + characters.activate(player, character.getId()); + server.getScheduler().performOneTick(); + assertEquals(100, character.getFoodValue()); verifyNoInteractions(players); + + PlayerItemConsumeEvent ready = consume(cooked, EquipmentSlot.HAND); + listener.onConsume(ready); + assertFalse(ready.isCancelled()); + spendOne(EquipmentSlot.HAND); + server.getScheduler().performOneTick(); + assertEquals(1, player.getInventory().getItemInMainHand().getAmount()); + assertEquals(110, character.getFoodValue()); verify(players).savePlayer(player); + } + + @Test void pendingMilkBucketIntakePreservesItsInputUntilAReadyRetry() { + MockBukkit.createMockPlugin("RPCharacters"); + ItemStack milk = stack("milk_bucket", true, "drink", Material.MILK_BUCKET); + player.getInventory().setItemInOffHand(milk); + characters.set(player, null); + DrinkConsumeListener listener = new DrinkConsumeListener(); + PlayerItemConsumeEvent pending = consume(milk, EquipmentSlot.OFF_HAND); + + listener.onMilkBucket(pending); + + assertTrue(pending.isCancelled()); + assertEquals(Material.MILK_BUCKET, player.getInventory().getItemInOffHand().getType()); + assertEquals(100, character.getFoodValue()); verifyNoInteractions(players); + characters.activate(player, character.getId()); + server.getScheduler().performOneTick(); + assertEquals(100, character.getFoodValue()); verifyNoInteractions(players); + + listener.onMilkBucket(consume(milk, EquipmentSlot.OFF_HAND)); + assertEquals(Material.BUCKET, player.getInventory().getItemInOffHand().getType()); + assertEquals(110, character.getFoodValue()); verify(players).savePlayer(player); + } + + @Test void pendingMilkCupIntakeDoesNotQueueWorkForTheNextReadySession() { + MockBukkit.createMockPlugin("RPCharacters"); + ItemStack milk = stack("cup_of_milk", true, "drink", Material.POTION); + player.getInventory().setItemInOffHand(milk); + characters.set(player, null); + DrinkConsumeListener listener = new DrinkConsumeListener(); + + listener.onDrink(consume(milk, EquipmentSlot.OFF_HAND)); + + assertEquals(Material.POTION, player.getInventory().getItemInOffHand().getType()); + assertEquals(100, character.getFoodValue()); verifyNoInteractions(players); + player.getInventory().setItemInOffHand(new ItemStack(Material.GLASS_BOTTLE)); + characters.activate(player, character.getId()); + server.getScheduler().performOneTick(); + assertEquals(Material.GLASS_BOTTLE, player.getInventory().getItemInOffHand().getType()); + assertEquals(100, character.getFoodValue()); verifyNoInteractions(players); + + player.getInventory().setItemInOffHand(milk); + listener.onDrink(consume(milk, EquipmentSlot.OFF_HAND)); + player.getInventory().setItemInOffHand(new ItemStack(Material.GLASS_BOTTLE)); + server.getScheduler().performOneTick(); + assertEquals(Material.BOWL, player.getInventory().getItemInOffHand().getType()); + assertEquals(110, character.getFoodValue()); verify(players).savePlayer(player); + } + private ItemStack stack(String id,boolean edible,String category,Material material) { YamlConfiguration config=new YamlConfiguration(); config.set("food",10); config.set("edible",edible); FoodItem food=new FoodItem(id,config); food.setCategory(category); food.setQualityRange(1,1); FoodLoader.oList.add(food); @@ -316,4 +666,10 @@ private ItemStack stack(String id,boolean edible,String category,Material materi private PlayerItemConsumeEvent consume(ItemStack stack,EquipmentSlot hand) { return new PlayerItemConsumeEvent(player,stack,hand); } + + private void spendOne(EquipmentSlot hand) { + ItemStack remaining = player.getInventory().getItem(hand).clone(); + remaining.setAmount(remaining.getAmount() - 1); + player.getInventory().setItem(hand, remaining.getAmount() == 0 ? null : remaining); + } } diff --git a/src/test/java/net/tfminecraft/cooking/nutrition/NutritionRuntimeCoverageTest.java b/src/test/java/net/tfminecraft/cooking/nutrition/NutritionRuntimeCoverageTest.java index 4626c9c..6e128da 100644 --- a/src/test/java/net/tfminecraft/cooking/nutrition/NutritionRuntimeCoverageTest.java +++ b/src/test/java/net/tfminecraft/cooking/nutrition/NutritionRuntimeCoverageTest.java @@ -7,6 +7,9 @@ import java.nio.file.Path; import java.util.ArrayList; import java.util.List; +import java.util.Locale; +import java.util.UUID; +import java.util.concurrent.atomic.AtomicInteger; import java.util.logging.Logger; import io.lumine.mythic.lib.player.resource.ResourceUpdateReason; import net.Indyuce.mmocore.api.player.PlayerData; @@ -17,6 +20,7 @@ import net.tfminecraft.cooking.item.tag.TagTrack; import net.tfminecraft.cooking.loader.TrackLoader; import net.tfminecraft.cooking.quality.QualityConfig; +import net.tfminecraft.cooking.testsupport.TestCharacters; import net.tfminecraft.rpcharacters.RPCharacters; import net.tfminecraft.rpcharacters.managers.PlayerManager; import net.tfminecraft.rpcharacters.objects.RPCharacter; @@ -41,6 +45,7 @@ class NutritionRuntimeCoverageTest { private MockedStatic rp; private Cooking previousPlugin; private List tracks; + private TestCharacters characters; @BeforeEach void setup() { server = MockBukkit.mock(); @@ -49,7 +54,10 @@ class NutritionRuntimeCoverageTest { when(Cooking.plugin.getName()).thenReturn("Cooking"); when(Cooking.plugin.namespace()).thenReturn("cooking"); when(Cooking.plugin.getLogger()).thenReturn(mock(Logger.class)); - player = server.addPlayer(); character = new RPCharacter(player); + player = spy(new PlayerMock(server, "Eater")); server.addPlayer(player); + character = new RPCharacter(player); + characters = new TestCharacters(MockBukkit.createMockPlugin("CharacterProvider")); + characters.activate(player, character.getId()); players = mock(PlayerManager.class); rp = mockStatic(RPCharacters.class); rp.when(RPCharacters::getPlayerManager).thenReturn(players); @@ -63,7 +71,7 @@ class NutritionRuntimeCoverageTest { NutritionDrainTask.stop(); SaturationGuard.stop(); NutritionLog.configure(false,false,null); NutritionConfig.load(new YamlConfiguration()); TrackLoader.oList.clear(); TrackLoader.oList.addAll(tracks); - rp.close(); MockBukkit.unmock(); Cooking.plugin = previousPlugin; + characters.close(); rp.close(); MockBukkit.unmock(); Cooking.plugin = previousPlugin; } @Test void eatingUpdatesTheCharacterHistoryHudAndPersistenceOnlyWhenFoodIsGained() { @@ -116,16 +124,17 @@ class NutritionRuntimeCoverageTest { NutritionDisplayService.sync(null,character); NutritionDisplayService.sync(player,null); NutritionDisplayService.syncFromPlayer(null); NutritionDisplayService.syncFromPlayer(player); + MockBukkit.createMockPlugin("RPCharacters"); character.setFoodValue(100); player.setSaturation(4); NutritionDisplayService.sync(player,character); assertEquals(10,player.getFoodLevel()); assertEquals(0,player.getSaturation()); NutritionDisplayService.sync(player,character); assertFalse(NutritionDisplayService.isSyncing(player)); - MockBukkit.createMockPlugin("RPCharacters"); character.setFoodValue(80); NutritionDisplayService.syncFromPlayer(player); assertEquals(8,player.getFoodLevel()); rp.when(() -> RPCharacters.getActiveCharacter(player)).thenReturn(null); NutritionDisplayService.syncFromPlayer(player); assertEquals(8,player.getFoodLevel()); - PlayerMock failing = spy(player); + rp.when(() -> RPCharacters.getActiveCharacter(player)).thenReturn(character); + PlayerMock failing = player; doAnswer(call -> { assertTrue(NutritionDisplayService.isSyncing(failing)); throw new IllegalStateException("Disconnected during HUD update"); @@ -177,10 +186,11 @@ class NutritionRuntimeCoverageTest { assertEquals(1,VarietyService.current(player).meals()); VarietyService.applyEffective(player,character); assertEquals(20,character.getDietScore()); assertFalse(DietTierService.seedIfAbsent(null,character)); assertFalse(DietTierService.seedIfAbsent(player,null)); - assertTrue(DietTierService.seedIfAbsent(player,character)); assertFalse(DietTierService.seedIfAbsent(player,character)); + assertFalse(DietTierService.seedIfAbsent(player,character)); verifyNoInteractions(players); MockBukkit.createMockPlugin("RPCharacters"); character.setLastDietTierId(" "); assertTrue(DietTierService.seedIfAbsent(player,character)); verify(players).savePlayer(player); + assertFalse(DietTierService.seedIfAbsent(player,character)); DietTierService.checkAndNotify(null,character,null); DietTierService.checkAndNotify(player,null,null); DietTierService.checkAndNotify(player,character,null); assertNull(player.nextMessage()); character.setDietScore(0); DietTierService.checkAndNotify(player,character,null); @@ -192,6 +202,7 @@ class NutritionRuntimeCoverageTest { } @Test void attributeBridgeOnlyUpdatesAvailableConfiguredAttributes() { + MockBukkit.createMockPlugin("RPCharacters"); NutritionAttributeBridge.apply(null,character); NutritionAttributeBridge.apply(player,null); NutritionAttributeBridge.apply(player,character); MockBukkit.createMockPlugin("MMOCore"); @@ -212,6 +223,7 @@ class NutritionRuntimeCoverageTest { } @Test void attributeBridgeGivesBackHealthAndManaClampedWhileTheBuffsAreReplaced() { + MockBukkit.createMockPlugin("RPCharacters"); MockBukkit.createMockPlugin("MMOCore"); try (MockedStatic data = mockStatic(PlayerData.class)) { PlayerData pd = mock(PlayerData.class,RETURNS_DEEP_STUBS); @@ -275,6 +287,215 @@ class NutritionRuntimeCoverageTest { assertEquals(0,noWeights.penaltyPercent()); assertEquals(100,noWeights.progressPercent()); } + @Test void loadedRpcDataCannotAuthorizeNutritionWhileSharedIdentityIsUnavailableOrPending() { + MockBukkit.createMockPlugin("RPCharacters"); + character.setFoodValue(100); + player.setFoodLevel(17); player.setSaturation(6); + characters.close(); + NutritionService.tryApplyEat(player, food(10, 40)); + NutritionDisplayService.sync(player, character); + assertNull(NutritionContext.capture(player)); + characters.register(); characters.set(player, null); + NutritionService.tryApplyEat(player, food(10, 40)); + NutritionDisplayService.syncFromPlayer(player); + assertFalse(DietTierService.seedIfAbsent(player, character)); + assertEquals(100, character.getFoodValue()); + assertEquals(0, character.getRawDietScore()); + assertEquals(0, VarietyService.current(player).meals()); + assertEquals(17, player.getFoodLevel()); assertEquals(6, player.getSaturation()); + verifyNoInteractions(players); + characters.activate(player, character.getId()); + NutritionService.tryApplyEat(player, food(10, 40)); + assertEquals(110, character.getFoodValue()); + verify(players).savePlayer(player); + } + + @Test void bridgeChecksRpcOwnerCharacterAndCanonicalIdBeforeExposingMutableData() { + MockBukkit.createMockPlugin("RPCharacters"); + String originalId = character.getId(); + character.setOwner(server.addPlayer("Other")); + assertNull(NutritionContext.capture(player)); + character.setOwner(player); + character.setId(UUID.randomUUID().toString()); + assertNull(NutritionContext.capture(player)); + character.setId("malformed"); + assertNull(NutritionContext.capture(player)); + character.setId(originalId.toUpperCase(Locale.ROOT)); + assertSame(character, NutritionContext.capture(player).character()); + assertNull(NutritionContext.capture(player, new RPCharacter(player))); + verifyNoInteractions(players); + } + + @Test void returningToTheSameCharacterDoesNotReviveCapturedNutritionWork() { + MockBukkit.createMockPlugin("RPCharacters"); + NutritionContext old = NutritionContext.capture(player); + assertNotNull(old); + characters.activate(player, UUID.randomUUID()); + characters.activate(player, character.getId()); + character.setFoodValue(20); player.setFoodLevel(17); player.setSaturation(6); + NutritionDisplayService.sync(old, "stale"); + NutritionAttributeBridge.apply(old); + assertFalse(old.save()); + assertFalse(DietTierService.seedIfAbsent(old)); + assertEquals(17, player.getFoodLevel()); assertEquals(6, player.getSaturation()); + assertNull(character.getLastDietTierId()); + assertFalse(NutritionDisplayService.isSyncing(player)); + 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); + character.setFoodValue(50); + AtomicInteger writes = new AtomicInteger(); + doAnswer(call -> { + assertTrue(NutritionDisplayService.isSyncing(player)); + if (writes.getAndIncrement() == 0) { + NutritionDisplayService.sync(context, "inner"); + assertTrue(NutritionDisplayService.isSyncing(player)); + } + return call.callRealMethod(); + }).when(player).setFoodLevel(anyInt()); + + NutritionDisplayService.sync(context, "outer"); + + assertEquals(2, writes.get()); + assertEquals(5, player.getFoodLevel()); + assertFalse(NutritionDisplayService.isSyncing(player)); + } + + @Test void attributeReentryAfterSetBaseStopsAllOutgoingResourceRestoration() { + MockBukkit.createMockPlugin("RPCharacters"); MockBukkit.createMockPlugin("MMOCore"); + try (MockedStatic data = mockStatic(PlayerData.class)) { + AttributeInstance attribute = mock(AttributeInstance.class); + PlayerData pd = attributeData(data, attribute); + when(pd.getMana()).thenReturn(30.0); + character.setDietScore(36); + doAnswer(call -> { + player.setHealth(16); + characters.activate(player, UUID.randomUUID()); + characters.activate(player, character.getId()); + return null; + }).when(attribute).setBase(36); + + NutritionAttributeBridge.apply(player, character); + + assertEquals(16, player.getHealth()); + verify(pd, never()).setMana(anyDouble(), any(ResourceUpdateReason.class)); + verify(pd, never()).setStamina(anyDouble(), any(ResourceUpdateReason.class)); + verify(pd, never()).setStellium(anyDouble(), any(ResourceUpdateReason.class)); + } + } + + @Test void healthRestorationCallbackStopsSubsequentManaRestoration() { + MockBukkit.createMockPlugin("RPCharacters"); MockBukkit.createMockPlugin("MMOCore"); + try (MockedStatic data = mockStatic(PlayerData.class)) { + AttributeInstance attribute = mock(AttributeInstance.class); + PlayerData pd = attributeData(data, attribute); + double[] mana = {30}; when(pd.getMana()).thenAnswer(call -> mana[0]); + doAnswer(call -> { + player.setHealth(16); mana[0] = 20; return null; + }).when(attribute).setBase(36); + doAnswer(call -> { + call.callRealMethod(); + if ((double) call.getArgument(0) == 20) characters.set(player, null); + return null; + }).when(player).setHealth(anyDouble()); + character.setDietScore(36); + + NutritionAttributeBridge.apply(player, character); + + assertEquals(20, player.getHealth()); + verify(pd, never()).setMana(anyDouble(), any(ResourceUpdateReason.class)); + } + } + + @Test void manaRestorationCallbackStopsLaterStaminaAndStelliumWrites() { + MockBukkit.createMockPlugin("RPCharacters"); MockBukkit.createMockPlugin("MMOCore"); + try (MockedStatic data = mockStatic(PlayerData.class)) { + AttributeInstance attribute = mock(AttributeInstance.class); + PlayerData pd = attributeData(data, attribute); + double[] values = {30, 8, 30}; + when(pd.getMana()).thenAnswer(call -> values[0]); + when(pd.getStamina()).thenAnswer(call -> values[1]); + when(pd.getStellium()).thenAnswer(call -> values[2]); + doAnswer(call -> { + values[0] = 20; values[1] = 3; values[2] = 10; return null; + }).when(attribute).setBase(36); + doAnswer(call -> { + characters.close(); return null; + }).when(pd).setMana(30.0, ResourceUpdateReason.CLAMPING); + character.setDietScore(36); + + NutritionAttributeBridge.apply(player, character); + + verify(pd).setMana(30.0, ResourceUpdateReason.CLAMPING); + verify(pd, never()).setStamina(anyDouble(), any(ResourceUpdateReason.class)); + verify(pd, never()).setStellium(anyDouble(), any(ResourceUpdateReason.class)); + } + } + + @Test void saveReentryCannotApplyOutgoingHudToTheReplacementCharacter() { + MockBukkit.createMockPlugin("RPCharacters"); + character.setFoodValue(100); + RPCharacter replacement = new RPCharacter(player); + doAnswer(call -> { + rp.when(() -> RPCharacters.getActiveCharacter(player)).thenReturn(replacement); + characters.activate(player, replacement.getId()); + player.setFoodLevel(17); player.setSaturation(6); + return null; + }).when(players).savePlayer(player); + + NutritionService.tryApplyEat(player, food(10, 0)); + + assertEquals(110, character.getFoodValue()); + assertEquals(200, replacement.getFoodValue()); + assertEquals(0, replacement.getRawDietScore()); + assertEquals(17, player.getFoodLevel()); assertEquals(6, player.getSaturation()); + verify(players).savePlayer(player); + } + + @Test void drainWaitsForReadinessEvenWhenRpcStillHasAnActiveCharacter() { + MockBukkit.createMockPlugin("RPCharacters"); + configure("nutrition.drain-interval", 1, "nutrition.drain-amount", 3); + character.setFoodValue(100); characters.set(player, null); + NutritionDrainTask.start(); server.getScheduler().performTicks(20); + assertEquals(100, character.getFoodValue()); verifyNoInteractions(players); + characters.activate(player, character.getId()); + server.getScheduler().performTicks(20); + assertEquals(97, character.getFoodValue()); verify(players).savePlayer(player); + } + + private PlayerData attributeData(MockedStatic data, AttributeInstance attribute) { + PlayerData pd = mock(PlayerData.class, RETURNS_DEEP_STUBS); + data.when(() -> PlayerData.get(player)).thenReturn(pd); + when(pd.getAttributes().getInstance(NutritionConfig.attributeName())).thenReturn(attribute); + when(pd.getStats().getStat("MAX_MANA")).thenReturn(30.0); + when(pd.getStats().getStat("MAX_STAMINA")).thenReturn(10.0); + when(pd.getStats().getStat("MAX_STELLIUM")).thenReturn(40.0); + return pd; + } + private FoodItem food(double food, double nutrition) { YamlConfiguration config = new YamlConfiguration(); config.set("food",food); config.set("nutrition",nutrition); FoodItem item = new FoodItem("meal",config); item.setQualityRange(1,1); diff --git a/src/test/java/net/tfminecraft/cooking/sausagemaker/SausageMakerCoverageTest.java b/src/test/java/net/tfminecraft/cooking/sausagemaker/SausageMakerCoverageTest.java index 67a71dc..8bf2c3c 100644 --- a/src/test/java/net/tfminecraft/cooking/sausagemaker/SausageMakerCoverageTest.java +++ b/src/test/java/net/tfminecraft/cooking/sausagemaker/SausageMakerCoverageTest.java @@ -16,12 +16,12 @@ import java.util.concurrent.atomic.AtomicInteger; import org.bukkit.Location; +import org.bukkit.Bukkit; import org.bukkit.Material; import org.bukkit.NamespacedKey; import org.bukkit.World; import org.bukkit.configuration.MemoryConfiguration; import org.bukkit.entity.Item; -import org.bukkit.entity.Player; import org.bukkit.inventory.ItemStack; import org.bukkit.persistence.PersistentDataType; import org.bukkit.util.Transformation; @@ -35,6 +35,8 @@ import org.mockbukkit.mockbukkit.MockBukkit; import org.mockbukkit.mockbukkit.ServerMock; import org.mockbukkit.mockbukkit.entity.ItemDisplayMock; +import org.mockbukkit.mockbukkit.entity.PlayerMock; +import org.mockbukkit.mockbukkit.world.WorldMock; import org.mockito.MockedStatic; import net.tfminecraft.cooking.Cooking; @@ -51,6 +53,7 @@ import net.tfminecraft.cooking.quality.CompositionQualityResolver; import net.tfminecraft.cooking.quality.CompositionResult; import net.tfminecraft.cooking.quality.OriginQualityResolver; +import net.tfminecraft.cooking.testsupport.TestCharacters; import net.tfminecraft.cooking.utils.InventoryAdder; import net.tfminecraft.cooking.utils.ItemBuilder; import net.tfminecraft.interactiblefurniture.InteractibleFurniture; @@ -244,6 +247,264 @@ void completionRechecksPlayerIngredientsCasingAndOutputTemplate(String failure) assertFalse(SausageMakerAnimation.isAnimating(station.furniture)); } + @Test + void pendingCharacterCannotCrankButAReadyCharacterCanFinish() { + try (TestCharacters characters = new TestCharacters(Cooking.plugin)) { + fill(); + ItemStack casing = env.hold(new ItemStack(Material.PAPER, 2)); + var pending = station.interact(); + handler.onInteract(pending); + assertTrue(pending.isCancelled()); + assertEquals(2, casing.getAmount()); + assertEquals(3, station.active.size()); + assertTrue(env.outputs().isEmpty()); + env.composer.verifyNoInteractions(); + + characters.activate(env.player, UUID.randomUUID()); + handler.onInteract(station.interact()); + assertEquals(1, casing.getAmount()); + assertTrue(station.active.isEmpty()); + assertEquals(1, env.outputs().size()); + } + } + + @ParameterizedTest + @ValueSource(strings = {"pending", "aba", "reconnect", "provider-arrival", "provider-loss"}) + void staleAnimationPreservesSharedMeatsAndBothCharactersCasing(String change) { + try (TestCharacters characters = new TestCharacters(Cooking.plugin)) { + UUID character = UUID.randomUUID(); + var origin = characters.activate(env.player, character); + if (change.equals("provider-arrival")) characters.close(); + fill(); + env.display(station.id); + ItemStack originalCasing = env.hold(new ItemStack(Material.PAPER, 2)); + handler.onInteract(station.interact()); + assertTrue(SausageMakerAnimation.isAnimating(station.furniture)); + PlayerMock current = env.player; + switch (change) { + case "pending" -> characters.set(env.player, null); + case "aba" -> { + characters.activate(env.player, UUID.randomUUID()); + characters.activate(env.player, character); + } + case "reconnect" -> { + env.player.disconnect(); + current = new PlayerMock(env.server, "Processor", env.player.getUniqueId()); + env.server.addPlayer(current); + characters.set(current, origin); + assertSame(current, Bukkit.getPlayer(env.player.getUniqueId())); + } + case "provider-arrival" -> characters.register(); + case "provider-loss" -> characters.close(); + default -> fail("Unknown identity change"); + } + current.getInventory().setItemInMainHand(new ItemStack(Material.PAPER, 5)); + env.server.getScheduler().performTicks(10); + assertEquals(2, originalCasing.getAmount()); + assertEquals(5, current.getInventory().getItemInMainHand().getAmount()); + assertEquals(3, station.active.size()); + assertTrue(env.outputs().isEmpty()); + assertTrue(env.dropped.isEmpty()); + assertFalse(SausageMakerAnimation.isAnimating(station.furniture)); + env.composer.verifyNoInteractions(); + } + } + + @Test + void itemBuilderSwitchCannotConsumeInputsOrAwardTheSuccessor() { + try (TestCharacters characters = new TestCharacters(Cooking.plugin)) { + characters.activate(env.player, UUID.randomUUID()); + fill(); + env.display(station.id); + ItemStack originalCasing = env.hold(new ItemStack(Material.PAPER, 2)); + env.builder.when(() -> ItemBuilder.buildComposedWithQuality(any(), anyInt())).thenAnswer(call -> { + ItemStack result = env.build(call.getArgument(0), call.getArgument(1)); + characters.activate(env.player, UUID.randomUUID()); + env.hold(new ItemStack(Material.PAPER, 5)); + return result; + }); + handler.onInteract(station.interact()); + env.server.getScheduler().performTicks(10); + assertEquals(2, originalCasing.getAmount()); + assertEquals(5, env.player.getInventory().getItemInMainHand().getAmount()); + assertEquals(3, station.active.size()); + assertTrue(env.outputs().isEmpty()); + assertTrue(env.dropped.isEmpty()); + verify(env.manager, never()).markDirty(station.furniture); + } + } + + @Test + void casingCheckerSwitchBeforeConsumptionPreservesEveryInput() { + try (TestCharacters characters = new TestCharacters(Cooking.plugin); + MockedStatic paper = mockStatic(CasingPaper.class)) { + characters.activate(env.player, UUID.randomUUID()); + fill(); + ItemStack originalCasing = env.hold(new ItemStack(Material.PAPER, 2)); + AtomicInteger checks = new AtomicInteger(); + paper.when(() -> CasingPaper.isCasing(any())).thenAnswer(call -> { + if (checks.incrementAndGet() == 3) { + characters.activate(env.player, UUID.randomUUID()); + env.hold(new ItemStack(Material.PAPER, 5)); + } + return true; + }); + handler.onInteract(station.interact()); + assertEquals(3, checks.get()); + assertEquals(2, originalCasing.getAmount()); + assertEquals(5, env.player.getInventory().getItemInMainHand().getAmount()); + assertEquals(3, station.active.size()); + assertTrue(env.outputs().isEmpty()); + assertTrue(env.dropped.isEmpty()); + } + } + + @ParameterizedTest + @ValueSource(strings = {"start", "completion"}) + void casingAvailabilityCallbackSwitchCannotStartOrCompleteARecipe(String phase) { + try (TestCharacters characters = new TestCharacters(Cooking.plugin); + MockedStatic paper = mockStatic(CasingPaper.class)) { + characters.activate(env.player, UUID.randomUUID()); + fill(); + env.display(station.id); + ItemStack originalCasing = env.hold(new ItemStack(Material.PAPER, 2)); + AtomicInteger checks = new AtomicInteger(); + int switchOnCheck = phase.equals("start") ? 1 : 2; + paper.when(() -> CasingPaper.isCasing(any())).thenAnswer(call -> { + if (checks.incrementAndGet() == switchOnCheck) { + characters.activate(env.player, UUID.randomUUID()); + env.hold(new ItemStack(Material.PAPER, 5)); + } + return true; + }); + var event = station.interact(); + handler.onInteract(event); + assertTrue(event.isCancelled()); + assertEquals(phase.equals("completion"), SausageMakerAnimation.isAnimating(station.furniture)); + env.server.getScheduler().performTicks(10); + assertEquals(switchOnCheck, checks.get()); + assertEquals(2, originalCasing.getAmount()); + assertEquals(5, env.player.getInventory().getItemInMainHand().getAmount()); + assertEquals(3, station.active.size()); + assertTrue(env.outputs().isEmpty()); + assertTrue(env.dropped.isEmpty()); + env.composer.verifyNoInteractions(); + verify(env.manager, never()).markDirty(station.furniture); + } + } + + @Test + void offhandCasingCheckSwitchPreservesTheOriginalAndSuccessorCasing() { + try (TestCharacters characters = new TestCharacters(Cooking.plugin); + MockedStatic paper = mockStatic(CasingPaper.class)) { + characters.activate(env.player, UUID.randomUUID()); + fill(); + env.hold(new ItemStack(Material.STICK)); + env.player.getInventory().setItemInOffHand(new ItemStack(Material.PAPER, 2)); + ItemStack originalCasing = env.player.getInventory().getItemInOffHand(); + AtomicInteger offhandChecks = new AtomicInteger(); + paper.when(() -> CasingPaper.isCasing(any())).thenAnswer(call -> { + ItemStack stack = call.getArgument(0); + if (stack == null || stack.getType() != Material.PAPER) return false; + if (offhandChecks.incrementAndGet() == 3) { + characters.activate(env.player, UUID.randomUUID()); + env.player.getInventory().setItemInOffHand(new ItemStack(Material.PAPER, 5)); + } + return true; + }); + handler.onInteract(station.interact()); + assertEquals(3, offhandChecks.get()); + assertEquals(2, originalCasing.getAmount()); + assertEquals(5, env.player.getInventory().getItemInOffHand().getAmount()); + assertEquals(Material.STICK, env.player.getInventory().getItemInMainHand().getType()); + assertEquals(3, station.active.size()); + assertTrue(env.outputs().isEmpty()); + assertTrue(env.dropped.isEmpty()); + verify(env.manager, never()).markDirty(station.furniture); + } + } + + @Test + void itemBuilderRemovingCasingWhileCurrentCannotConsumeMeatsOrAwardAChain() { + try (TestCharacters characters = new TestCharacters(Cooking.plugin)) { + var origin = characters.activate(env.player, UUID.randomUUID()); + fill(); + ItemStack originalCasing = env.hold(new ItemStack(Material.PAPER, 2)); + env.builder.when(() -> ItemBuilder.buildComposedWithQuality(any(), anyInt())).thenAnswer(call -> { + ItemStack result = env.build(call.getArgument(0), call.getArgument(1)); + env.hold(new ItemStack(Material.STICK)); + return result; + }); + handler.onInteract(station.interact()); + assertTrue(net.tfminecraft.tlibs.character.Characters.isCurrent(origin)); + assertEquals(2, originalCasing.getAmount()); + assertEquals(Material.STICK, env.player.getInventory().getItemInMainHand().getType()); + assertEquals(3, station.active.size()); + assertTrue(env.outputs().isEmpty()); + assertTrue(env.dropped.isEmpty()); + verify(env.manager, never()).markDirty(station.furniture); + } + } + + @Test + void furnitureCallbackAfterConsumptionDropsCommittedOutputAtTheCapturedStation() { + try (TestCharacters characters = new TestCharacters(Cooking.plugin); + MockedStatic delivery = mockStatic(InventoryAdder.class)) { + characters.activate(env.player, UUID.randomUUID()); + fill(); + ItemStack originalCasing = env.hold(new ItemStack(Material.PAPER, 2)); + Location originalLocation = station.furniture.getLoc().clone(); + World successorWorld = mock(World.class); + doAnswer(call -> { + assertEquals(1, originalCasing.getAmount()); + assertTrue(station.active.isEmpty()); + characters.activate(env.player, UUID.randomUUID()); + env.hold(new ItemStack(Material.PAPER, 5)); + when(station.furniture.getLoc()).thenReturn(new Location(successorWorld, 100, 80, 100)); + return null; + }).when(env.manager).markDirty(station.furniture); + handler.onInteract(station.interact()); + delivery.verifyNoInteractions(); + assertEquals(1, originalCasing.getAmount()); + assertEquals(5, env.player.getInventory().getItemInMainHand().getAmount()); + assertTrue(station.active.isEmpty()); + assertTrue(env.outputs().isEmpty()); + assertEquals(1, env.dropped.size()); + assertEquals("sausage_chain", env.resolve(env.dropped.getFirst()).getId()); + verify(env.world).dropItemNaturally(eq(originalLocation), any(ItemStack.class)); + verify(successorWorld, never()).dropItemNaturally(any(Location.class), any(ItemStack.class)); + } + } + + @Test + void awardCallbackSeesConsumedInputsAndCannotChargeTheSuccessorCasing() { + try (TestCharacters characters = new TestCharacters(Cooking.plugin); + MockedStatic delivery = mockStatic(InventoryAdder.class)) { + characters.activate(env.player, UUID.randomUUID()); + fill(); + ItemStack originalCasing = env.hold(new ItemStack(Material.PAPER, 2)); + Location originalLocation = station.furniture.getLoc().clone(); + World successorWorld = mock(World.class); + delivery.when(() -> InventoryAdder.addItem(eq(env.player), any())).thenAnswer(call -> { + assertEquals(1, originalCasing.getAmount(), "Consume the casing before handing off the chain"); + assertTrue(station.active.isEmpty(), "Consume the meats before handing off the chain"); + characters.activate(env.player, UUID.randomUUID()); + env.hold(new ItemStack(Material.PAPER, 5)); + when(station.furniture.getLoc()).thenReturn(new Location(successorWorld, 100, 80, 100)); + return call.getArgument(1); + }); + handler.onInteract(station.interact()); + delivery.verify(() -> InventoryAdder.addItem(eq(env.player), any()), times(1)); + assertEquals(1, originalCasing.getAmount()); + assertEquals(5, env.player.getInventory().getItemInMainHand().getAmount()); + assertTrue(station.active.isEmpty()); + assertTrue(env.outputs().isEmpty()); + assertEquals(1, env.dropped.size()); + verify(env.world).dropItemNaturally(eq(originalLocation), any(ItemStack.class)); + verify(successorWorld, never()).dropItemNaturally(any(Location.class), any(ItemStack.class)); + } + } + @Test void batchTotalsIgnoreMissingMeatsAndKeepTemplateNutritionForAnEmptyBatch() { FoodItem chain = env.food("sausage_chain", "Mixed", 2); chain.setBaseNutrition(9); @@ -295,8 +556,8 @@ void zeroDurationWobbleNeverWritesANonFiniteTransform() { /** Shared public fixture keeps the two processor suites on real inventories, food values and scheduler ticks. */ public static final class Workshop implements AutoCloseable { public final ServerMock server = MockBukkit.mock(); - public final Player player = mock(Player.class); - public final World world = mock(World.class); + public final PlayerMock player = spy(new PlayerMock(server, "Processor")); + public final World world = mock(WorldMock.class); public final FurnitureManager manager = mock(FurnitureManager.class); public final ItemAPI api = mock(ItemAPI.class, RETURNS_DEEP_STUBS); public final List dropped = new ArrayList<>(); @@ -325,10 +586,10 @@ public Workshop() { when(Cooking.plugin.getName()).thenReturn("Cooking"); when(Cooking.plugin.namespace()).thenReturn("cooking"); when(Cooking.plugin.isEnabled()).thenReturn(true); - when(player.getInventory()).thenReturn(server.addPlayer().getInventory()); - when(player.isOnline()).thenReturn(true); - when(player.getWorld()).thenReturn(world); - when(player.getLocation()).thenReturn(new Location(world, 1, 64, 1)); + server.addPlayer(player); + doNothing().when(player).swingMainHand(); + doReturn(world).when(player).getWorld(); + doReturn(new Location(world, 1, 64, 1)).when(player).getLocation(); when(world.getUID()).thenReturn(UUID.randomUUID()); when(world.getName()).thenReturn("processors"); when(world.dropItemNaturally(any(Location.class), any(ItemStack.class))).thenAnswer(inv -> { dropped.add(((ItemStack) inv.getArgument(1)).clone()); return mock(Item.class); diff --git a/src/test/java/net/tfminecraft/cooking/testsupport/TestCharacters.java b/src/test/java/net/tfminecraft/cooking/testsupport/TestCharacters.java new file mode 100644 index 0000000..bf1fb91 --- /dev/null +++ b/src/test/java/net/tfminecraft/cooking/testsupport/TestCharacters.java @@ -0,0 +1,58 @@ +package net.tfminecraft.cooking.testsupport; + +import static org.mockito.Mockito.*; +import java.util.HashMap; +import java.util.Map; +import java.util.Optional; +import java.util.UUID; +import org.bukkit.Bukkit; +import org.bukkit.entity.Player; +import org.bukkit.plugin.Plugin; +import org.bukkit.plugin.ServicePriority; +import net.tfminecraft.tlibs.character.CharacterIdentity; +import net.tfminecraft.tlibs.character.CharacterRef; +import net.tfminecraft.tlibs.character.CharacterSession; + +/** Real facade and registry with controllable activation generations. */ +public final class TestCharacters implements AutoCloseable { + public final CharacterIdentity provider = mock(CharacterIdentity.class); + private final Plugin plugin; + private final Map sessions = new HashMap<>(); + private long generation; + + public TestCharacters(Plugin plugin) { + this.plugin = plugin; + when(provider.currentSession(any())).thenAnswer(call -> { + Player player = call.getArgument(0); + return player == null ? Optional.empty() : Optional.ofNullable(sessions.get(player.getUniqueId())); + }); + when(provider.isCurrent(any())).thenAnswer(call -> { + CharacterSession session = call.getArgument(0); + return session != null && session.equals(sessions.get(session.ref().ownerId())); + }); + register(); + } + + public CharacterSession activate(Player player, UUID characterId) { + CharacterSession token = new CharacterSession(new CharacterRef(player.getUniqueId(), characterId), ++generation); + sessions.put(player.getUniqueId(), token); + return token; + } + + public CharacterSession activate(Player player, String characterId) { + return activate(player, UUID.fromString(characterId)); + } + + public void set(Player player, CharacterSession session) { + if (session == null) sessions.remove(player.getUniqueId()); + else sessions.put(player.getUniqueId(), session); + } + + public void register() { + Bukkit.getServicesManager().register(CharacterIdentity.class, provider, plugin, ServicePriority.Normal); + } + + @Override public void close() { + Bukkit.getServicesManager().unregister(CharacterIdentity.class, provider); + } +} diff --git a/src/test/java/net/tfminecraft/cooking/utils/PlayerActionContextTest.java b/src/test/java/net/tfminecraft/cooking/utils/PlayerActionContextTest.java new file mode 100644 index 0000000..1a1110c --- /dev/null +++ b/src/test/java/net/tfminecraft/cooking/utils/PlayerActionContextTest.java @@ -0,0 +1,138 @@ +package net.tfminecraft.cooking.utils; + +import static org.junit.jupiter.api.Assertions.*; +import static org.mockito.Mockito.*; + +import java.util.UUID; +import net.tfminecraft.cooking.testsupport.TestCharacters; +import net.tfminecraft.tlibs.character.CharacterSession; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.mockbukkit.mockbukkit.MockBukkit; +import org.mockbukkit.mockbukkit.ServerMock; +import org.mockbukkit.mockbukkit.entity.PlayerMock; + +class PlayerActionContextTest { + ServerMock server; + PlayerMock player; + TestCharacters characters; + + @BeforeEach void setup() { + server=MockBukkit.mock(); + player=server.addPlayer(); + characters=new TestCharacters(MockBukkit.createMockPlugin()); + } + + @AfterEach void cleanup() { + characters.close(); + MockBukkit.unmock(); + } + + @Test void nullAndPendingPlayersHaveNoActionContext() { + assertNull(PlayerActionContext.capture(null)); + assertNull(PlayerActionContext.capture(player)); + characters.activate(player,UUID.randomUUID()); + PlayerActionContext ready=PlayerActionContext.capture(player); + assertNotNull(ready); + characters.set(player,null); + assertFalse(ready.isCurrent()); + assertNull(PlayerActionContext.capture(player)); + } + + @Test void readyContextCapturesExactTokenAndRejectsSwitchBackGeneration() { + UUID characterId=UUID.randomUUID(); + CharacterSession original=characters.activate(player,characterId); + PlayerActionContext action=PlayerActionContext.capture(player); + assertNotNull(action); + assertSame(player,action.player()); + assertEquals(original,action.session()); + assertTrue(action.isCurrent()); + + characters.activate(player,UUID.randomUUID()); + assertFalse(action.isCurrent()); + CharacterSession returned=characters.activate(player,characterId); + assertEquals(original.ref(),returned.ref()); + assertNotEquals(original,returned); + assertFalse(action.isCurrent()); + PlayerActionContext fresh=PlayerActionContext.capture(player); + assertNotNull(fresh); + assertEquals(returned,fresh.session()); + assertTrue(fresh.isCurrent()); + } + + @Test void readyContextBecomesInvalidWhenProviderDisappears() { + characters.activate(player,UUID.randomUUID()); + PlayerActionContext action=PlayerActionContext.capture(player); + assertNotNull(action); + characters.close(); + assertFalse(action.isCurrent()); + PlayerActionContext standalone=PlayerActionContext.capture(player); + assertNotNull(standalone); + assertNull(standalone.session()); + assertTrue(standalone.isCurrent()); + } + + @Test void standaloneContextRequiresProviderToRemainAbsent() { + characters.close(); + PlayerActionContext action=PlayerActionContext.capture(player); + assertNotNull(action); + assertNull(action.session()); + assertTrue(action.isCurrent()); + characters.register(); + assertFalse(action.isCurrent()); + assertNull(PlayerActionContext.capture(player)); + characters.activate(player,UUID.randomUUID()); + assertFalse(action.isCurrent()); + assertNotNull(PlayerActionContext.capture(player)); + } + + @Test void staleTokenReturnedByProviderCannotStartAnAction() { + CharacterSession token=characters.activate(player,UUID.randomUUID()); + when(characters.provider.isCurrent(token)).thenReturn(false); + assertNull(PlayerActionContext.capture(player)); + } + + @Test void disconnectedPlayerCannotCaptureOrContinueAnAction() { + characters.activate(player,UUID.randomUUID()); + PlayerActionContext action=PlayerActionContext.capture(player); + assertNotNull(action); + player.disconnect(); + assertFalse(action.isCurrent()); + assertNull(PlayerActionContext.capture(player)); + } + + @Test void replacementConnectionInvalidatesActionEvenIfProviderKeepsSameToken() { + characters.activate(player,UUID.randomUUID()); + PlayerActionContext action=PlayerActionContext.capture(player); + assertNotNull(action); + PlayerMock replacement=new PlayerMock(server,player.getName(),player.getUniqueId()); + assertTrue(replacement.isOnline()); + assertNull(PlayerActionContext.capture(replacement)); + + server.getPlayerList().disconnectPlayer(player); + server.addPlayer(replacement); + assertTrue(player.isOnline()); + assertSame(replacement,server.getPlayer(player.getUniqueId())); + assertFalse(action.isCurrent()); + assertNull(PlayerActionContext.capture(player)); + assertNotNull(PlayerActionContext.capture(replacement)); + } + + @Test void standaloneActionAlsoRequiresOriginalConnection() { + characters.close(); + PlayerActionContext action=PlayerActionContext.capture(player); + assertNotNull(action); + server.getPlayerList().disconnectPlayer(player); + PlayerMock replacement=new PlayerMock(server,player.getName(),player.getUniqueId()); + server.addPlayer(replacement); + assertTrue(player.isOnline()); + assertSame(replacement,server.getPlayer(player.getUniqueId())); + assertFalse(action.isCurrent()); + assertNull(PlayerActionContext.capture(player)); + PlayerActionContext fresh=PlayerActionContext.capture(replacement); + assertNotNull(fresh); + assertNull(fresh.session()); + assertTrue(fresh.isCurrent()); + } +}