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());
+ }
+}