From 664216b7cc5c183bc8efad1b70390eb8749c3ae5 Mon Sep 17 00:00:00 2001 From: Ryan Barlow <7389646+ryanbarlow97@users.noreply.github.com> Date: Tue, 6 Oct 2026 20:12:49 +0000 Subject: [PATCH 1/6] test: cover cooking runtime and fix state and item loss --- .github/workflows/build.yml | 8 + README.md | 8 +- pom.xml | 38 + .../cooking/baking/BakingTrayAging.java | 19 +- .../cooking/baking/BakingTrayBakeApplier.java | 4 +- .../cooking/baking/BakingTrayHandler.java | 17 +- .../cooking/baking/BakingTrayTransform.java | 3 - .../cooking/cache/FurnitureCache.java | 4 +- .../cooking/cache/NamingConfig.java | 11 +- .../cooking/carve/CarvableRoastUtils.java | 17 +- .../cooking/carve/CarveHandler.java | 53 +- .../cooking/churn/ButterChurnHandler.java | 25 +- .../cooking/churn/ButterItems.java | 2 + .../cooking/cooking/CookingReference.java | 23 +- .../cooking/cooking/FryingReference.java | 12 +- .../cooking/cooking/PotReference.java | 42 +- .../cooking/crafting/CraftingStation.java | 86 +- .../crops/CropCustomCropsListener.java | 22 +- .../cooking/crops/CropGrowthChance.java | 2 +- .../cooking/crops/CropHarvestQuality.java | 2 +- .../cooking/crops/CropsLoader.java | 2 +- .../net/tfminecraft/cooking/cup/CupItems.java | 6 +- .../cooking/farming/FarmingLoader.java | 6 - .../cooking/fishing/CustomFishingCatalog.java | 3 +- .../cooking/fishing/SeafoodWholeItems.java | 3 - .../tfminecraft/cooking/heat/HeatLoader.java | 4 +- .../tfminecraft/cooking/heat/HeatLookup.java | 2 +- .../cooking/heat/HeatSourceType.java | 4 +- .../tfminecraft/cooking/heat/HeatSources.java | 14 +- .../cooking/hook/MeatHookHandler.java | 6 +- .../husbandry/HusbandryAnimalsCommand.java | 3 - .../husbandry/HusbandryBreedListener.java | 3 - .../husbandry/HusbandryCareListener.java | 8 +- .../husbandry/HusbandryDamageListener.java | 3 - .../husbandry/HusbandryDeathListener.java | 24 +- .../husbandry/HusbandryDropRoller.java | 13 +- .../cooking/husbandry/HusbandryEntities.java | 7 +- .../husbandry/HusbandryEntityScan.java | 43 +- .../cooking/husbandry/HusbandryGuiBars.java | 5 +- .../cooking/husbandry/HusbandryHarvest.java | 9 +- .../husbandry/HusbandryHarvestListener.java | 20 +- .../husbandry/HusbandryInspectGui.java | 14 +- .../husbandry/HusbandryInspectListener.java | 3 + .../husbandry/HusbandryLifecycleListener.java | 7 +- .../cooking/husbandry/HusbandryLoader.java | 14 +- .../husbandry/HusbandryRepository.java | 8 - .../cooking/husbandry/HusbandrySimulator.java | 7 - .../cooking/husbandry/HusbandrySpawner.java | 6 +- .../husbandry/HusbandryTamingListener.java | 14 +- .../cooking/item/CookingPathHandler.java | 9 - .../tfminecraft/cooking/item/FoodItem.java | 40 +- .../cooking/item/IngredientLineage.java | 25 +- .../cooking/item/data/CookData.java | 7 +- .../cooking/item/data/OverrideData.java | 3 +- .../cooking/item/model/FoodModel.java | 2 +- .../cooking/item/model/ModelData.java | 18 +- .../cooking/item/tag/AgeScale.java | 11 +- .../cooking/item/tag/TagTrack.java | 6 + .../liquid/LiquidContainerHandler.java | 13 +- .../cooking/loader/CarveSequenceLoader.java | 18 +- .../loader/CompositionConfigLoader.java | 7 +- .../cooking/loader/ConversionLoader.java | 5 - .../cooking/loader/FoodLoader.java | 9 +- .../cooking/loader/ModelLoader.java | 9 +- .../cooking/loader/NamingLoader.java | 11 +- .../cooking/loader/QualityConfigLoader.java | 3 +- .../cooking/loader/TrackLoader.java | 9 +- .../cooking/manager/CommandManager.java | 6 +- .../cooking/manager/ConversionManager.java | 3 +- .../cooking/manager/CookingManager.java | 11 +- .../cooking/manager/PlateManager.java | 84 +- .../cooking/manager/TagManager.java | 2 +- .../cooking/milling/MillingRecipeLoader.java | 11 +- .../cooking/milling/MillingStoneHandler.java | 31 +- .../cooking/mixing/MixingBowlHandler.java | 44 +- .../cooking/nutrition/BowlEatHandler.java | 4 + .../cooking/nutrition/NutritionConfig.java | 2 +- .../cooking/nutrition/NutritionService.java | 8 +- .../cooking/nutrition/VarietyMath.java | 6 - .../cooking/oven/OvenBurnManager.java | 4 + .../cooking/oven/OvenCavityManager.java | 16 +- .../tfminecraft/cooking/oven/OvenHandler.java | 7 +- .../tfminecraft/cooking/oven/OvenState.java | 3 - .../cooking/quality/CompositionConfig.java | 5 +- .../quality/CompositionQualityResolver.java | 8 +- .../quality/OriginQualityResolver.java | 4 +- .../cooking/quality/QualityConfig.java | 7 +- .../sausagemaker/SausageMakerAnimation.java | 2 +- .../sausagemaker/SausageMakerHandler.java | 13 +- .../cooking/trough/TroughAging.java | 3 + .../cooking/trough/TroughHandler.java | 6 +- .../cooking/utils/DoughMixinRules.java | 9 +- .../tfminecraft/cooking/utils/FoodParser.java | 53 +- .../cooking/utils/IngredientConverter.java | 3 + .../cooking/utils/InventoryAdder.java | 5 +- .../cooking/utils/ItemBuilder.java | 31 +- .../tfminecraft/cooking/utils/ItemRef.java | 17 +- .../cooking/utils/ItemUpdater.java | 13 +- .../cooking/utils/NameComposer.java | 24 +- .../cooking/utils/QualityUtils.java | 2 +- .../cooking/utils/StackNormalizer.java | 6 - .../cooking/utils/StationAddonRules.java | 23 +- .../cooking/CookingLifecycleTest.java | 275 +++++++ .../baking/BakingTrayCoverageTest.java | 496 +++++++++++ .../cooking/cache/CachesCoverageTest.java | 174 ++++ .../carve/CarvingAndMeatHookCoverageTest.java | 448 ++++++++++ .../churn/ButterChurnCoverageTest.java | 507 ++++++++++++ .../cooking/CookingReferencesTest.java | 777 ++++++++++++++++++ .../cooking/PotReferenceCoverageTest.java | 480 +++++++++++ .../crafting/CraftingStationCoverageTest.java | 708 ++++++++++++++++ .../cooking/crops/CropsCoverageTest.java | 211 +++++ .../cooking/cup/CupItemsCoverageTest.java | 163 ++++ .../cooking/farming/FarmingCoverageTest.java | 274 ++++++ .../fishing/FishingCatalogCoverageTest.java | 85 ++ .../fishing/FishingRuntimeCoverageTest.java | 169 ++++ .../cooking/heat/HeatRuntimeCoverageTest.java | 110 +++ .../HusbandryInspectionCoverageTest.java | 181 ++++ .../HusbandryListenersCoverageTest.java | 345 ++++++++ .../HusbandryLoaderCoverageTest.java | 209 +++++ .../husbandry/HusbandryOwnedMountTest.java | 3 + .../HusbandryRemainingCoverageTest.java | 458 +++++++++++ .../HusbandryRuntimeCoverageTest.java | 374 +++++++++ .../cooking/item/FoodItemCoverageTest.java | 311 +++++++ .../item/IngredientRuntimeCoverageTest.java | 110 +++ .../cooking/item/NamingRulesCoverageTest.java | 144 ++++ .../item/model/FoodModelsCoverageTest.java | 126 +++ .../liquid/LiquidContainerCoverageTest.java | 275 +++++++ .../ConfigurationLoadersCoverageTest.java | 250 ++++++ .../loader/ResourceLoadersCoverageTest.java | 155 ++++ .../manager/CommandManagerCoverageTest.java | 521 ++++++++++++ .../CookingAndTagManagersCoverageTest.java | 234 ++++++ .../manager/CraftingManagerCoverageTest.java | 387 +++++++++ .../manager/MealManagersCoverageTest.java | 429 ++++++++++ .../milling/MillingStoneCoverageTest.java | 409 +++++++++ .../mixing/MixingBowlCoverageTest.java | 265 ++++++ .../NutritionListenersCoverageTest.java | 319 +++++++ .../NutritionRuntimeCoverageTest.java | 254 ++++++ .../cooking/oven/OvenCoverageTest.java | 316 +++++++ .../quality/QualityRuntimeCoverageTest.java | 146 ++++ .../SausageMakerCoverageTest.java | 455 ++++++++++ .../cooking/trough/TroughCoverageTest.java | 141 ++++ .../cooking/utils/FoodParserCoverageTest.java | 123 +++ .../cooking/utils/ItemAgingCoverageTest.java | 149 ++++ .../utils/ItemBuilderCoverageTest.java | 225 +++++ .../utils/RuntimeUtilitiesCoverageTest.java | 193 +++++ 145 files changed, 13054 insertions(+), 670 deletions(-) create mode 100644 src/test/java/net/tfminecraft/cooking/CookingLifecycleTest.java create mode 100644 src/test/java/net/tfminecraft/cooking/baking/BakingTrayCoverageTest.java create mode 100644 src/test/java/net/tfminecraft/cooking/cache/CachesCoverageTest.java create mode 100644 src/test/java/net/tfminecraft/cooking/carve/CarvingAndMeatHookCoverageTest.java create mode 100644 src/test/java/net/tfminecraft/cooking/churn/ButterChurnCoverageTest.java create mode 100644 src/test/java/net/tfminecraft/cooking/cooking/CookingReferencesTest.java create mode 100644 src/test/java/net/tfminecraft/cooking/cooking/PotReferenceCoverageTest.java create mode 100644 src/test/java/net/tfminecraft/cooking/crafting/CraftingStationCoverageTest.java create mode 100644 src/test/java/net/tfminecraft/cooking/crops/CropsCoverageTest.java create mode 100644 src/test/java/net/tfminecraft/cooking/cup/CupItemsCoverageTest.java create mode 100644 src/test/java/net/tfminecraft/cooking/farming/FarmingCoverageTest.java create mode 100644 src/test/java/net/tfminecraft/cooking/fishing/FishingCatalogCoverageTest.java create mode 100644 src/test/java/net/tfminecraft/cooking/fishing/FishingRuntimeCoverageTest.java create mode 100644 src/test/java/net/tfminecraft/cooking/heat/HeatRuntimeCoverageTest.java create mode 100644 src/test/java/net/tfminecraft/cooking/husbandry/HusbandryInspectionCoverageTest.java create mode 100644 src/test/java/net/tfminecraft/cooking/husbandry/HusbandryListenersCoverageTest.java create mode 100644 src/test/java/net/tfminecraft/cooking/husbandry/HusbandryLoaderCoverageTest.java create mode 100644 src/test/java/net/tfminecraft/cooking/husbandry/HusbandryRemainingCoverageTest.java create mode 100644 src/test/java/net/tfminecraft/cooking/husbandry/HusbandryRuntimeCoverageTest.java create mode 100644 src/test/java/net/tfminecraft/cooking/item/FoodItemCoverageTest.java create mode 100644 src/test/java/net/tfminecraft/cooking/item/IngredientRuntimeCoverageTest.java create mode 100644 src/test/java/net/tfminecraft/cooking/item/NamingRulesCoverageTest.java create mode 100644 src/test/java/net/tfminecraft/cooking/item/model/FoodModelsCoverageTest.java create mode 100644 src/test/java/net/tfminecraft/cooking/liquid/LiquidContainerCoverageTest.java create mode 100644 src/test/java/net/tfminecraft/cooking/loader/ConfigurationLoadersCoverageTest.java create mode 100644 src/test/java/net/tfminecraft/cooking/loader/ResourceLoadersCoverageTest.java create mode 100644 src/test/java/net/tfminecraft/cooking/manager/CommandManagerCoverageTest.java create mode 100644 src/test/java/net/tfminecraft/cooking/manager/CookingAndTagManagersCoverageTest.java create mode 100644 src/test/java/net/tfminecraft/cooking/manager/CraftingManagerCoverageTest.java create mode 100644 src/test/java/net/tfminecraft/cooking/manager/MealManagersCoverageTest.java create mode 100644 src/test/java/net/tfminecraft/cooking/milling/MillingStoneCoverageTest.java create mode 100644 src/test/java/net/tfminecraft/cooking/mixing/MixingBowlCoverageTest.java create mode 100644 src/test/java/net/tfminecraft/cooking/nutrition/NutritionListenersCoverageTest.java create mode 100644 src/test/java/net/tfminecraft/cooking/nutrition/NutritionRuntimeCoverageTest.java create mode 100644 src/test/java/net/tfminecraft/cooking/oven/OvenCoverageTest.java create mode 100644 src/test/java/net/tfminecraft/cooking/quality/QualityRuntimeCoverageTest.java create mode 100644 src/test/java/net/tfminecraft/cooking/sausagemaker/SausageMakerCoverageTest.java create mode 100644 src/test/java/net/tfminecraft/cooking/trough/TroughCoverageTest.java create mode 100644 src/test/java/net/tfminecraft/cooking/utils/FoodParserCoverageTest.java create mode 100644 src/test/java/net/tfminecraft/cooking/utils/ItemAgingCoverageTest.java create mode 100644 src/test/java/net/tfminecraft/cooking/utils/ItemBuilderCoverageTest.java create mode 100644 src/test/java/net/tfminecraft/cooking/utils/RuntimeUtilitiesCoverageTest.java diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index 495c41d..48d130d 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -74,3 +74,11 @@ jobs: name: unit-test-reports-${{ github.run_id }}-${{ github.run_attempt }} path: target/surefire-reports/ if-no-files-found: error + + - name: Upload coverage report + if: ${{ !cancelled() && hashFiles('target/site/jacoco/jacoco.xml') != '' }} + uses: actions/upload-artifact@v7 + with: + name: coverage-report-${{ github.run_id }}-${{ github.run_attempt }} + path: target/site/jacoco/ + if-no-files-found: error diff --git a/README.md b/README.md index dfda91a..0d518e2 100644 --- a/README.md +++ b/README.md @@ -34,9 +34,11 @@ mvn -B --no-transfer-progress clean verify The JUnit 5 suite covers food parsing and items, crops, husbandry, nutrition, fishing and configuration loading, using MockBukkit and Mockito where Bukkit or plugin APIs are involved. The Build and release workflows run the same command, -and the Build workflow uploads the Surefire reports. No coverage gate is -enforced. The tests do not replace checking kitchens, crops and livestock on a -live Paper server with the pinned integrations. +and the Build workflow uploads the Surefire reports. JaCoCo requires 100% line +coverage across all production classes, with no coverage exclusions. HTML and XML +reports are written to `target/site/jacoco/` and uploaded by the Build workflow. +The tests do not replace checking kitchens, crops and livestock on a live Paper +server with the pinned integrations. ## License diff --git a/pom.xml b/pom.xml index 3c67c1c..67894fa 100644 --- a/pom.xml +++ b/pom.xml @@ -32,6 +32,12 @@ + + com.googlecode.json-simple + json-simple + 1.1.1 + test + org.mockbukkit.mockbukkit mockbukkit-v1.21 @@ -185,6 +191,38 @@ + + org.jacoco + jacoco-maven-plugin + 0.8.15 + + prepare-agent + + coverage-report + verify + report + + + coverage-check + verify + check + + + + BUNDLE + + + LINE + MISSEDCOUNT + 0 + + + + + + + + org.apache.maven.plugins maven-compiler-plugin diff --git a/src/main/java/net/tfminecraft/cooking/baking/BakingTrayAging.java b/src/main/java/net/tfminecraft/cooking/baking/BakingTrayAging.java index cdef1ac..e2b987e 100644 --- a/src/main/java/net/tfminecraft/cooking/baking/BakingTrayAging.java +++ b/src/main/java/net/tfminecraft/cooking/baking/BakingTrayAging.java @@ -35,9 +35,10 @@ private void tick() { .getPlacedFurniture(); for (Furniture furniture : placed.values()) { - if (BakingTrayRegistry.isTray(furniture) && !furniture.isAttached()) { + BakingTrayRecipe recipe = BakingTrayRegistry.getByFurniture(furniture); + if (recipe != null && !furniture.isAttached()) { if (visited.add(furniture.getEntityId())) { - ageTray(furniture); + ageTray(furniture, recipe); } } @@ -47,22 +48,18 @@ private void tick() { for (PlacedFurnitureSlot slot : furniture.getActiveFurnitureSlots().values()) { Furniture nested = slot.getNested(); - if (nested == null || !BakingTrayRegistry.isTray(nested)) { + BakingTrayRecipe nestedRecipe = BakingTrayRegistry.getByFurniture(nested); + if (nestedRecipe == null) { continue; } if (visited.add(nested.getEntityId())) { - ageTray(nested); + ageTray(nested, nestedRecipe); } } } } - private void ageTray(Furniture tray) { - BakingTrayRecipe recipe = BakingTrayRegistry.getByFurniture(tray); - if (recipe == null) { - return; - } - + private void ageTray(Furniture tray, BakingTrayRecipe recipe) { boolean changed = false; for (String slotId : recipe.getAllSlotIds()) { if (!tray.hasActiveSlot(slotId)) { @@ -94,7 +91,7 @@ private boolean ageSlot(PlacedSlot slot, Furniture tray) { } ItemStack updated = ItemUpdater.updateItem(item, foodItem, tray.getId()); - if (updated == null) { + if (updated == null || updated.getType().isAir()) { return false; } diff --git a/src/main/java/net/tfminecraft/cooking/baking/BakingTrayBakeApplier.java b/src/main/java/net/tfminecraft/cooking/baking/BakingTrayBakeApplier.java index 08110a0..e736108 100644 --- a/src/main/java/net/tfminecraft/cooking/baking/BakingTrayBakeApplier.java +++ b/src/main/java/net/tfminecraft/cooking/baking/BakingTrayBakeApplier.java @@ -69,7 +69,6 @@ private static boolean applyToSlot(Furniture tray, String slotId, int fromValue, return false; } cookedTrack = new TagTrack(baseTrack); - foodItem.addOrModifyTrack(cookedTrack); } if (cookedTrack.getValue() != fromValue) { @@ -77,8 +76,9 @@ private static boolean applyToSlot(Furniture tray, String slotId, int fromValue, } cookedTrack.setValue(toValue); + foodItem.addOrModifyTrack(cookedTrack); ItemStack updated = ItemUpdater.applyItemUpdate(item, foodItem, tray.getId()); - if (updated == null) { + if (updated == null || updated.getType().isAir()) { return false; } diff --git a/src/main/java/net/tfminecraft/cooking/baking/BakingTrayHandler.java b/src/main/java/net/tfminecraft/cooking/baking/BakingTrayHandler.java index 530aea6..a1808fc 100644 --- a/src/main/java/net/tfminecraft/cooking/baking/BakingTrayHandler.java +++ b/src/main/java/net/tfminecraft/cooking/baking/BakingTrayHandler.java @@ -154,15 +154,8 @@ private boolean handleTake(Furniture furniture, BakingTrayRecipe recipe, Player return false; } - PlacedSlot slot = furniture.getActiveSlot(slotId).orElse(null); - if (slot == null) { - return false; - } - + PlacedSlot slot = furniture.getActiveSlot(slotId).orElseThrow(); ItemStack item = slot.getCurrentItem(); - if (item == null || item.getType().isAir()) { - return false; - } FoodItem foodItem = FoodItem.fromItem(item); if (foodItem == null) { @@ -173,7 +166,7 @@ private boolean handleTake(Furniture furniture, BakingTrayRecipe recipe, Player if (WarmthUtils.isHeated(foodItem, 1)) { WarmthUtils.applyHot(foodItem); toGive = ItemUpdater.applyItemUpdate(item, foodItem, furniture.getId()); - if (toGive == null) { + if (toGive == null || toGive.getType().isAir()) { return false; } } else { @@ -201,9 +194,6 @@ private boolean handleTake(Furniture furniture, BakingTrayRecipe recipe, Player } private static boolean matchesFillInput(BakingTrayRecipe recipe, ItemStack hand) { - if (hand == null) { - return false; - } BakingTrayFill fill = recipe.getFill(); if (fill.getInputFood() != null) { FoodItem foodItem = FoodItem.fromItem(hand); @@ -219,9 +209,6 @@ private static boolean matchesFillInput(BakingTrayRecipe recipe, ItemStack hand) } private static boolean matchesPlaceableLoaf(BakingTrayRecipe recipe, ItemStack hand) { - if (hand == null || hand.getType().isAir()) { - return false; - } FoodItem foodItem = FoodItem.fromItem(hand); if (foodItem == null) { return false; diff --git a/src/main/java/net/tfminecraft/cooking/baking/BakingTrayTransform.java b/src/main/java/net/tfminecraft/cooking/baking/BakingTrayTransform.java index 12038bb..ea50930 100644 --- a/src/main/java/net/tfminecraft/cooking/baking/BakingTrayTransform.java +++ b/src/main/java/net/tfminecraft/cooking/baking/BakingTrayTransform.java @@ -65,9 +65,6 @@ private static void applyTagSpec(FoodItem item, String tagsSpec) { } String[] kv = part.split("\\.", 2); - if (kv.length != 2) { - continue; - } TagTrack baseTrack = TrackLoader.getByString(kv[0]); if (baseTrack == null) { diff --git a/src/main/java/net/tfminecraft/cooking/cache/FurnitureCache.java b/src/main/java/net/tfminecraft/cooking/cache/FurnitureCache.java index 2ac8dc3..88b8fb0 100644 --- a/src/main/java/net/tfminecraft/cooking/cache/FurnitureCache.java +++ b/src/main/java/net/tfminecraft/cooking/cache/FurnitureCache.java @@ -1,5 +1,7 @@ package net.tfminecraft.cooking.cache; +import java.util.Locale; + import net.tfminecraft.cooking.enums.Method; import net.tfminecraft.interactiblefurniture.furniture.Furniture; @@ -94,6 +96,6 @@ public static boolean isMealHolder(Furniture f) { // Other plugins' furniture (pedestals, displays) keeps its own slot display. public static boolean isCookingFurniture(Furniture f) { return f.getType() != null - && f.getType().getItemPath().toLowerCase().startsWith("ia.tfmc_cooking:"); + && f.getType().getItemPath().toLowerCase(Locale.ROOT).startsWith("ia.tfmc_cooking:"); } } diff --git a/src/main/java/net/tfminecraft/cooking/cache/NamingConfig.java b/src/main/java/net/tfminecraft/cooking/cache/NamingConfig.java index e4ce699..cead0c9 100644 --- a/src/main/java/net/tfminecraft/cooking/cache/NamingConfig.java +++ b/src/main/java/net/tfminecraft/cooking/cache/NamingConfig.java @@ -5,6 +5,7 @@ import java.util.HashSet; import java.util.List; import java.util.Map; +import java.util.Locale; import java.util.Set; public final class NamingConfig { @@ -65,7 +66,7 @@ public static boolean isCategory(String bucket, String category) { if (category == null) { return false; } - return getCategories(bucket).contains(category.toLowerCase()); + return getCategories(bucket).contains(category.toLowerCase(Locale.ROOT)); } public static Set allCategoryBuckets() { @@ -76,21 +77,21 @@ public static String getTagLabelDefault(String category, String trackId, String if (category == null || trackId == null || stepId == null) { return null; } - Map> byTrack = tagLabelDefaults.get(category.toLowerCase()); + Map> byTrack = tagLabelDefaults.get(category.toLowerCase(Locale.ROOT)); if (byTrack == null) { return null; } - Map byStep = byTrack.get(trackId.toLowerCase()); + Map byStep = byTrack.get(trackId.toLowerCase(Locale.ROOT)); if (byStep == null) { return null; } - return byStep.get(stepId.toLowerCase()); + return byStep.get(stepId.toLowerCase(Locale.ROOT)); } public static String getOriginAdjective(String origin) { if (origin == null) { return null; } - return originAdjectives.get(origin.toLowerCase()); + return originAdjectives.get(origin.toLowerCase(Locale.ROOT)); } } diff --git a/src/main/java/net/tfminecraft/cooking/carve/CarvableRoastUtils.java b/src/main/java/net/tfminecraft/cooking/carve/CarvableRoastUtils.java index 9780f31..6ee37c3 100644 --- a/src/main/java/net/tfminecraft/cooking/carve/CarvableRoastUtils.java +++ b/src/main/java/net/tfminecraft/cooking/carve/CarvableRoastUtils.java @@ -47,8 +47,10 @@ public static void initCarveState(ItemStack stack, FoodItem item) { } public static boolean isCarvable(FoodItem item) { - return item != null && item.hasCarveState() && item.getCarveRemaining() > 0 - && item.getCarveNextIndex() < getSequence(item).getCuts().size(); + if (item == null || !item.hasCarveState() || item.getCarveRemaining() <= 0 + || item.getCarveNextIndex() < 0) return false; + CarveSequence sequence = getSequence(item); + return sequence != null && item.getCarveNextIndex() < sequence.getCuts().size(); } public static boolean isCarvable(ItemStack stack) { @@ -162,16 +164,13 @@ public static int visualCarveStage(FoodItem item, CarveSequence seq) { } public static ModelData getStageModelData(FoodItem item) { + FoodModel model = item.getModel(); + if (model == null || model.getStates().isEmpty()) return null; if (!item.hasCarveState() || item.getCarveRemaining() <= 0) { - FoodModel model = item.getModel(); - ModelData fallback = model != null ? model.getModel(item) : null; - return fallback != null ? fallback : item.getModel().getModel(item); + return model.getModel(item); } String cookTag = resolveCookTag(item); - ModelData staged = item.getModel().getModelByStageAndTag(getVisualCarveStage(item), cookTag); - if (staged != null) return staged; - FoodModel model = item.getModel(); - return model != null ? model.getModel(item) : null; + return model.getModelByStageAndTag(getVisualCarveStage(item), cookTag); } public static String resolveCookTag(FoodItem item) { diff --git a/src/main/java/net/tfminecraft/cooking/carve/CarveHandler.java b/src/main/java/net/tfminecraft/cooking/carve/CarveHandler.java index ac7c43f..97c9579 100644 --- a/src/main/java/net/tfminecraft/cooking/carve/CarveHandler.java +++ b/src/main/java/net/tfminecraft/cooking/carve/CarveHandler.java @@ -42,6 +42,7 @@ import net.tfminecraft.cooking.utils.ItemUpdater; import net.tfminecraft.interactiblefurniture.furniture.Furniture; +import net.tfminecraft.interactiblefurniture.InteractibleFurniture; import net.tfminecraft.interactiblefurniture.furniture.PlacedSlot; @@ -93,50 +94,31 @@ public static boolean tryCarve(Player player, Furniture furniture, PlacedSlot sl ItemStack reward = buildReward(player, roast, stack, cut, sequence); - if (reward == null) return false; - - - - Location dropLoc = furniture.getLoc(); - - giveReward(player, dropLoc, reward); - - - - CarvableRoastUtils.advanceAfterCarve(roast); - - dropLoc.getWorld().playSound(dropLoc, Sound.BLOCK_SWEET_BERRY_BUSH_PICK_BERRIES, 1f, 1f); - - - - if (roast.getCarveRemaining() <= 0) { + if (reward == null || reward.getType().isAir()) return false; + FoodItem remaining = new FoodItem(roast); + CarvableRoastUtils.advanceAfterCarve(remaining); + if (remaining.getCarveRemaining() <= 0) { slot.clearModel(); - furniture.removeActiveSlot(slot.getId()); - - return true; - + } else { + var model = CarvableRoastUtils.getStageModelData(remaining); + if (model == null) return false; + ItemStack updated = ItemUpdater.applyItemUpdate(stack.clone(), remaining, furniture.getId()); + if (updated == null || updated.getType().isAir()) return false; + CarvableRoastUtils.writeCarveState(updated, remaining); + slot.setCurrentItem(updated); + slot.applyDisplayData(model.getDisplayData(furniture.getId())); } - - - ItemStack updated = ItemUpdater.applyItemUpdate(stack, roast, furniture.getId()); - - if (updated == null) return true; - - CarvableRoastUtils.writeCarveState(updated, roast); - - slot.setCurrentItem(updated); - - slot.applyDisplayData(CarvableRoastUtils.getStageModelData(roast).getDisplayData(furniture.getId())); - + Location dropLoc = furniture.getLoc(); + InteractibleFurniture.getInstance().getFurnitureManager().markDirty(furniture); + giveReward(player, dropLoc, reward); + dropLoc.getWorld().playSound(dropLoc, Sound.BLOCK_SWEET_BERRY_BUSH_PICK_BERRIES, 1f, 1f); return true; - } - private static ItemStack buildReward(Player player, FoodItem roast, ItemStack stack, CarveCut cut, CarveSequence sequence) { if (cut.isItemCut()) { @@ -220,4 +202,3 @@ public static boolean tryCarveFirstCarvableSlot(Player player, Furniture furnitu } - diff --git a/src/main/java/net/tfminecraft/cooking/churn/ButterChurnHandler.java b/src/main/java/net/tfminecraft/cooking/churn/ButterChurnHandler.java index 942f27a..8fea7ef 100644 --- a/src/main/java/net/tfminecraft/cooking/churn/ButterChurnHandler.java +++ b/src/main/java/net/tfminecraft/cooking/churn/ButterChurnHandler.java @@ -229,6 +229,14 @@ private void handleChurn(Furniture furniture, Player player, int required) { } private void handlePlateCollection(Furniture churn, Furniture plate, Player player, int required) { + if (plate.getType().getSlots().isEmpty()) return; + for (SlotDefinition def : plate.getType().getSlots().values()) { + if (plate.getActiveSlot(def.getId()).map(PlacedSlot::getCurrentItem) + .filter(item -> !item.getType().isAir()).isPresent()) { + player.sendMessage("Empty the butter plate first."); + return; + } + } int count = ButterChurnState.getChurnCount(churn); if (count < required) { player.sendMessage("Churn the milk " + required + " times first (" + count + "/" + required + ")"); @@ -256,13 +264,16 @@ private void handlePlateCollection(Furniture churn, Furniture plate, Player play } FoodItem butterItem = FoodItem.fromItem(butter); + if (butterItem == null) { + player.sendMessage("Could not create butter."); + return; + } + ItemStack display = ItemUpdater.applyItemUpdate(butter.clone(), butterItem, plate.getId()); + if (display == null) display = butter.clone(); ItemDisplay churnDisplay = (ItemDisplay) Bukkit.getEntity(churn.getEntityId()); for (SlotDefinition def : plate.getType().getSlots().values()) { PlacedSlot slot = plate.getOrCreatePlacedSlot(def.getId()); - ItemStack display = butterItem == null - ? butter.clone() - : ItemUpdater.applyItemUpdate(butter.clone(), butterItem, plate.getId()); - slot.forceModel(display); + slot.forceModel(display.clone()); if (churnDisplay != null) { slot.followParentTransform(churnDisplay); } @@ -286,9 +297,6 @@ private boolean tryAddSalt(Furniture furniture, Player player, ItemStack hand) { } FoodItem salt = FoodItem.fromItem(hand); - if (salt == null) { - return false; - } ButterChurnState.setSalt(furniture, QualityUtils.clamp(salt.getQualityMin())); ButterChurnState.setSaltLineage(furniture, salt.getLineage()); @@ -362,9 +370,6 @@ private void resumeChurnsInChunk(Chunk chunk) { } private void ensureStick(Furniture furniture) { - if (!FurnitureCache.isButterChurn(furniture)) { - return; - } if (furniture.getType() == null || furniture.getType().getSlot(STICK_SLOT) == null) { return; } diff --git a/src/main/java/net/tfminecraft/cooking/churn/ButterItems.java b/src/main/java/net/tfminecraft/cooking/churn/ButterItems.java index 9060b05..1510d25 100644 --- a/src/main/java/net/tfminecraft/cooking/churn/ButterItems.java +++ b/src/main/java/net/tfminecraft/cooking/churn/ButterItems.java @@ -67,11 +67,13 @@ public static ItemStack fromMilkSnapshot( inputs.add(buildMilkStub(milkQuality, dairyFreshness, origin, milkLineage)); if (hasSalt) { + if (FoodLoader.getByString("seasoning_1") == null || TrackLoader.getByString("butter_salted") == null) return null; inputs.add(buildSaltStub(saltQuality, saltLineage)); } boolean hasSpice = spiceOrigin != null && !spiceOrigin.isBlank(); if (hasSpice) { + if (FoodLoader.getByString("spice_1") == null || TrackLoader.getByString("butter_spiced") == null) return null; inputs.add(buildSpiceStub(spiceOrigin, spiceQuality, spiceFreshness, spiceLineage)); } diff --git a/src/main/java/net/tfminecraft/cooking/cooking/CookingReference.java b/src/main/java/net/tfminecraft/cooking/cooking/CookingReference.java index f0f1389..2dc6275 100644 --- a/src/main/java/net/tfminecraft/cooking/cooking/CookingReference.java +++ b/src/main/java/net/tfminecraft/cooking/cooking/CookingReference.java @@ -121,13 +121,14 @@ public boolean add(String slot, ItemStack item) { if(!slot.contains("input")) return false; FoodItem fi = FoodItem.fromItem(item); if(fi == null) return false; - slots.put(slot, fi); if(f.getType() == null || f.getType().getSlot(slot) == null) return false; - PlacedSlot fslot = f.getOrCreatePlacedSlot(slot); ItemStack model = new ItemStack(item); model = ItemUpdater.applyItemUpdate(model, fi, f.getId()); + if(model == null) return false; model.setAmount(1); + PlacedSlot fslot = f.getOrCreatePlacedSlot(slot); fslot.forceModel(model); + slots.put(slot, fi); addColour(ItemCache.getColour(item)); f.getLoc().getWorld().playSound(f.getLoc(), Sound.ITEM_BUCKET_FILL, 1f, 1f); //TODO SOUND item.setAmount(item.getAmount()-1); @@ -206,7 +207,7 @@ public void slotAdd(FurnitureSlotItemAddEvent e) { } public void slotRemove(FurnitureSlotItemTakeEvent e) { - FoodItem fi = slots.remove(e.getSlot().getId()); + FoodItem fi = slots.get(e.getSlot().getId()); ItemStack item = e.getItem(); if(fi != null) { if(!fi.canBeCooked()) { @@ -218,11 +219,21 @@ public void slotRemove(FurnitureSlotItemTakeEvent e) { e.setCancelled(true); return; } - if(data.getCurrentTime() < 5) return; - if (!WarmthUtils.applyHot(fi)) return; + if(data.getCurrentTime() < 5) { + slots.remove(e.getSlot().getId()); + return; + } + if (!WarmthUtils.applyHot(fi)) { + e.setCancelled(true); + return; + } item = ItemUpdater.applyItemUpdate(item, fi, f.getId()); - if(item == null) return; + if(item == null) { + e.setCancelled(true); + return; + } e.setItem(item); + slots.remove(e.getSlot().getId()); org.bukkit.Bukkit.getPluginManager().callEvent( new net.tfminecraft.cooking.events.DishCookedEvent(e.getPlayer(), item, "station")); } diff --git a/src/main/java/net/tfminecraft/cooking/cooking/FryingReference.java b/src/main/java/net/tfminecraft/cooking/cooking/FryingReference.java index 0d3e5d1..b443c36 100644 --- a/src/main/java/net/tfminecraft/cooking/cooking/FryingReference.java +++ b/src/main/java/net/tfminecraft/cooking/cooking/FryingReference.java @@ -117,6 +117,7 @@ public void interact(FurnitureInteractEvent e) { } ItemStack display = ItemUpdater.applyItemUpdate(item.clone(), butterFi, f.getId()); + if (display == null) return; display.setAmount(1); item.setAmount(item.getAmount() - 1); butterExtra = new FoodItem(butterFi); @@ -192,7 +193,7 @@ public void slotAdd(FurnitureSlotItemAddEvent e) { @Override public void slotRemove(FurnitureSlotItemTakeEvent e) { - FoodItem fi = slots.remove(e.getSlot().getId()); + FoodItem fi = slots.get(e.getSlot().getId()); ItemStack item = e.getItem(); if (fi == null) { return; @@ -210,12 +211,14 @@ public void slotRemove(FurnitureSlotItemTakeEvent e) { ItemStack returned = ItemUpdater.applyItemUpdate(item, fi, null); if (returned != null) { e.setItem(returned); + slots.remove(e.getSlot().getId()); + } else { + e.setCancelled(true); } - slots.put(e.getSlot().getId(), fi); return; } if (!WarmthUtils.applyHot(fi)) { - slots.put(e.getSlot().getId(), fi); + e.setCancelled(true); return; } @@ -230,10 +233,11 @@ public void slotRemove(FurnitureSlotItemTakeEvent e) { item = ItemUpdater.applyItemUpdate(item, fi, null); if (item == null) { - slots.put(e.getSlot().getId(), fi); + e.setCancelled(true); return; } + slots.remove(e.getSlot().getId()); clearButterSecondary(); e.setItem(item); org.bukkit.Bukkit.getPluginManager().callEvent( diff --git a/src/main/java/net/tfminecraft/cooking/cooking/PotReference.java b/src/main/java/net/tfminecraft/cooking/cooking/PotReference.java index 1ec630f..e885cc7 100644 --- a/src/main/java/net/tfminecraft/cooking/cooking/PotReference.java +++ b/src/main/java/net/tfminecraft/cooking/cooking/PotReference.java @@ -257,8 +257,8 @@ private void handleCookingSlots() { FoodItem item = entry.getValue(); CookData data = item.getCookData(); if (!item.canBeCooked()) continue; - if (isSoup() && data.isBeingCooked()) { - data.stop(); + if (isSoup()) { + if (data.isBeingCooked()) data.stop(); continue; } if (!data.isBeingCooked()) { @@ -452,9 +452,11 @@ private void acceptExtra(Player p, ItemStack item, String extraKey) { stored.setAmount(1); FoodItem food = FoodItem.fromItem(stored); if (food == null) return; + Map updated = new HashMap<>(extraItems); + updated.put(extraKey, stored); + if (!saveExtras(updated)) return; extraItems.put(extraKey, stored); slots.put(extraKey, food); - saveExtras(); item.setAmount(item.getAmount() - 1); p.swingMainHand(); f.getLoc().getWorld().playSound(f.getLoc(), Sound.ITEM_BUCKET_FILL, 1f, 1f); @@ -493,27 +495,18 @@ private int mainCount() { return count; } - private void saveExtras() { - if (f == null) return; - if (extraItems.isEmpty()) { - f.getVariables().remove(VAR_EXTRAS); - } else { - StringBuilder encoded = new StringBuilder(); - boolean first = true; - for (Map.Entry entry : extraItems.entrySet()) { - String payload = encodeStack(entry.getValue()); - if (payload == null) continue; - if (!first) encoded.append('|'); - first = false; - encoded.append(entry.getKey()).append('.').append(payload); - } - if (encoded.isEmpty()) { - f.getVariables().remove(VAR_EXTRAS); - } else { - f.getVariables().put(VAR_EXTRAS, encoded.toString()); - } + private boolean saveExtras(Map updated) { + if (f == null) return false; + StringBuilder encoded = new StringBuilder(); + for (Map.Entry entry : updated.entrySet()) { + String payload = encodeStack(entry.getValue()); + if (payload == null) return false; + if (!encoded.isEmpty()) encoded.append('|'); + encoded.append(entry.getKey()).append('.').append(payload); } + f.getVariables().put(VAR_EXTRAS, encoded.toString()); InteractibleFurniture.getInstance().getFurnitureManager().markDirty(f); + return true; } private void restoreExtras() { @@ -562,7 +555,7 @@ static ItemStack decodeStack(String payload) { return stack; } } - } catch (IOException | ClassNotFoundException ignored) { + } catch (IOException | ClassNotFoundException | IllegalArgumentException ignored) { return null; } return null; @@ -697,9 +690,6 @@ private void applySoupLevel() { } private static boolean isWrongPotWaterSource(ItemStack item) { - if (item == null || item.getType() == Material.AIR) { - return false; - } return ItemCache.isCupOfWater(item) || ItemCache.isWater(item) || ItemCache.isLiquid(item); diff --git a/src/main/java/net/tfminecraft/cooking/crafting/CraftingStation.java b/src/main/java/net/tfminecraft/cooking/crafting/CraftingStation.java index 155db43..4f75255 100644 --- a/src/main/java/net/tfminecraft/cooking/crafting/CraftingStation.java +++ b/src/main/java/net/tfminecraft/cooking/crafting/CraftingStation.java @@ -123,7 +123,8 @@ public boolean canAddItem(ItemStack i) { for(ItemStack stack : slots.values()) { FoodItem fs = FoodItem.fromItem(stack); - if(fs != null && !fs.getOrigin().equalsIgnoreCase(fi.getOrigin())) { + if(fs != null && (fs.getOrigin() == null + ? fi.getOrigin() != null : !fs.getOrigin().equalsIgnoreCase(fi.getOrigin()))) { return false; } } @@ -195,7 +196,10 @@ public void craft() { public void craft(Player p) { if(currentRecipe == null) return; if(slots.isEmpty()) return; - if(slots.size() < currentRecipe.getRatio()) return; + int ratio = currentRecipe.getRatio(); + if (ratio <= 0) return; + int available = slots.values().stream().mapToInt(ItemStack::getAmount).sum(); + if(available < ratio) return; if ("cut_seafood".equals(currentRecipe.getId())) { craftSeafood(p); return; @@ -204,62 +208,60 @@ public void craft(Player p) { String origin = "none"; List consumedInputs = new ArrayList<>(); - int outputAmount = slots.size() / currentRecipe.getRatio(); - int used = outputAmount * currentRecipe.getRatio(); - - for(String slotId : new ArrayList<>(slots.keySet())) { - if(used <= 0) break; - - FoodItem fi = FoodItem.fromItem(slots.get(slotId)); - if(fi != null) { - consumedInputs.add(fi); - if(isSingleOrigin()) origin = fi.getOrigin(); + int outputAmount = available / ratio; + int used = outputAmount * ratio; + List remainingInputs = new ArrayList<>(); + for (ItemStack source : slots.values()) { + int take = Math.min(used, source.getAmount()); + if (take > 0) { + FoodItem fi = FoodItem.fromItem(source); + if (fi != null) { + for (int i = 0; i < take; i++) consumedInputs.add(fi); + if (isSingleOrigin()) origin = fi.getOrigin(); + } + used -= take; } - - slots.remove(slotId); - - PlacedSlot slot = f.getActiveSlot(slotId).orElse(null); - if(slot != null) { - slot.clearModel(); - f.removeActiveSlot(slotId); + if (take < source.getAmount()) { + ItemStack remaining = source.clone(); + remaining.setAmount(source.getAmount() - take); + remainingInputs.add(remaining); } - - used--; } - String data = new String(currentRecipe.getOutput()); - if(isSingleOrigin() && !origin.equalsIgnoreCase("none")) { + String data = currentRecipe.getOutput(); + if(isSingleOrigin() && origin != null && !origin.equalsIgnoreCase("none")) { data = data.replace("{origin}", origin); } - FoodItem item = FoodParser.parse(data).template; + FoodParser.Result parsed = FoodParser.parse(data); + if (parsed == null || parsed.template == null) return; + FoodItem item = parsed.template; if(currentRecipe.isProcessed()) { item.addOrModifyTrack(TrackLoader.getByString("processed")); } - f.getLoc().getWorld().playSound(f.getLoc(), Sound.BLOCK_SWEET_BERRY_BUSH_PICK_BERRIES, 1f, 1f); - - for(Map.Entry entry : slots.entrySet()) { - PlacedSlot slot = f.getActiveSlot(entry.getKey()).orElse(null); - ItemStack remaining = slot.getCurrentItem(); - if(remaining == null) continue; - - if(slot != null) slot.clearModel(); - - f.getLoc().getWorld().dropItem( - f.getLoc(), - remaining - ).setVelocity(new Vector(Math.random()*0.2-0.1, 0.1, Math.random()*0.2-0.1)); - } - slots.clear(); - currentRecipe = null; - CompositionResult composed = CompositionQualityResolver.compose(p, consumedInputs, CompositionContext.CUTTING_BOARD); CompositionApplier.apply(item, composed); ItemStack output = ItemBuilder.buildSingleWithQuality(item, null, composed.getFinalQuality()); + if (output == null || output.getType().isAir()) return; output.setAmount(outputAmount); + // Prepare the complete result before taking any ingredient from the board. + var modelData = item.getModelData(); + var displayData = modelData == null ? null : modelData.getDisplayData(f.getId()); + for (String slotId : slots.keySet()) { + f.getActiveSlot(slotId).ifPresent(PlacedSlot::clearModel); + f.removeActiveSlot(slotId); + } + for (ItemStack remaining : remainingInputs) { + f.getLoc().getWorld().dropItem(f.getLoc(), remaining) + .setVelocity(new Vector(Math.random()*0.2-0.1, 0.1, Math.random()*0.2-0.1)); + } + slots.clear(); + currentRecipe = null; + f.getLoc().getWorld().playSound(f.getLoc(), Sound.BLOCK_SWEET_BERRY_BUSH_PICK_BERRIES, 1f, 1f); + boolean onBoard = false; for(SlotDefinition def : f.getType().getSlots().values()) { if(f.hasActiveSlot(def.getId())) continue; @@ -267,7 +269,7 @@ public void craft(Player p) { PlacedSlot slot = f.getOrCreatePlacedSlot(def.getId()); slot.forceModel(output); onBoard = true; - slot.applyDisplayData(item.getModelData().getDisplayData(f.getId())); + if (displayData != null) slot.applyDisplayData(displayData); // Attempt to match new currentRecipe based on the output item CraftingRecipe newRecipe = null; diff --git a/src/main/java/net/tfminecraft/cooking/crops/CropCustomCropsListener.java b/src/main/java/net/tfminecraft/cooking/crops/CropCustomCropsListener.java index c707ef4..a0070f6 100644 --- a/src/main/java/net/tfminecraft/cooking/crops/CropCustomCropsListener.java +++ b/src/main/java/net/tfminecraft/cooking/crops/CropCustomCropsListener.java @@ -112,15 +112,33 @@ private void scheduleInventoryRewrite(Player player, CropDefinition crop, int qu PlayerInventory inventory = online.getInventory(); ItemStack[] now = inventory.getStorageContents(); int length = Math.min(before.length, now.length); + java.util.List additions = new java.util.ArrayList<>(); for (int i = 0; i < length; i++) { if (sameStack(before[i], now[i])) { continue; } - ItemStack rewritten = CropHarvestItems.rewriteCustomDrop(now[i], crop, quality); - if (rewritten != null && rewritten != now[i]) { + ItemStack candidate = now[i]; + boolean grewExisting = before[i] != null && candidate != null && before[i].isSimilar(candidate); + if (grewExisting) { + int gained = candidate.getAmount() - before[i].getAmount(); + if (gained <= 0) continue; + candidate = candidate.clone(); + candidate.setAmount(gained); + } + ItemStack rewritten = CropHarvestItems.rewriteCustomDrop(candidate, crop, quality); + if (rewritten == null || rewritten == candidate || rewritten.getType().isAir()) continue; + if (grewExisting) { + inventory.setItem(i, before[i].clone()); + additions.add(rewritten); + } else { inventory.setItem(i, rewritten); } } + for (ItemStack addition : additions) { + for (ItemStack leftover : inventory.addItem(addition).values()) { + online.getWorld().dropItemNaturally(online.getLocation(), leftover); + } + } }, 1L); } diff --git a/src/main/java/net/tfminecraft/cooking/crops/CropGrowthChance.java b/src/main/java/net/tfminecraft/cooking/crops/CropGrowthChance.java index df73292..baedeaf 100644 --- a/src/main/java/net/tfminecraft/cooking/crops/CropGrowthChance.java +++ b/src/main/java/net/tfminecraft/cooking/crops/CropGrowthChance.java @@ -29,7 +29,7 @@ public static boolean rollGrows(int fertility, double affection, Random random) } static void validateAffection(double affection) { - if (affection <= 0.0 || affection > 1.0) { + if (!Double.isFinite(affection) || affection <= 0.0 || affection > 1.0) { throw new IllegalArgumentException("affection must be in (0, 1], got " + affection); } } diff --git a/src/main/java/net/tfminecraft/cooking/crops/CropHarvestQuality.java b/src/main/java/net/tfminecraft/cooking/crops/CropHarvestQuality.java index 4a65e3f..b2ff677 100644 --- a/src/main/java/net/tfminecraft/cooking/crops/CropHarvestQuality.java +++ b/src/main/java/net/tfminecraft/cooking/crops/CropHarvestQuality.java @@ -15,7 +15,7 @@ private CropHarvestQuality() {} public static double[] weights(int fertility, double affection) { double aff = affection; - if (aff <= 0.0 || aff > 1.0) { + if (!Double.isFinite(aff) || aff <= 0.0 || aff > 1.0) { aff = 0.5; } int fert = Math.max(0, Math.min(100, fertility)); diff --git a/src/main/java/net/tfminecraft/cooking/crops/CropsLoader.java b/src/main/java/net/tfminecraft/cooking/crops/CropsLoader.java index 5b3edd5..da75081 100644 --- a/src/main/java/net/tfminecraft/cooking/crops/CropsLoader.java +++ b/src/main/java/net/tfminecraft/cooking/crops/CropsLoader.java @@ -89,7 +89,7 @@ private static Map parseCrops(ConfigurationSection secti } private static double clampAffection(double affection) { - if (affection <= 0.0 || affection > 1.0) { + if (!Double.isFinite(affection) || affection <= 0.0 || affection > 1.0) { return 0.5; } return affection; diff --git a/src/main/java/net/tfminecraft/cooking/cup/CupItems.java b/src/main/java/net/tfminecraft/cooking/cup/CupItems.java index c45ded1..81d514a 100644 --- a/src/main/java/net/tfminecraft/cooking/cup/CupItems.java +++ b/src/main/java/net/tfminecraft/cooking/cup/CupItems.java @@ -13,11 +13,13 @@ public final class CupItems { private CupItems() {} public static ItemStack emptyCup() { - return TLibs.getItemAPI().getCreator().getItemFromPath(ItemCache.emptyCup).clone(); + ItemStack item = TLibs.getItemAPI().getCreator().getItemFromPath(ItemCache.emptyCup); + return item == null ? null : item.clone(); } public static ItemStack cupOfWater() { - return TLibs.getItemAPI().getCreator().getItemFromPath(ItemCache.cupOfWater).clone(); + ItemStack item = TLibs.getItemAPI().getCreator().getItemFromPath(ItemCache.cupOfWater); + return item == null ? null : item.clone(); } public static ItemStack cupOfMilk(Player player, int quality, int dairyFreshnessValue) { diff --git a/src/main/java/net/tfminecraft/cooking/farming/FarmingLoader.java b/src/main/java/net/tfminecraft/cooking/farming/FarmingLoader.java index aa64269..e809849 100644 --- a/src/main/java/net/tfminecraft/cooking/farming/FarmingLoader.java +++ b/src/main/java/net/tfminecraft/cooking/farming/FarmingLoader.java @@ -72,9 +72,6 @@ public void load(File file) { private static List parseTools(List> raw) { List tools = new ArrayList<>(); - if (raw == null) { - return tools; - } for (Map entry : raw) { Object pathRaw = entry.get("path"); if (pathRaw == null || String.valueOf(pathRaw).isBlank()) { @@ -109,9 +106,6 @@ private static List parseTools(List> raw) { private static Map parseCrops(List> raw) { Map crops = new HashMap<>(); - if (raw == null) { - return crops; - } for (Map entry : raw) { Material crop = parseMaterial(entry.get("crop")); Material seed = parseMaterial(entry.get("seed")); diff --git a/src/main/java/net/tfminecraft/cooking/fishing/CustomFishingCatalog.java b/src/main/java/net/tfminecraft/cooking/fishing/CustomFishingCatalog.java index 49bbe2d..6dd6ad7 100644 --- a/src/main/java/net/tfminecraft/cooking/fishing/CustomFishingCatalog.java +++ b/src/main/java/net/tfminecraft/cooking/fishing/CustomFishingCatalog.java @@ -255,7 +255,8 @@ private static CutRule readCutRule(String key, Node section) { private static Double decimal(String raw) { try { - return Double.valueOf(raw.trim()); + double value = Double.parseDouble(raw.trim()); + return Double.isFinite(value) ? value : null; } catch (NumberFormatException exception) { return null; } diff --git a/src/main/java/net/tfminecraft/cooking/fishing/SeafoodWholeItems.java b/src/main/java/net/tfminecraft/cooking/fishing/SeafoodWholeItems.java index 0bcf1b9..861b9cb 100644 --- a/src/main/java/net/tfminecraft/cooking/fishing/SeafoodWholeItems.java +++ b/src/main/java/net/tfminecraft/cooking/fishing/SeafoodWholeItems.java @@ -51,9 +51,6 @@ private static FoodItem templateOrNull() { } private static ItemStack build(ItemStack caught, FoodItem item, int quality) { - if (item == null || caught == null) { - return null; - } return ItemBuilder.buildSingleWithQuality(item, caught, quality); } } diff --git a/src/main/java/net/tfminecraft/cooking/heat/HeatLoader.java b/src/main/java/net/tfminecraft/cooking/heat/HeatLoader.java index c809b55..fd3ad42 100644 --- a/src/main/java/net/tfminecraft/cooking/heat/HeatLoader.java +++ b/src/main/java/net/tfminecraft/cooking/heat/HeatLoader.java @@ -26,7 +26,7 @@ public static void load(FileConfiguration config) { + "' for furniture '" + furnitureId + "'"); continue; } - sources.put(furnitureId.toLowerCase(), new HeatSourceDefinition(furnitureId, type)); + sources.put(furnitureId.toLowerCase(java.util.Locale.ROOT), new HeatSourceDefinition(furnitureId, type)); } } @@ -42,7 +42,7 @@ public static void load(FileConfiguration config) { + furnitureId + "'"); continue; } - consumers.put(furnitureId.toLowerCase(), + consumers.put(furnitureId.toLowerCase(java.util.Locale.ROOT), new HeatConsumerDefinition(furnitureId, sourceFurnitureId, lookup)); } } diff --git a/src/main/java/net/tfminecraft/cooking/heat/HeatLookup.java b/src/main/java/net/tfminecraft/cooking/heat/HeatLookup.java index 133ba40..72118eb 100644 --- a/src/main/java/net/tfminecraft/cooking/heat/HeatLookup.java +++ b/src/main/java/net/tfminecraft/cooking/heat/HeatLookup.java @@ -7,7 +7,7 @@ public static HeatLookup fromConfig(String value) { if (value == null) { return null; } - return switch (value.toLowerCase().replace('-', '_')) { + return switch (value.toLowerCase(java.util.Locale.ROOT).replace('-', '_')) { case "block_below" -> BLOCK_BELOW; default -> null; }; diff --git a/src/main/java/net/tfminecraft/cooking/heat/HeatSourceType.java b/src/main/java/net/tfminecraft/cooking/heat/HeatSourceType.java index 0dde678..780517d 100644 --- a/src/main/java/net/tfminecraft/cooking/heat/HeatSourceType.java +++ b/src/main/java/net/tfminecraft/cooking/heat/HeatSourceType.java @@ -1,5 +1,7 @@ package net.tfminecraft.cooking.heat; +import java.util.Locale; + public enum HeatSourceType { OVEN, CAMPFIRE; @@ -8,7 +10,7 @@ public static HeatSourceType fromConfig(String value) { if (value == null) { return null; } - return switch (value.toLowerCase().replace('-', '_')) { + return switch (value.toLowerCase(Locale.ROOT).replace('-', '_')) { case "oven" -> OVEN; case "campfire" -> CAMPFIRE; default -> null; diff --git a/src/main/java/net/tfminecraft/cooking/heat/HeatSources.java b/src/main/java/net/tfminecraft/cooking/heat/HeatSources.java index 767cb44..ab3ebb8 100644 --- a/src/main/java/net/tfminecraft/cooking/heat/HeatSources.java +++ b/src/main/java/net/tfminecraft/cooking/heat/HeatSources.java @@ -29,21 +29,21 @@ public static boolean isSource(Furniture furniture) { if (furniture == null) { return false; } - return sources.containsKey(furniture.getId().toLowerCase()); + return sources.containsKey(furniture.getId().toLowerCase(java.util.Locale.ROOT)); } public static boolean isConsumer(Furniture furniture) { if (furniture == null) { return false; } - return consumers.containsKey(furniture.getId().toLowerCase()); + return consumers.containsKey(furniture.getId().toLowerCase(java.util.Locale.ROOT)); } public static boolean hasHeat(Furniture source) { if (source == null) { return false; } - HeatSourceDefinition definition = sources.get(source.getId().toLowerCase()); + HeatSourceDefinition definition = sources.get(source.getId().toLowerCase(java.util.Locale.ROOT)); if (definition == null) { return false; } @@ -54,7 +54,7 @@ public static Optional findSource(Furniture consumer) { if (consumer == null) { return Optional.empty(); } - HeatConsumerDefinition definition = consumers.get(consumer.getId().toLowerCase()); + HeatConsumerDefinition definition = consumers.get(consumer.getId().toLowerCase(java.util.Locale.ROOT)); if (definition == null) { return Optional.empty(); } @@ -89,7 +89,7 @@ public static boolean hasBlockingConsumerAbove(Furniture source) { } Location above = origin.get().getBlock().getRelative(BlockFace.UP).getLocation(); - String sourceId = source.getId().toLowerCase(); + String sourceId = source.getId().toLowerCase(java.util.Locale.ROOT); for (HeatConsumerDefinition definition : consumers.values()) { if (!definition.getSourceFurnitureId().equalsIgnoreCase(sourceId)) { @@ -140,7 +140,7 @@ private static Optional findSourceBlockBelow(Furniture consumer, Stri Location target = origin.get().getBlock().getRelative(BlockFace.DOWN).getLocation(); Optional source = findFurnitureAt(sourceFurnitureId, target); - if (source.isEmpty() || !sources.containsKey(source.get().getId().toLowerCase())) { + if (source.isEmpty() || !sources.containsKey(source.get().getId().toLowerCase(java.util.Locale.ROOT))) { return Optional.empty(); } return source; @@ -151,7 +151,7 @@ private static Optional findFurnitureAt(String furnitureId, Location return Optional.empty(); } - String expectedId = furnitureId.toLowerCase(); + String expectedId = furnitureId.toLowerCase(java.util.Locale.ROOT); for (Furniture furniture : InteractibleFurniture.getInstance() .getFurnitureManager() .getPlacedFurniture() diff --git a/src/main/java/net/tfminecraft/cooking/hook/MeatHookHandler.java b/src/main/java/net/tfminecraft/cooking/hook/MeatHookHandler.java index e1f2c30..37d2f39 100644 --- a/src/main/java/net/tfminecraft/cooking/hook/MeatHookHandler.java +++ b/src/main/java/net/tfminecraft/cooking/hook/MeatHookHandler.java @@ -117,10 +117,11 @@ private static void tickHook(Furniture furniture) { return; } ItemStack updated = ItemUpdater.updateItem(stack, foodItem, furniture.getId()); - if (updated == null) { + if (updated == null || updated.getType().isAir()) { return; } slot.setCurrentItem(updated); + InteractibleFurniture.getInstance().getFurnitureManager().markDirty(furniture); syncRoastDisplay(furniture); } @@ -135,7 +136,8 @@ private static void syncRoastDisplay(Furniture furniture) { return; } CarvableRoastUtils.readCarveState(foodItem, stack); - slot.applyDisplayData(CarvableRoastUtils.getStageModelData(foodItem).getDisplayData(furniture.getId())); + var model = CarvableRoastUtils.getStageModelData(foodItem); + if (model != null) slot.applyDisplayData(model.getDisplayData(furniture.getId())); }); } } diff --git a/src/main/java/net/tfminecraft/cooking/husbandry/HusbandryAnimalsCommand.java b/src/main/java/net/tfminecraft/cooking/husbandry/HusbandryAnimalsCommand.java index b906690..374e2ac 100644 --- a/src/main/java/net/tfminecraft/cooking/husbandry/HusbandryAnimalsCommand.java +++ b/src/main/java/net/tfminecraft/cooking/husbandry/HusbandryAnimalsCommand.java @@ -53,9 +53,6 @@ public boolean onCommand(CommandSender sender, Command command, String label, St } List rows = new ArrayList<>(); for (HusbandryOwned owned : repository.listForPlayer(targetId)) { - if (owned.animal() == null) { - continue; - } HusbandryAnimal animal = HusbandryEntities.getLoaded(owned.animal().uuid()).orElse(owned.animal()); Entity live = Bukkit.getEntity(animal.uuid()); if (live != null) { diff --git a/src/main/java/net/tfminecraft/cooking/husbandry/HusbandryBreedListener.java b/src/main/java/net/tfminecraft/cooking/husbandry/HusbandryBreedListener.java index 06d3ec2..6a2bde6 100644 --- a/src/main/java/net/tfminecraft/cooking/husbandry/HusbandryBreedListener.java +++ b/src/main/java/net/tfminecraft/cooking/husbandry/HusbandryBreedListener.java @@ -157,9 +157,6 @@ private static void persistBaby( LivingEntity mother, LivingEntity father, ItemStack bredWith) { - if (child == null) { - return; - } HusbandryRepository repository = HusbandryEntities.repository(); if (repository == null) { return; diff --git a/src/main/java/net/tfminecraft/cooking/husbandry/HusbandryCareListener.java b/src/main/java/net/tfminecraft/cooking/husbandry/HusbandryCareListener.java index 5861be9..1b7d2a0 100644 --- a/src/main/java/net/tfminecraft/cooking/husbandry/HusbandryCareListener.java +++ b/src/main/java/net/tfminecraft/cooking/husbandry/HusbandryCareListener.java @@ -47,7 +47,6 @@ public void onInteract(PlayerInteractEntityEvent event) { return; } HusbandryAnimal animal = stored.get(); - boolean changed = false; if (feed) { if (animal.hungrySince() == null) { return; @@ -55,17 +54,12 @@ public void onInteract(PlayerInteractEntityEvent event) { HusbandrySimulator.clearHungry(animal, ThreadLocalRandom.current()); HusbandryItems.useFromMainHand(player); HusbandryFx.playCare(living); - changed = true; - } else if (glove) { + } else { if (animal.dirtySince() == null) { return; } HusbandrySimulator.clearDirty(animal, ThreadLocalRandom.current()); HusbandryFx.playCare(living); - changed = true; - } - if (!changed) { - return; } repository.upsertAnimal(animal); HusbandryStateDisplay.sync(living, animal); diff --git a/src/main/java/net/tfminecraft/cooking/husbandry/HusbandryDamageListener.java b/src/main/java/net/tfminecraft/cooking/husbandry/HusbandryDamageListener.java index 3344ca3..c552e73 100644 --- a/src/main/java/net/tfminecraft/cooking/husbandry/HusbandryDamageListener.java +++ b/src/main/java/net/tfminecraft/cooking/husbandry/HusbandryDamageListener.java @@ -31,9 +31,6 @@ public void onDamage(EntityDamageEvent event) { } private static boolean cancelAttack(Entity victim, Entity attacker) { - if (attacker == null) { - return false; - } if (attacker instanceof Player player) { boolean owner = HusbandryOwnershipService.isOwner(player, victim.getUniqueId()); if (owner) { diff --git a/src/main/java/net/tfminecraft/cooking/husbandry/HusbandryDeathListener.java b/src/main/java/net/tfminecraft/cooking/husbandry/HusbandryDeathListener.java index 4acc6de..2cbf2af 100644 --- a/src/main/java/net/tfminecraft/cooking/husbandry/HusbandryDeathListener.java +++ b/src/main/java/net/tfminecraft/cooking/husbandry/HusbandryDeathListener.java @@ -1,6 +1,5 @@ package net.tfminecraft.cooking.husbandry; -import java.util.Iterator; import java.util.Optional; import java.util.concurrent.ThreadLocalRandom; @@ -41,22 +40,13 @@ public void onDeath(EntityDeathEvent event) { boolean hasSlaughter = species != null && species.canSlaughter(); boolean mature = HusbandryGrowth.isMature(animal, now); if (HusbandrySlaughterDrops.shouldReplaceVanilla(hasSlaughter, mature)) { - Iterator drops = event.getDrops().iterator(); - while (drops.hasNext()) { - ItemStack drop = drops.next(); - if (HusbandryHarvest.isWoolDrop(drop)) { - continue; - } - drops.remove(); - } - if (HusbandrySlaughterDrops.shouldAddRoast(hasSlaughter, mature)) { - ItemStack roast = HusbandryHarvest.buildFood(animal, species.slaughterMeat()); - if (roast != null) { - event.getDrops().add(roast); - } else { - Bukkit.getLogger().warning("[Cooking] Slaughter roast failed for " - + entity.getType() + " using " + species.slaughterMeat()); - } + ItemStack roast = HusbandryHarvest.buildFood(animal, species.slaughterMeat()); + if (roast == null || roast.getType().isAir()) { + Bukkit.getLogger().warning("[Cooking] Slaughter roast failed for " + + entity.getType() + " using " + species.slaughterMeat()); + } else { + event.getDrops().removeIf(drop -> !HusbandryHarvest.isWoolDrop(drop)); + event.getDrops().add(roast); HusbandryDropRoller.rollSlaughterExtras(animal, ThreadLocalRandom.current(), now) .forEach(extra -> event.getDrops().add(extra)); } diff --git a/src/main/java/net/tfminecraft/cooking/husbandry/HusbandryDropRoller.java b/src/main/java/net/tfminecraft/cooking/husbandry/HusbandryDropRoller.java index b35e179..074cb3c 100644 --- a/src/main/java/net/tfminecraft/cooking/husbandry/HusbandryDropRoller.java +++ b/src/main/java/net/tfminecraft/cooking/husbandry/HusbandryDropRoller.java @@ -148,18 +148,17 @@ public static HusbandryDropEntry pickEntry(List pool, Random if (pool == null || pool.isEmpty() || random == null) { return null; } - int totalWeight = pool.stream().mapToInt(HusbandryDropEntry::weight).sum(); - if (totalWeight <= 0) { - return null; - } - int roll = random.nextInt(totalWeight); - for (HusbandryDropEntry entry : pool) { + long totalWeight = pool.stream().mapToLong(HusbandryDropEntry::weight).sum(); + long roll = totalWeight <= Integer.MAX_VALUE + ? random.nextInt((int) totalWeight) : random.nextLong(totalWeight); + for (int index = 0; index < pool.size() - 1; index++) { + HusbandryDropEntry entry = pool.get(index); roll -= entry.weight(); if (roll < 0) { return entry; } } - return null; + return pool.getLast(); } private static HusbandrySpecies speciesOf(HusbandryAnimal animal) { diff --git a/src/main/java/net/tfminecraft/cooking/husbandry/HusbandryEntities.java b/src/main/java/net/tfminecraft/cooking/husbandry/HusbandryEntities.java index fa791c9..d0a55c8 100644 --- a/src/main/java/net/tfminecraft/cooking/husbandry/HusbandryEntities.java +++ b/src/main/java/net/tfminecraft/cooking/husbandry/HusbandryEntities.java @@ -104,16 +104,11 @@ public static String displayName(org.bukkit.entity.EntityType type) { String raw = type.name().toLowerCase(Locale.ROOT).replace('_', ' '); StringBuilder out = new StringBuilder(); for (String word : raw.split(" ")) { - if (word.isEmpty()) { - continue; - } if (!out.isEmpty()) { out.append(' '); } out.append(Character.toUpperCase(word.charAt(0))); - if (word.length() > 1) { - out.append(word.substring(1)); - } + out.append(word.substring(1)); } return out.toString(); } diff --git a/src/main/java/net/tfminecraft/cooking/husbandry/HusbandryEntityScan.java b/src/main/java/net/tfminecraft/cooking/husbandry/HusbandryEntityScan.java index 3a8792e..1d219f7 100644 --- a/src/main/java/net/tfminecraft/cooking/husbandry/HusbandryEntityScan.java +++ b/src/main/java/net/tfminecraft/cooking/husbandry/HusbandryEntityScan.java @@ -199,13 +199,14 @@ private static int readInt(byte[] data, int at) { private static void readEntityChunk(DataInputStream in, String world, Set targets, Map found) throws IOException { if (in.readByte() != COMPOUND) { - return; + throw new IOException("Entity chunk NBT root is not a compound"); } in.readUTF(); byte type; while ((type = in.readByte()) != END) { String key = in.readUTF(); - if (type == LIST && key.equals("Entities")) { + if (key.equals("Entities")) { + if (type != LIST) throw new IOException("Invalid Entities tag"); readEntityList(in, world, targets, found); } else { skip(in, type); @@ -216,17 +217,19 @@ private static void readEntityChunk(DataInputStream in, String world, Set private static void readPlayer(DataInputStream in, Set targets, Map found) throws IOException { if (in.readByte() != COMPOUND) { - return; + throw new IOException("Player NBT root is not a compound"); } in.readUTF(); byte type; while ((type = in.readByte()) != END) { String key = in.readUTF(); - if (type == COMPOUND && key.equals("RootVehicle")) { + if (key.equals("RootVehicle")) { + if (type != COMPOUND) throw new IOException("Invalid RootVehicle tag"); byte inner; while ((inner = in.readByte()) != END) { String innerKey = in.readUTF(); - if (inner == COMPOUND && innerKey.equals("Entity")) { + if (innerKey.equals("Entity")) { + if (inner != COMPOUND) throw new IOException("Invalid root vehicle Entity tag"); readEntity(in, "", targets, found); } else { skip(in, inner); @@ -242,11 +245,8 @@ private static void readEntityList(DataInputStream in, String world, Set t throws IOException { byte element = in.readByte(); int size = in.readInt(); - if (element != COMPOUND) { - for (int i = 0; i < size; i++) { - skip(in, element); - } - return; + if (size < 0 || (size > 0 && element != COMPOUND)) { + throw new IOException("Invalid entity list"); } for (int i = 0; i < size; i++) { readEntity(in, world, targets, found); @@ -260,32 +260,39 @@ private static void readEntity(DataInputStream in, String world, Set targe byte type; while ((type = in.readByte()) != END) { String key = in.readUTF(); - if (type == INT_ARRAY && key.equals("UUID")) { + if (key.equals("UUID")) { + if (type != INT_ARRAY) throw new IOException("Invalid entity UUID tag"); int size = in.readInt(); if (size == 4) { uuid = new UUID( ((long) in.readInt() << 32) | (in.readInt() & 0xFFFFFFFFL), ((long) in.readInt() << 32) | (in.readInt() & 0xFFFFFFFFL)); } else { - in.skipNBytes(4L * size); + throw new IOException("Invalid entity UUID length"); } - } else if (type == LIST && key.equals("Pos")) { + } else if (key.equals("Pos")) { + if (type != LIST) throw new IOException("Invalid entity position tag"); byte element = in.readByte(); int size = in.readInt(); if (element == DOUBLE && size == 3) { pos = new double[] {in.readDouble(), in.readDouble(), in.readDouble()}; - } else { - for (int i = 0; i < size; i++) { - skip(in, element); + if (!Double.isFinite(pos[0]) || !Double.isFinite(pos[1]) || !Double.isFinite(pos[2])) { + throw new IOException("Non-finite entity position"); } + } else { + throw new IOException("Invalid entity position list"); } - } else if (type == LIST && key.equals("Passengers")) { + } else if (key.equals("Passengers")) { + if (type != LIST) throw new IOException("Invalid Passengers tag"); readEntityList(in, world, targets, found); } else { skip(in, type); } } - if (uuid != null && pos != null && targets.contains(uuid)) { + if (uuid == null || pos == null) { + throw new IOException("Entity is missing UUID or position"); + } + if (targets.contains(uuid)) { found.putIfAbsent(uuid, new Found( world, (int) Math.floor(pos[0]), (int) Math.floor(pos[1]), (int) Math.floor(pos[2]))); } diff --git a/src/main/java/net/tfminecraft/cooking/husbandry/HusbandryGuiBars.java b/src/main/java/net/tfminecraft/cooking/husbandry/HusbandryGuiBars.java index dc0414a..f16d27f 100644 --- a/src/main/java/net/tfminecraft/cooking/husbandry/HusbandryGuiBars.java +++ b/src/main/java/net/tfminecraft/cooking/husbandry/HusbandryGuiBars.java @@ -18,9 +18,6 @@ static int yieldPercent(HusbandryAnimal animal) { } int effective = HusbandryConfig.effectiveGenetics(animal); int max = HusbandryConfig.maxGenetics(); - if (max <= 0) { - return 0; - } return (int) Math.round(100.0 * effective / max); } @@ -28,7 +25,7 @@ static int filledSegments(int value, int max) { if (max <= 0 || value <= 0) { return 0; } - return Math.max(0, Math.min(BAR_SLOTS, (int) Math.round(BAR_SLOTS * value / (double) max))); + return Math.max(0, Math.min(BAR_SLOTS, (int) Math.round(BAR_SLOTS * (double) value / max))); } // Keep the existing legacy text representation, formatting, and exact-string comparisons. diff --git a/src/main/java/net/tfminecraft/cooking/husbandry/HusbandryHarvest.java b/src/main/java/net/tfminecraft/cooking/husbandry/HusbandryHarvest.java index ff0aa07..53e36b6 100644 --- a/src/main/java/net/tfminecraft/cooking/husbandry/HusbandryHarvest.java +++ b/src/main/java/net/tfminecraft/cooking/husbandry/HusbandryHarvest.java @@ -122,7 +122,10 @@ private static void grantShearExtras(Player player, HusbandryAnimal animal, long return; } for (ItemStack drop : HusbandryDropRoller.rollShearDrops(animal, ThreadLocalRandom.current(), nowMillis)) { - InventoryAdder.addItem(player, drop); + ItemStack overflow = InventoryAdder.addItem(player, drop); + if (overflow != null) { + player.getWorld().dropItemNaturally(player.getLocation(), overflow); + } } } @@ -194,7 +197,7 @@ public static ItemStack buildFood(HusbandryAnimal animal, String foodString, Ite item.setCarveState(seqId, portion.nextIndex(), portion.remaining()); CarvableRoastUtils.writeCarveState(stack, item); ItemStack updated = ItemUpdater.applyItemUpdate(stack, item, null); - if (updated != null) { + if (updated != null && !updated.getType().isAir()) { stack = updated; } return stack; @@ -206,7 +209,7 @@ public static ItemStack buildTlibs(String path, int amount) { } try { ItemStack stack = TLibs.getItemAPI().getCreator().getItemFromPath(path); - if (stack == null) { + if (stack == null || stack.getType().isAir()) { return null; } stack.setAmount(Math.max(1, Math.min(64, amount))); diff --git a/src/main/java/net/tfminecraft/cooking/husbandry/HusbandryHarvestListener.java b/src/main/java/net/tfminecraft/cooking/husbandry/HusbandryHarvestListener.java index f353817..de66f90 100644 --- a/src/main/java/net/tfminecraft/cooking/husbandry/HusbandryHarvestListener.java +++ b/src/main/java/net/tfminecraft/cooking/husbandry/HusbandryHarvestListener.java @@ -153,16 +153,15 @@ public void onShear(PlayerShearEntityEvent event) { return; } event.setCancelled(true); - if (!(sheared instanceof LivingEntity living)) { - return; + if (sheared instanceof LivingEntity living) { + HusbandryHarvest.tryShear( + event.getPlayer(), + living, + stored.get(), + species, + repository, + System.currentTimeMillis()); } - HusbandryHarvest.tryShear( - event.getPlayer(), - living, - stored.get(), - species, - repository, - System.currentTimeMillis()); } private static boolean onMilkCooldown(HusbandryAnimal animal, EntityType type, long now) { @@ -184,9 +183,6 @@ private static void consumeOneBucket(Player player, EquipmentSlot slot) { return; } ItemStack used = player.getInventory().getItem(slot); - if (used == null || used.getType() != Material.BUCKET) { - return; - } int amount = used.getAmount(); if (amount <= 1) { player.getInventory().setItem(slot, new ItemStack(Material.AIR)); diff --git a/src/main/java/net/tfminecraft/cooking/husbandry/HusbandryInspectGui.java b/src/main/java/net/tfminecraft/cooking/husbandry/HusbandryInspectGui.java index 2f116d6..130e053 100644 --- a/src/main/java/net/tfminecraft/cooking/husbandry/HusbandryInspectGui.java +++ b/src/main/java/net/tfminecraft/cooking/husbandry/HusbandryInspectGui.java @@ -38,7 +38,11 @@ public static void open(Player player, LivingEntity entity, HusbandryAnimal anim } boolean mount = HusbandryMounts.isMount(entity); HusbandryInspectHolder holder = new HusbandryInspectHolder(animal.uuid()); - String title = "§6" + (animal.name() == null ? "Animal" : animal.name()); + String displayName = animal.name(); + if (displayName == null || displayName.isBlank() || "???".equals(displayName.trim())) { + displayName = HusbandryEntities.displayName(entity.getType()); + } + String title = "§6" + displayName; Inventory inv = Bukkit.createInventory(holder, INVENTORY_SIZE, title); holder.setInventory(inv); @@ -48,7 +52,7 @@ public static void open(Player player, LivingEntity entity, HusbandryAnimal anim inv.setItem(0, statusItem(animal)); inv.setItem(1, named(Material.SHEARS, animal.neutered() ? "§cNeutered" : "§aNot neutered", List.of())); inv.setItem(2, ownersItem(animal.uuid())); - inv.setItem(4, named(Material.NAME_TAG, "§6" + animal.name(), + inv.setItem(4, named(Material.NAME_TAG, "§6" + displayName, List.of("§7" + entity.getType().name(), "§7State: " + animal.state().storage(), growingLine(animal)))); ItemStack products = productsItem(entity.getType(), yieldPct); @@ -97,9 +101,6 @@ private static String growingLine(HusbandryAnimal animal) { return "§7Mature"; } Long matureAt = animal.matureAt(); - if (matureAt == null) { - return "§7Growing up"; - } long remaining = Math.max(0L, matureAt - System.currentTimeMillis()); return "§7Grows up in " + TimeFormatter.formatTime((int) (remaining / 1000L)); } @@ -124,9 +125,6 @@ private static ItemStack statusItem(HusbandryAnimal animal) { private static String decayLine(HusbandryAnimal animal) { Long hungry = animal.hungrySince(); Long dirty = animal.dirtySince(); - if (hungry == null && dirty == null) { - return "§7No decay"; - } long earliest = hungry == null ? dirty : (dirty == null ? hungry : Math.min(hungry, dirty)); long decayStart = earliest + HusbandryConfig.decayGraceSeconds() * 1000L; long now = System.currentTimeMillis(); diff --git a/src/main/java/net/tfminecraft/cooking/husbandry/HusbandryInspectListener.java b/src/main/java/net/tfminecraft/cooking/husbandry/HusbandryInspectListener.java index 6d6c3ea..90dcaeb 100644 --- a/src/main/java/net/tfminecraft/cooking/husbandry/HusbandryInspectListener.java +++ b/src/main/java/net/tfminecraft/cooking/husbandry/HusbandryInspectListener.java @@ -74,6 +74,9 @@ public void onClick(InventoryClickEvent event) { return; } event.setCancelled(true); + if (event.getClickedInventory() != event.getInventory() || event.getRawSlot() != 8) { + return; + } if (!(event.getWhoClicked() instanceof Player player)) { return; } diff --git a/src/main/java/net/tfminecraft/cooking/husbandry/HusbandryLifecycleListener.java b/src/main/java/net/tfminecraft/cooking/husbandry/HusbandryLifecycleListener.java index c147e18..46f9847 100644 --- a/src/main/java/net/tfminecraft/cooking/husbandry/HusbandryLifecycleListener.java +++ b/src/main/java/net/tfminecraft/cooking/husbandry/HusbandryLifecycleListener.java @@ -154,7 +154,8 @@ static void handleLoad(Entity entity) { return; } UUID uuid = entity.getUniqueId(); - boolean hasRow = repository.exists(uuid); + Optional stored = repository.getAnimal(uuid); + boolean hasRow = stored.isPresent(); if (HusbandryMounts.shouldWipeUnowned( HusbandryConfig.isRemoveUnowned(entity.getType()), @@ -175,10 +176,6 @@ static void handleLoad(Entity entity) { HusbandryEntities.applyPersistFlags(living); HusbandryEntities.stampManaged(living); - Optional stored = repository.getAnimal(uuid); - if (stored.isEmpty()) { - return; - } HusbandryAnimal animal = stored.get(); long now = System.currentTimeMillis(); HusbandryLocation.remember(animal, living); diff --git a/src/main/java/net/tfminecraft/cooking/husbandry/HusbandryLoader.java b/src/main/java/net/tfminecraft/cooking/husbandry/HusbandryLoader.java index 5715b34..2210e5b 100644 --- a/src/main/java/net/tfminecraft/cooking/husbandry/HusbandryLoader.java +++ b/src/main/java/net/tfminecraft/cooking/husbandry/HusbandryLoader.java @@ -90,9 +90,6 @@ public void load(File file) { private static Set parseEntityTypes(List raw) { Set types = EnumSet.noneOf(EntityType.class); - if (raw == null) { - return types; - } for (String entry : raw) { EntityType type = parseEntityType(entry); if (type != null) { @@ -151,9 +148,6 @@ private static HusbandryDropTable parseDropTable(ConfigurationSection section) { private static List parseDropEntries(List> raw) { List entries = new ArrayList<>(); - if (raw == null) { - return entries; - } for (Map entry : raw) { Object pathRaw = entry.get("path"); String path = pathRaw == null ? "" : String.valueOf(pathRaw).trim(); @@ -189,9 +183,6 @@ private static int parseSpeciesDuration(ConfigurationSection section, String key private static List parseQualityBands(List> raw) { List bands = new ArrayList<>(); - if (raw == null) { - return bands; - } for (Map entry : raw) { int min = intValue(entry.get("min"), 0); int stars = intValue(entry.get("stars"), 1); @@ -203,9 +194,6 @@ private static List parseQualityBands(List> raw) private static List parseAmountBands(List> raw) { List bands = new ArrayList<>(); - if (raw == null) { - return bands; - } for (Map entry : raw) { bands.add(new HusbandryAmountBand( intValue(entry.get("min"), 0), @@ -304,7 +292,7 @@ private static void applyDefaults() { 20, 1000, 1, - 1200, + 28800, 3600, 28800, 0.15, diff --git a/src/main/java/net/tfminecraft/cooking/husbandry/HusbandryRepository.java b/src/main/java/net/tfminecraft/cooking/husbandry/HusbandryRepository.java index af56457..248bdca 100644 --- a/src/main/java/net/tfminecraft/cooking/husbandry/HusbandryRepository.java +++ b/src/main/java/net/tfminecraft/cooking/husbandry/HusbandryRepository.java @@ -272,7 +272,6 @@ public void upsertAnimals(Collection animals) { if (animal == null || animal.uuid() == null) { continue; } - sanitizeName(animal); valid.add(animal); } if (valid.isEmpty()) { @@ -426,13 +425,6 @@ public void close() { database.close(); } - private static void sanitizeName(HusbandryAnimal animal) { - String name = animal.name() == null ? "" : animal.name(); - if ("???".equals(name.trim())) { - animal.setName(""); - } - } - private static void bindAnimal(PreparedStatement statement, HusbandryAnimal animal) throws SQLException { bindParams( statement, diff --git a/src/main/java/net/tfminecraft/cooking/husbandry/HusbandrySimulator.java b/src/main/java/net/tfminecraft/cooking/husbandry/HusbandrySimulator.java index 73b86eb..54780a3 100644 --- a/src/main/java/net/tfminecraft/cooking/husbandry/HusbandrySimulator.java +++ b/src/main/java/net/tfminecraft/cooking/husbandry/HusbandrySimulator.java @@ -165,11 +165,7 @@ private static void applyAffliction(HusbandryAnimal animal, double loadedHours, animal.setAfflictionAt(rollAfflictionHours(random)); } animal.setAfflictionElapsed(animal.afflictionElapsed() + loadedHours); - int guard = 0; while (animal.hungrySince() == null || animal.dirtySince() == null) { - if (guard++ > 8) { - break; - } if (animal.afflictionElapsed() < animal.afflictionAt()) { break; } @@ -185,9 +181,6 @@ private static void applyAffliction(HusbandryAnimal animal, double loadedHours, private static void applyMissingState(HusbandryAnimal animal, long nowMillis, Random random) { boolean hungry = animal.hungrySince() != null; boolean dirty = animal.dirtySince() != null; - if (hungry && dirty) { - return; - } if (!hungry && !dirty) { if (random.nextBoolean()) { animal.setHungrySince(nowMillis); diff --git a/src/main/java/net/tfminecraft/cooking/husbandry/HusbandrySpawner.java b/src/main/java/net/tfminecraft/cooking/husbandry/HusbandrySpawner.java index 3e89b0e..872f745 100644 --- a/src/main/java/net/tfminecraft/cooking/husbandry/HusbandrySpawner.java +++ b/src/main/java/net/tfminecraft/cooking/husbandry/HusbandrySpawner.java @@ -20,11 +20,7 @@ public static LivingEntity spawn(Player player, EntityType type, int genetics, i return null; } Location at = player.getLocation(); - Entity spawned = player.getWorld().spawnEntity(at, type); - if (!(spawned instanceof LivingEntity living)) { - spawned.remove(); - return null; - } + LivingEntity living = (LivingEntity) player.getWorld().spawnEntity(at, type); int clampedGenetics = Math.max(0, Math.min(HusbandryConfig.maxGenetics(), genetics)); int clampedCare = Math.max(0, Math.min(HusbandryConfig.careMax(), care)); diff --git a/src/main/java/net/tfminecraft/cooking/husbandry/HusbandryTamingListener.java b/src/main/java/net/tfminecraft/cooking/husbandry/HusbandryTamingListener.java index 3183a1d..c8559cb 100644 --- a/src/main/java/net/tfminecraft/cooking/husbandry/HusbandryTamingListener.java +++ b/src/main/java/net/tfminecraft/cooking/husbandry/HusbandryTamingListener.java @@ -25,9 +25,7 @@ public void onPrepareAnvil(PrepareAnvilEvent event) { if (result == null || !HusbandryItems.matches(result, HusbandryConfig.tameItem())) { return; } - if (!(event.getView() instanceof AnvilView anvil)) { - return; - } + AnvilView anvil = event.getView(); String rename = anvil.getRenameText(); if (rename == null || rename.isBlank()) { return; @@ -36,7 +34,7 @@ public void onPrepareAnvil(PrepareAnvilEvent event) { event.setResult(result); } - @EventHandler + @EventHandler(ignoreCancelled = true) public void onInteractEntity(PlayerInteractEntityEvent event) { if (event.getHand() != EquipmentSlot.HAND) { return; @@ -121,10 +119,6 @@ private static void handleTame(Player player, LivingEntity entity, ItemStack han return; } animal = HusbandrySpawner.createWildRecord(entity); - if (animal == null) { - player.sendMessage("§cCould not tame this animal."); - return; - } } else { animal = stored.get(); } @@ -144,7 +138,6 @@ private static void handleTame(Player player, LivingEntity entity, ItemStack han player.sendMessage("§aTamed " + name + "."); } } - default -> player.sendMessage("§cCould not tame this animal."); } } @@ -206,7 +199,7 @@ private static void handleCoOwnOnPlayer(Player actor, Player target, ItemStack h case TARGET_AT_CAP -> actor.sendMessage("§cThat player already owns too many animals."); case OK -> { HusbandryItems.consumeOne(hand); - String label = animal.name() != null ? animal.name() : "animal"; + String label = animal.name() != null && !animal.name().isBlank() ? animal.name() : "animal"; if (actor.equals(target)) { actor.sendMessage("§aYou are now a co-owner of " + label + "."); } else { @@ -214,7 +207,6 @@ private static void handleCoOwnOnPlayer(Player actor, Player target, ItemStack h target.sendMessage("§aYou are now a co-owner of " + label + "."); } } - default -> actor.sendMessage("§cCould not share this animal."); } } } diff --git a/src/main/java/net/tfminecraft/cooking/item/CookingPathHandler.java b/src/main/java/net/tfminecraft/cooking/item/CookingPathHandler.java index 0c5e8b9..b492634 100644 --- a/src/main/java/net/tfminecraft/cooking/item/CookingPathHandler.java +++ b/src/main/java/net/tfminecraft/cooking/item/CookingPathHandler.java @@ -116,9 +116,6 @@ private static String field(Map fields, String key) { for (Map.Entry entry : fields.entrySet()) { if (entry.getKey() != null && key.equalsIgnoreCase(entry.getKey().trim())) { String value = entry.getValue(); - if (value == null) { - return null; - } String trimmed = value.trim(); return trimmed.isEmpty() ? null : trimmed; } @@ -155,9 +152,6 @@ private static boolean tagsMatch(FoodItem food, String spec) { continue; } String[] kv = t.split("\\.", 2); - if (kv.length != 2) { - continue; - } int expected; try { expected = Integer.parseInt(kv[1].trim()); @@ -175,9 +169,6 @@ private static boolean tagsMatch(FoodItem food, String spec) { } private static TagTrack findTrack(FoodItem food, String trackId) { - if (trackId == null) { - return null; - } TagTrack direct = food.getTagTrack(trackId); if (direct != null) { return direct; diff --git a/src/main/java/net/tfminecraft/cooking/item/FoodItem.java b/src/main/java/net/tfminecraft/cooking/item/FoodItem.java index 44b6f07..c130222 100644 --- a/src/main/java/net/tfminecraft/cooking/item/FoodItem.java +++ b/src/main/java/net/tfminecraft/cooking/item/FoodItem.java @@ -108,7 +108,7 @@ public FoodItem(String key, ConfigurationSection config) { ConfigurationSection o = overSec.getConfigurationSection(originKey); if (o != null) { overrides.put( - originKey.toUpperCase(), + originKey.toUpperCase(Locale.ROOT), new OverrideData( o.getString("name", null), o.getString("model", null), @@ -373,8 +373,8 @@ public ModelData getModelData() { if (hasCarveState() && carveRemaining > 0) { return net.tfminecraft.cooking.carve.CarvableRoastUtils.getStageModelData(this); } - ModelData data = getModel().getModel(this); - return data != null ? data : model.getModel(this); + FoodModel selected = getModel(); + return selected == null ? null : selected.getModel(this); } public Map getOverrides() { return overrides; } @@ -399,7 +399,7 @@ public double resolveAgeMultiplier(String trackId) { return AgeScale.DEFAULT; } if (origin != null) { - OverrideData od = overrides.get(origin.toUpperCase()); + OverrideData od = overrides.get(origin.toUpperCase(Locale.ROOT)); if (od != null) { Double override = od.getAge(trackId); if (override != null) { @@ -407,7 +407,7 @@ public double resolveAgeMultiplier(String trackId) { } } } - Double type = age.get(trackId.toLowerCase()); + Double type = age.get(trackId.toLowerCase(Locale.ROOT)); return type == null ? AgeScale.DEFAULT : AgeScale.clamp(type); } @@ -415,18 +415,18 @@ public double getAgeRemainder(String trackId) { if (trackId == null) { return 0; } - return ageRemainder.getOrDefault(trackId.toLowerCase(), 0.0); + return ageRemainder.getOrDefault(trackId.toLowerCase(Locale.ROOT), 0.0); } public void setAgeRemainder(String trackId, double leftover) { if (trackId == null) { return; } - if (leftover <= 0) { - ageRemainder.remove(trackId.toLowerCase()); + if (!Double.isFinite(leftover) || leftover <= 0) { + ageRemainder.remove(trackId.toLowerCase(Locale.ROOT)); return; } - ageRemainder.put(trackId.toLowerCase(), leftover); + ageRemainder.put(trackId.toLowerCase(Locale.ROOT), leftover); } public boolean hasAgeRemainder() { @@ -444,16 +444,13 @@ public String encodeAgeRemainder() { StringBuilder sb = new StringBuilder(); boolean first = true; for (Map.Entry entry : ageRemainder.entrySet()) { - if (entry.getValue() == null || entry.getValue() <= 0) { - continue; - } if (!first) { sb.append(';'); } sb.append(entry.getKey()).append(':').append(entry.getValue()); first = false; } - return sb.isEmpty() ? null : sb.toString(); + return sb.toString(); } public void decodeAgeRemainder(String raw) { @@ -467,9 +464,9 @@ public void decodeAgeRemainder(String raw) { continue; } try { - String trackId = entry.substring(0, sep).toLowerCase(); + String trackId = entry.substring(0, sep).toLowerCase(Locale.ROOT); double leftover = Double.parseDouble(entry.substring(sep + 1)); - if (leftover > 0) { + if (Double.isFinite(leftover) && leftover > 0) { ageRemainder.put(trackId, leftover); } } catch (NumberFormatException ignored) { @@ -483,14 +480,14 @@ private static Map readAge(ConfigurationSection section) { return map; } for (String key : section.getKeys(false)) { - map.put(key.toLowerCase(), section.getDouble(key, AgeScale.DEFAULT)); + map.put(key.toLowerCase(Locale.ROOT), section.getDouble(key, AgeScale.DEFAULT)); } return map; } public String getCarveSequenceId() { if (origin != null) { - OverrideData od = overrides.get(origin.toUpperCase()); + OverrideData od = overrides.get(origin.toUpperCase(Locale.ROOT)); if (od != null && od.getCarveSequence() != null) { return od.getCarveSequence(); } @@ -542,8 +539,7 @@ public void addOrModifyTrack(TagTrack t) { } public void addTagTrack(String trackId) { - updateAge(); - tags.put(trackId, new TagTrack(TrackLoader.getByString(trackId))); + addOrModifyTrack(TrackLoader.getByString(trackId)); } public boolean hasTagTrack(String tag) { @@ -560,14 +556,16 @@ public void removeTrack(String id) { public List getCurrentTags() { List current = new ArrayList<>(); for(TagTrack track : tags.values()) { - current.add(track.getCurrentStep()); + TagStep step = track.getCurrentStep(); + if (step != null) current.add(step); } return current; } public List getCurrentTagIds() { List current = new ArrayList<>(); for(TagTrack track : tags.values()) { - current.add(track.getCurrentStep().getId()); + TagStep step = track.getCurrentStep(); + if (step != null) current.add(step.getId()); } return current; } diff --git a/src/main/java/net/tfminecraft/cooking/item/IngredientLineage.java b/src/main/java/net/tfminecraft/cooking/item/IngredientLineage.java index b501e60..c803ec0 100644 --- a/src/main/java/net/tfminecraft/cooking/item/IngredientLineage.java +++ b/src/main/java/net/tfminecraft/cooking/item/IngredientLineage.java @@ -43,19 +43,18 @@ public static IngredientLineage from(Collection inputs, CompositionCon return result; } for (FoodItem item : inputs) { - if (item == null) { - continue; - } - IngredientLineage existing = item.getLineage(); - if (existing != null && !existing.isEmpty()) { - result = result.merge(existing); - continue; - } - CompositionRole role = CompositionConfig.getRole(item.getCategory(), context); - if (role == CompositionRole.EXTRA) { - result = result.withExtra(item.getOrigin()); - } else if (role == CompositionRole.MAIN) { - result = result.withMain(item.getOrigin()); + if (item != null) { + IngredientLineage existing = item.getLineage(); + if (existing != null && !existing.isEmpty()) { + result = result.merge(existing); + } else { + CompositionRole role = CompositionConfig.getRole(item.getCategory(), context); + if (role == CompositionRole.EXTRA) { + result = result.withExtra(item.getOrigin()); + } else if (role == CompositionRole.MAIN) { + result = result.withMain(item.getOrigin()); + } + } } } return result; diff --git a/src/main/java/net/tfminecraft/cooking/item/data/CookData.java b/src/main/java/net/tfminecraft/cooking/item/data/CookData.java index dcb5d9d..7dcb0ac 100644 --- a/src/main/java/net/tfminecraft/cooking/item/data/CookData.java +++ b/src/main/java/net/tfminecraft/cooking/item/data/CookData.java @@ -20,7 +20,7 @@ public CookData(String id, ConfigurationSection config) { for(String key : config.getKeys(false)) { ConfigurationSection param = config.getConfigurationSection(key); try { - parameters.put(Method.valueOf(key.toUpperCase()), new CookParameter(param.getInt("tag", 1), param.getInt("time", 15), param.getInt("burn", 30))); + parameters.put(Method.valueOf(key.toUpperCase(java.util.Locale.ROOT)), new CookParameter(param.getInt("tag", 1), param.getInt("time", 15), param.getInt("burn", 30))); } catch (Exception e) { Bukkit.getLogger().info("Failed to create cooking data for "+id); } @@ -31,7 +31,7 @@ public CookData() {} public CookData(FoodItem item, CookData other) { this.item = item; - parameters = other.parameters; + parameters = new HashMap<>(other.parameters); this.currentMethod = other.currentMethod; this.currentTime = other.currentTime; } @@ -86,6 +86,9 @@ public boolean tick() { public boolean check() { CookParameter p = parameters.get(currentMethod); + if (p == null || item == null || item.getTagTrack("cooked") == null) { + return false; + } boolean changed = false; if(currentTime == p.getTime()) { changed = true; diff --git a/src/main/java/net/tfminecraft/cooking/item/data/OverrideData.java b/src/main/java/net/tfminecraft/cooking/item/data/OverrideData.java index e1454cf..2affc68 100644 --- a/src/main/java/net/tfminecraft/cooking/item/data/OverrideData.java +++ b/src/main/java/net/tfminecraft/cooking/item/data/OverrideData.java @@ -3,6 +3,7 @@ import java.util.Collections; import java.util.HashMap; import java.util.Map; +import java.util.Locale; import net.tfminecraft.tlibs.objects.api.subapi.StringFormatter; @@ -37,6 +38,6 @@ public Double getAge(String trackId) { if (trackId == null || age.isEmpty()) { return null; } - return age.get(trackId.toLowerCase()); + return age.get(trackId.toLowerCase(Locale.ROOT)); } } diff --git a/src/main/java/net/tfminecraft/cooking/item/model/FoodModel.java b/src/main/java/net/tfminecraft/cooking/item/model/FoodModel.java index 8a1b4b3..83432d8 100644 --- a/src/main/java/net/tfminecraft/cooking/item/model/FoodModel.java +++ b/src/main/java/net/tfminecraft/cooking/item/model/FoodModel.java @@ -109,6 +109,6 @@ public Map getStates() { } private ModelData getFirstModel() { - return states.values().iterator().next(); + return states.isEmpty() ? null : states.values().iterator().next(); } } diff --git a/src/main/java/net/tfminecraft/cooking/item/model/ModelData.java b/src/main/java/net/tfminecraft/cooking/item/model/ModelData.java index 25188c2..df89b03 100644 --- a/src/main/java/net/tfminecraft/cooking/item/model/ModelData.java +++ b/src/main/java/net/tfminecraft/cooking/item/model/ModelData.java @@ -73,9 +73,9 @@ public ModelData(ModelData other) { this.guiRef = other.guiRef; this.displayRef = other.displayRef; this.weight = other.weight; - this.displayData = other.displayData; + this.displayData = copyDisplayData(other.displayData); this.overrides = new HashMap<>(other.overrides); - this.furnitureDisplayData = new HashMap<>(other.furnitureDisplayData); + other.furnitureDisplayData.forEach((key, value) -> this.furnitureDisplayData.put(key, copyDisplayData(value))); this.tags.addAll(other.tags); this.stage = other.stage; this.directItem = other.directItem != null ? other.directItem.clone() : null; @@ -93,6 +93,20 @@ public ModelData(ItemStack item) { displayData.setyPos(-0.25f); } + private static DisplayData copyDisplayData(DisplayData source) { + DisplayData copy = new DisplayData(); + copy.setxRot(source.getxRot()); + copy.setyRot(source.getyRot()); + copy.setzRot(source.getzRot()); + copy.setxScale(source.getxScale()); + copy.setyScale(source.getyScale()); + copy.setzScale(source.getzScale()); + copy.setxPos(source.getxPos()); + copy.setyPos(source.getyPos()); + copy.setzPos(source.getzPos()); + return copy; + } + public ItemStack apply(String furniture, ItemStack source) { if (directItem != null) { ItemStack out = directItem.clone(); diff --git a/src/main/java/net/tfminecraft/cooking/item/tag/AgeScale.java b/src/main/java/net/tfminecraft/cooking/item/tag/AgeScale.java index b5e3613..4c24912 100644 --- a/src/main/java/net/tfminecraft/cooking/item/tag/AgeScale.java +++ b/src/main/java/net/tfminecraft/cooking/item/tag/AgeScale.java @@ -10,7 +10,7 @@ public final class AgeScale { private AgeScale() {} public static double clamp(double multiplier) { - return multiplier > 0 ? multiplier : DEFAULT; + return Double.isFinite(multiplier) && multiplier > 0 ? multiplier : DEFAULT; } public static double forFood(String foodId, String trackId) { @@ -26,8 +26,11 @@ public static Scaled apply(int value, long deltaSeconds, double multiplier, doub return new Scaled(value, leftover); } double scaled = leftover + deltaSeconds / clamp(multiplier); - int whole = (int) Math.floor(scaled); - return new Scaled(value + whole, scaled - whole); + if (scaled >= (long) Integer.MAX_VALUE - value) { + return new Scaled(Integer.MAX_VALUE, 0); + } + long whole = (long) Math.floor(scaled); + return new Scaled((int) (value + whole), scaled - whole); } public static String migrateTrackId(String trackId) { @@ -42,7 +45,7 @@ public static String migrateTrackId(String trackId) { public static int migrateTrackValue(String trackId, int value) { if (trackId != null && "dairy_freshness".equalsIgnoreCase(trackId)) { - return Math.max(0, value) * DAIRY_SCALE; + return (int) Math.min(Integer.MAX_VALUE, Math.max(0L, value) * DAIRY_SCALE); } return Math.max(0, value); } diff --git a/src/main/java/net/tfminecraft/cooking/item/tag/TagTrack.java b/src/main/java/net/tfminecraft/cooking/item/tag/TagTrack.java index 2614bb0..6146766 100644 --- a/src/main/java/net/tfminecraft/cooking/item/tag/TagTrack.java +++ b/src/main/java/net/tfminecraft/cooking/item/tag/TagTrack.java @@ -3,6 +3,7 @@ import java.util.ArrayList; import java.util.List; +import org.bukkit.Bukkit; import org.bukkit.configuration.ConfigurationSection; public class TagTrack { @@ -20,6 +21,10 @@ public TagTrack(String key, int i, ConfigurationSection config) { for (String id : config.getKeys(false)) { if(id.equalsIgnoreCase("ageable")) continue; ConfigurationSection section = config.getConfigurationSection(id); + if (section == null) { + Bukkit.getLogger().warning("[Cooking] Ignoring non-section tag step: " + key + "." + id); + continue; + } steps.add(new TagStep(id, section)); } } @@ -60,6 +65,7 @@ public void forceSetValue(int value) { public boolean isAgeable() { return ageable; } public TagStep getCurrentStep() { + if (steps.isEmpty()) return null; TagStep current = null; for (TagStep step : steps) { if (value >= step.getRequiredValue()) { diff --git a/src/main/java/net/tfminecraft/cooking/liquid/LiquidContainerHandler.java b/src/main/java/net/tfminecraft/cooking/liquid/LiquidContainerHandler.java index 7bfd739..602b6e4 100644 --- a/src/main/java/net/tfminecraft/cooking/liquid/LiquidContainerHandler.java +++ b/src/main/java/net/tfminecraft/cooking/liquid/LiquidContainerHandler.java @@ -102,6 +102,10 @@ public void onBreak(FurnitureBreakEvent event) { } private void handleWaterBucket(Furniture furniture, Player player) { + if (ItemCache.blocksPerBucket <= 0) { + player.sendMessage("Could not empty this bucket into the container."); + return; + } if (!LiquidContainerState.canAccept(furniture, LiquidContainerState.TYPE_WATER)) { player.sendMessage("This container already has milk."); return; @@ -123,6 +127,10 @@ private void handleWaterBucket(Furniture furniture, Player player) { } private void handleMilkBucket(Furniture furniture, Player player) { + if (ItemCache.blocksPerBucket <= 0) { + player.sendMessage("Could not empty this bucket into the container."); + return; + } ItemStack hand = player.getInventory().getItemInMainHand(); ItemStack converted = MilkBucketConverter.convertIfNeeded(player, hand); if (converted != hand) { @@ -167,9 +175,6 @@ private void handleDispense(Furniture furniture, Player player) { } ItemStack hand = player.getInventory().getItemInMainHand(); - if (hand == null || hand.getType() == Material.AIR) { - return; - } LiquidContainerState.tickAge(furniture); String type = LiquidContainerState.getType(furniture); @@ -184,7 +189,7 @@ private void handleDispense(Furniture furniture, Player player) { cup = CupItems.cupOfWater(); } - if (cup == null) { + if (cup == null || cup.getType().isAir()) { player.sendMessage("Could not fill a cup."); return; } diff --git a/src/main/java/net/tfminecraft/cooking/loader/CarveSequenceLoader.java b/src/main/java/net/tfminecraft/cooking/loader/CarveSequenceLoader.java index 81633eb..18f26bf 100644 --- a/src/main/java/net/tfminecraft/cooking/loader/CarveSequenceLoader.java +++ b/src/main/java/net/tfminecraft/cooking/loader/CarveSequenceLoader.java @@ -5,6 +5,7 @@ import java.util.ArrayList; import java.util.HashMap; import java.util.List; +import java.util.Locale; import java.util.Map; import java.util.logging.Level; @@ -23,7 +24,7 @@ public class CarveSequenceLoader { public static CarveSequence get(String id) { if (id == null) return null; - return sequences.get(id.toLowerCase()); + return sequences.get(id.toLowerCase(Locale.ROOT)); } public void load(File file) { @@ -47,6 +48,11 @@ public void load(File file) { List cuts = new ArrayList<>(); for (Map map : sec.getMapList("cuts")) { + if (!validNumber(map, "food", false) || !validNumber(map, "nutrition", false) + || !validNumber(map, "amount", true)) { + Cooking.plugin.getLogger().warning("Carve sequence '" + key + "' has invalid cut values; skipping."); + continue; + } double food = map.containsKey("food") ? ((Number) map.get("food")).doubleValue() : 1.0; double nutrition = map.containsKey("nutrition") @@ -73,10 +79,18 @@ public void load(File file) { } } - sequences.put(key.toLowerCase(), new CarveSequence(key, startRemaining, minFoodCuts, cuts)); + sequences.put(key.toLowerCase(Locale.ROOT), new CarveSequence(key, startRemaining, minFoodCuts, cuts)); } } + private static boolean validNumber(Map cut, String key, boolean amount) { + if (!cut.containsKey(key)) return true; + if (!(cut.get(key) instanceof Number number)) return false; + double value = number.doubleValue(); + if (!Double.isFinite(value)) return false; + return amount ? value > 0 && value <= Integer.MAX_VALUE && value == Math.rint(value) : value >= 0; + } + /** Used when an older carve-sequences.yml has no min-food-cuts key. */ static int defaultMinFoodCuts(String key) { if ("poultry".equalsIgnoreCase(key)) { diff --git a/src/main/java/net/tfminecraft/cooking/loader/CompositionConfigLoader.java b/src/main/java/net/tfminecraft/cooking/loader/CompositionConfigLoader.java index bdc964e..3f12b13 100644 --- a/src/main/java/net/tfminecraft/cooking/loader/CompositionConfigLoader.java +++ b/src/main/java/net/tfminecraft/cooking/loader/CompositionConfigLoader.java @@ -5,6 +5,7 @@ import java.util.HashMap; import java.util.HashSet; import java.util.Map; +import java.util.Locale; import java.util.Set; import org.bukkit.configuration.ConfigurationSection; @@ -47,7 +48,7 @@ private static Map readContextOv for (String key : section.getKeys(false)) { try { - CompositionContext context = CompositionContext.valueOf(key.toUpperCase()); + CompositionContext context = CompositionContext.valueOf(key.toUpperCase(Locale.ROOT)); ConfigurationSection contextSection = section.getConfigurationSection(key); if (contextSection == null) { continue; @@ -66,7 +67,7 @@ private static Set readCategorySet(ConfigurationSection config, String p Set set = new HashSet<>(); for (String entry : config.getStringList(path)) { if (entry != null && !entry.isBlank()) { - set.add(entry.toLowerCase()); + set.add(entry.toLowerCase(Locale.ROOT)); } } return set; @@ -76,7 +77,7 @@ private static Set readCategorySet(FileConfiguration config, String path Set set = new HashSet<>(); for (String entry : config.getStringList(path)) { if (entry != null && !entry.isBlank()) { - set.add(entry.toLowerCase()); + set.add(entry.toLowerCase(Locale.ROOT)); } } return set; diff --git a/src/main/java/net/tfminecraft/cooking/loader/ConversionLoader.java b/src/main/java/net/tfminecraft/cooking/loader/ConversionLoader.java index 8575619..aeba2b9 100644 --- a/src/main/java/net/tfminecraft/cooking/loader/ConversionLoader.java +++ b/src/main/java/net/tfminecraft/cooking/loader/ConversionLoader.java @@ -56,11 +56,6 @@ public void load(File file) { String input = parts[0].trim(); String output = parts[1].trim(); - if (input.isEmpty() || output.isEmpty()) { - Bukkit.getLogger().warning("[Cooking] Invalid conversion line (empty input or output): " + trimmed); - continue; - } - conversions.put(input, output); } } diff --git a/src/main/java/net/tfminecraft/cooking/loader/FoodLoader.java b/src/main/java/net/tfminecraft/cooking/loader/FoodLoader.java index acf65f5..0f67511 100644 --- a/src/main/java/net/tfminecraft/cooking/loader/FoodLoader.java +++ b/src/main/java/net/tfminecraft/cooking/loader/FoodLoader.java @@ -6,6 +6,8 @@ import java.util.List; import java.util.Set; +import org.bukkit.Bukkit; +import org.bukkit.configuration.ConfigurationSection; import org.bukkit.configuration.InvalidConfigurationException; import org.bukkit.configuration.file.FileConfiguration; import org.bukkit.configuration.file.YamlConfiguration; @@ -38,7 +40,12 @@ public void load(File configFile) { List list = new ArrayList(set); for(String key : list) { - FoodItem r = new FoodItem(key, config.getConfigurationSection(key)); + ConfigurationSection section = config.getConfigurationSection(key); + if (section == null) { + Bukkit.getLogger().warning("[Cooking] Ignoring non-section FoodLoader entry: " + key); + continue; + } + FoodItem r = new FoodItem(key, section); oList.add(r); } } diff --git a/src/main/java/net/tfminecraft/cooking/loader/ModelLoader.java b/src/main/java/net/tfminecraft/cooking/loader/ModelLoader.java index 836cb5a..f0cb1e8 100644 --- a/src/main/java/net/tfminecraft/cooking/loader/ModelLoader.java +++ b/src/main/java/net/tfminecraft/cooking/loader/ModelLoader.java @@ -6,6 +6,8 @@ import java.util.List; import java.util.Set; +import org.bukkit.Bukkit; +import org.bukkit.configuration.ConfigurationSection; import org.bukkit.configuration.InvalidConfigurationException; import org.bukkit.configuration.file.FileConfiguration; import org.bukkit.configuration.file.YamlConfiguration; @@ -43,7 +45,12 @@ public void load(File file) { Set keys = config.getKeys(false); for (String key : keys) { - FoodModel model = new FoodModel(key, config.getConfigurationSection(key)); + ConfigurationSection section = config.getConfigurationSection(key); + if (section == null) { + Bukkit.getLogger().warning("[Cooking] Ignoring non-section ModelLoader entry: " + key); + continue; + } + FoodModel model = new FoodModel(key, section); models.add(model); } } diff --git a/src/main/java/net/tfminecraft/cooking/loader/NamingLoader.java b/src/main/java/net/tfminecraft/cooking/loader/NamingLoader.java index c6e9409..49a04d4 100644 --- a/src/main/java/net/tfminecraft/cooking/loader/NamingLoader.java +++ b/src/main/java/net/tfminecraft/cooking/loader/NamingLoader.java @@ -6,6 +6,7 @@ import java.util.HashSet; import java.util.List; import java.util.Map; +import java.util.Locale; import java.util.Set; import org.bukkit.configuration.ConfigurationSection; @@ -36,7 +37,7 @@ public void load(File configFile) { Set values = new HashSet<>(); for (String entry : categorySection.getStringList(key)) { if (entry != null && !entry.isBlank()) { - values.add(entry.toLowerCase()); + values.add(entry.toLowerCase(Locale.ROOT)); } } categories.put(key, values); @@ -61,15 +62,15 @@ public void load(File configFile) { for (String stepKey : stepSection.getKeys(false)) { String label = stepSection.getString(stepKey); if (label != null && !label.isBlank()) { - byStep.put(stepKey.toLowerCase(), label); + byStep.put(stepKey.toLowerCase(Locale.ROOT), label); } } if (!byStep.isEmpty()) { - byTrack.put(trackKey.toLowerCase(), byStep); + byTrack.put(trackKey.toLowerCase(Locale.ROOT), byStep); } } if (!byTrack.isEmpty()) { - tagLabelDefaults.put(categoryKey.toLowerCase(), byTrack); + tagLabelDefaults.put(categoryKey.toLowerCase(Locale.ROOT), byTrack); } } } @@ -80,7 +81,7 @@ public void load(File configFile) { for (String key : adjectiveSection.getKeys(false)) { String value = adjectiveSection.getString(key); if (key != null && !key.isBlank() && value != null && !value.isBlank()) { - originAdjectives.put(key.toLowerCase(), value); + originAdjectives.put(key.toLowerCase(Locale.ROOT), value); } } } diff --git a/src/main/java/net/tfminecraft/cooking/loader/QualityConfigLoader.java b/src/main/java/net/tfminecraft/cooking/loader/QualityConfigLoader.java index c50561f..98bf09a 100644 --- a/src/main/java/net/tfminecraft/cooking/loader/QualityConfigLoader.java +++ b/src/main/java/net/tfminecraft/cooking/loader/QualityConfigLoader.java @@ -5,6 +5,7 @@ import java.util.HashMap; import java.util.HashSet; import java.util.Map; +import java.util.Locale; import java.util.Set; import org.bukkit.configuration.InvalidConfigurationException; @@ -28,7 +29,7 @@ public void load(File configFile) { Set excludes = new HashSet<>(); for (String entry : config.getStringList("composition.exclude-categories")) { if (entry != null && !entry.isBlank()) { - excludes.add(entry.toLowerCase()); + excludes.add(entry.toLowerCase(Locale.ROOT)); } } diff --git a/src/main/java/net/tfminecraft/cooking/loader/TrackLoader.java b/src/main/java/net/tfminecraft/cooking/loader/TrackLoader.java index 5ae5e20..aa4d636 100644 --- a/src/main/java/net/tfminecraft/cooking/loader/TrackLoader.java +++ b/src/main/java/net/tfminecraft/cooking/loader/TrackLoader.java @@ -6,6 +6,8 @@ import java.util.List; import java.util.Set; +import org.bukkit.Bukkit; +import org.bukkit.configuration.ConfigurationSection; import org.bukkit.configuration.InvalidConfigurationException; import org.bukkit.configuration.file.FileConfiguration; import org.bukkit.configuration.file.YamlConfiguration; @@ -38,7 +40,12 @@ public void load(File configFile) { List list = new ArrayList(set); int i = 0; for(String key : list) { - TagTrack r = new TagTrack(key, i, config.getConfigurationSection(key)); + ConfigurationSection section = config.getConfigurationSection(key); + if (section == null) { + Bukkit.getLogger().warning("[Cooking] Ignoring non-section TrackLoader entry: " + key); + continue; + } + TagTrack r = new TagTrack(key, i, section); oList.add(r); i++; } diff --git a/src/main/java/net/tfminecraft/cooking/manager/CommandManager.java b/src/main/java/net/tfminecraft/cooking/manager/CommandManager.java index 7d61249..1828961 100644 --- a/src/main/java/net/tfminecraft/cooking/manager/CommandManager.java +++ b/src/main/java/net/tfminecraft/cooking/manager/CommandManager.java @@ -344,7 +344,7 @@ private boolean handleQualityTest(Player player, String[] args) { if (args[1].equalsIgnoreCase("compose") && args.length >= 4) { try { - context = CompositionContext.valueOf(args[end - 1].toUpperCase()); + context = CompositionContext.valueOf(args[end - 1].toUpperCase(Locale.ROOT)); end--; } catch (IllegalArgumentException ignored) { } @@ -385,7 +385,7 @@ private boolean handleCompose2(Player player, String[] args) { if (args.length >= 4) { try { - context = CompositionContext.valueOf(args[end - 1].toUpperCase()); + context = CompositionContext.valueOf(args[end - 1].toUpperCase(Locale.ROOT)); end--; } catch (IllegalArgumentException ignored) { } @@ -602,6 +602,7 @@ private Furniture findNearestMixingBowl(Player player) { continue; } + if (!player.getWorld().equals(furniture.getLoc().getWorld())) continue; double distance = furniture.getLoc().distance(player.getLocation()); if (distance <= nearestDistance) { nearestDistance = distance; @@ -705,6 +706,7 @@ private Furniture findNearestFurniture(Player player, double radius) { continue; } + if (!player.getWorld().equals(furniture.getLoc().getWorld())) continue; double distance = furniture.getLoc().distance(player.getLocation()); if (distance <= nearestDistance) { nearestDistance = distance; diff --git a/src/main/java/net/tfminecraft/cooking/manager/ConversionManager.java b/src/main/java/net/tfminecraft/cooking/manager/ConversionManager.java index f44688b..263d4c5 100644 --- a/src/main/java/net/tfminecraft/cooking/manager/ConversionManager.java +++ b/src/main/java/net/tfminecraft/cooking/manager/ConversionManager.java @@ -27,7 +27,7 @@ public class ConversionManager implements Listener { - @EventHandler + @EventHandler(ignoreCancelled = true) public void pickup(EntityPickupItemEvent e) { ItemStack item = e.getItem().getItemStack(); if (FoodItem.fromItem(item) != null) return; @@ -88,6 +88,7 @@ private static boolean hasCustomModelData(ItemStack item) { } private void giveConverted(EntityPickupItemEvent event, Player player, ItemStack stack) { + if (stack == null || stack.getType().isAir()) return; stack.setAmount(event.getItem().getItemStack().getAmount()); event.setCancelled(true); event.getItem().remove(); diff --git a/src/main/java/net/tfminecraft/cooking/manager/CookingManager.java b/src/main/java/net/tfminecraft/cooking/manager/CookingManager.java index f5901f0..08d6080 100644 --- a/src/main/java/net/tfminecraft/cooking/manager/CookingManager.java +++ b/src/main/java/net/tfminecraft/cooking/manager/CookingManager.java @@ -98,10 +98,7 @@ private CookingReference createReference(Furniture furniture, Method method) { if (method == Method.SAUCEPAN) { return new SauceReference(furniture, method); } - if (method == Method.POT) { - return new PotReference(furniture, method); - } - return new CookingReference(furniture, method); + return new PotReference(furniture, method); } private CookingReference getOrCreateReference(Furniture furniture) { @@ -138,12 +135,14 @@ public void empty(PlayerInteractEvent e) { Player p = e.getPlayer(); ItemStack item = p.getInventory().getItemInMainHand(); if(item == null) return; - if(!e.getClickedBlock().getType().equals(Material.CAULDRON)) return; + if(e.getClickedBlock() == null || !e.getClickedBlock().getType().equals(Material.CAULDRON)) return; FoodItem fi = FoodItem.fromItem(item); if(fi == null) return; if(fi.getCategory().equalsIgnoreCase("sauce") || fi.getCategory().equalsIgnoreCase("soup")) { + ItemStack empty = TLibs.getItemAPI().getCreator().getItemFromPath(ItemCache.ladle); + if (empty == null || empty.getType().isAir()) return; e.setCancelled(true); - p.getInventory().setItemInMainHand(TLibs.getItemAPI().getCreator().getItemFromPath(ItemCache.ladle)); + p.getInventory().setItemInMainHand(empty.clone()); } } @EventHandler diff --git a/src/main/java/net/tfminecraft/cooking/manager/PlateManager.java b/src/main/java/net/tfminecraft/cooking/manager/PlateManager.java index e0a3b87..df3d78d 100644 --- a/src/main/java/net/tfminecraft/cooking/manager/PlateManager.java +++ b/src/main/java/net/tfminecraft/cooking/manager/PlateManager.java @@ -63,20 +63,24 @@ public void update(Furniture f) { f.removeActiveSlot("sauce"); InteractibleFurniture.getInstance().getFurnitureManager().persistFurniture(f); } + boolean changed = false; for(PlacedSlot slot : f.getActiveSlots().values()) { - if(slot.getId().contains("display")) continue; + if(slot == null || slot.getId().contains("display")) continue; ItemStack item = slot.getCurrentItem(); if(item == null) continue; FoodItem fi = FoodItem.fromItem(item); if(fi == null) continue; item = ItemUpdater.updateItem(item, fi, f.getId()); - if(item == null) continue; + if(item == null || item.getType().isAir()) continue; slot.forceModel(item); + changed = true; } + if (changed) InteractibleFurniture.getInstance().getFurnitureManager().markDirty(f); } public void clear(Furniture f) { for(PlacedSlot slot : new ArrayList<>(f.getActiveSlots().values())) { + if (slot == null) continue; if(slot.getId().contains("display") || FurnitureCache.isBowl(f)) { slot.clearModel(); } @@ -93,6 +97,7 @@ public void remove(FurnitureBreakEvent e) { public boolean hasSauce(Furniture f) { for(PlacedSlot slot : new ArrayList<>(f.getActiveSlots().values())) { + if (slot == null) continue; if(!slot.getId().contains("display")) { ItemStack item = slot.getCurrentItem(); if(item == null) continue; @@ -100,7 +105,7 @@ public boolean hasSauce(Furniture f) { if(slot.getId().equals("sauce")) return true; FoodItem sauce = FoodItem.fromItem(item); if(sauce == null) continue; - if(sauce.getCategory().equalsIgnoreCase("sauce")) return true; + if(sauce.hasSauce() || "sauce".equalsIgnoreCase(sauce.getCategory())) return true; } } return false; @@ -125,16 +130,6 @@ private boolean hasFood(Furniture f, String ignoredSlot) { return false; } - // Food addSauce can actually apply the sauce to. - private boolean hasSauceableFood(Furniture f) { - for(PlacedSlot slot : f.getActiveSlots().values()) { - if(slot.getId().equals("sauce") || slot.getId().contains("display")) continue; - ItemStack item = slot.getCurrentItem(); - if(item != null && FoodItem.fromItem(item) != null) return true; - } - return false; - } - @EventHandler(priority = EventPriority.HIGHEST, ignoreCancelled = true) public void takeItem(FurnitureSlotItemTakeEvent e) { Furniture f = e.getFurniture(); @@ -149,44 +144,37 @@ public void takeItem(FurnitureSlotItemTakeEvent e) { @SuppressWarnings("deprecation") public void addSauce(Player p, Furniture f, FoodItem sauce, ItemStack base) { if (hasSauce(f)) return; - // Sauce with no food to take it would never reach food added later, so keep the ladle full. - if (!hasSauceableFood(f)) return; - - // Replace player ladle with empty ladle - p.getInventory().setItemInMainHand( - TLibs.getItemAPI().getCreator().getItemFromPath(ItemCache.ladle) - ); - p.swingMainHand(); - + Map updates = new java.util.LinkedHashMap<>(); for (PlacedSlot slot : f.getActiveSlots().values()) { - - if (slot.getId().contains("display")) continue; - + if (slot == null || slot.getId().equals("sauce") || slot.getId().contains("display")) continue; ItemStack item = slot.getCurrentItem(); if (item == null) continue; - - FoodItem fi = FoodItem.fromItem(item); - if (fi == null) continue; - - // Apply sauce + FoodItem parsed = FoodItem.fromItem(item); + if (parsed == null) continue; + FoodItem fi = new FoodItem(parsed); fi.setSauce(new FoodItem(sauce)); fi.setSauceName(base.getItemMeta().getDisplayName()); - - // Update the item - item = ItemUpdater.applyItemUpdate(item, fi, f.getId()); - if (item == null) continue; - - slot.forceModel(item); + ItemStack updated = ItemUpdater.applyItemUpdate(item.clone(), fi, f.getId()); + if (updated == null || updated.getType().isAir()) return; + updates.put(slot, updated); } + if (updates.isEmpty()) return; + ItemStack emptyLadle = TLibs.getItemAPI().getCreator().getItemFromPath(ItemCache.ladle); + if (emptyLadle == null || emptyLadle.getType().isAir()) return; + + updates.forEach(PlacedSlot::forceModel); + p.getInventory().setItemInMainHand(emptyLadle.clone()); + p.swingMainHand(); + InteractibleFurniture.getInstance().getFurnitureManager().markDirty(f); if (f.getType() == null || f.getType().getSlot("sauce") == null) return; - if(f.hasActiveSlot("sauce")) return; ItemMeta baseMeta = base.getItemMeta(); String saucePath = CategoryDictionary.getSauceItemPath(sauceColour( baseMeta.getPersistentDataContainer().get(Keys.SAUCE_COLOUR, PersistentDataType.STRING), baseMeta.getDisplayName()), 1); - f.getOrCreatePlacedSlot("sauce").forceModel(TLibs.getItemAPI().getCreator().getItemFromPath(saucePath)); - f.getLoc().getWorld().playSound(f.getLoc(), Sound.ITEM_BUCKET_FILL, 1f, 1f); //TODO SOUND + ItemStack visual = TLibs.getItemAPI().getCreator().getItemFromPath(saucePath); + if (visual != null && !visual.getType().isAir()) f.getOrCreatePlacedSlot("sauce").forceModel(visual); + f.getLoc().getWorld().playSound(f.getLoc(), Sound.ITEM_BUCKET_FILL, 1f, 1f); } /** Sauce colour stored at scoop time; ladles scooped before it existed fall back to the name's colour. */ @@ -195,14 +183,15 @@ static String sauceColour(String stored, String displayName) { } public void addSoup(Player p, Furniture f, FoodItem soup, ItemStack base) { - p.getInventory().setItemInMainHand( - TLibs.getItemAPI().getCreator().getItemFromPath(ItemCache.ladle) - ); - p.swingMainHand(); + if (f.getType() == null || f.getType().getSlot("food_item") == null) return; + PlacedSlot current = f.getActiveSlot("food_item").orElse(null); + if (current != null && current.getCurrentItem() != null && !current.getCurrentItem().getType().isAir()) return; + ItemStack emptyLadle = TLibs.getItemAPI().getCreator().getItemFromPath(ItemCache.ladle); + if (emptyLadle == null || emptyLadle.getType().isAir()) return; Map map = Encoder.decodeSlots(base.getItemMeta().getPersistentDataContainer().get(Keys.SLOT_DATA, PersistentDataType.STRING)); for(Map.Entry entry : map.entrySet()) { if(f.hasActiveSlot(entry.getKey())) continue; - if (f.getType() == null || f.getType().getSlot(entry.getKey()) == null) continue; + if (f.getType().getSlot(entry.getKey()) == null) continue; PlacedSlot placed = f.getOrCreatePlacedSlot(entry.getKey()); placed.forceModel(entry.getValue()); DisplayData spread = BowlIngredientLayout.offsetFor(entry.getKey()); @@ -210,8 +199,10 @@ public void addSoup(Player p, Furniture f, FoodItem soup, ItemStack base) { placed.applyDisplayData(spread); } } - if (f.getType() == null || f.getType().getSlot("food_item") == null) return; f.getOrCreatePlacedSlot("food_item").forceModel(base); + p.getInventory().setItemInMainHand(emptyLadle.clone()); + p.swingMainHand(); + InteractibleFurniture.getInstance().getFurnitureManager().markDirty(f); f.getLoc().getWorld().playSound(f.getLoc(), Sound.ITEM_BUCKET_FILL, 1f, 1f); //TODO SOUND } @@ -248,6 +239,7 @@ public void addItem(FurnitureSlotItemAddEvent e) { ItemStack item = e.getItem(); FoodItem fi = FoodItem.fromItem(item); if(fi == null) return; - e.setDisplayData(fi.getModelData().getDisplayData(f.getId())); + var model = fi.getModelData(); + if (model != null) e.setDisplayData(model.getDisplayData(f.getId())); } } diff --git a/src/main/java/net/tfminecraft/cooking/manager/TagManager.java b/src/main/java/net/tfminecraft/cooking/manager/TagManager.java index b07d644..8f2248d 100644 --- a/src/main/java/net/tfminecraft/cooking/manager/TagManager.java +++ b/src/main/java/net/tfminecraft/cooking/manager/TagManager.java @@ -44,7 +44,7 @@ public void update(Player player, Inventory inventory, int slot, ItemStack stack } boolean held = isHeldSlot(player, inventory, slot); ItemStack updated = ItemUpdater.updateItem(stack, food, null, held); - if (updated != null && inventory != null && slot >= 0) { + if (updated != null && !updated.getType().isAir() && inventory != null && slot >= 0) { inventory.setItem(slot, updated); } } diff --git a/src/main/java/net/tfminecraft/cooking/milling/MillingRecipeLoader.java b/src/main/java/net/tfminecraft/cooking/milling/MillingRecipeLoader.java index fe3d19d..35c1d76 100644 --- a/src/main/java/net/tfminecraft/cooking/milling/MillingRecipeLoader.java +++ b/src/main/java/net/tfminecraft/cooking/milling/MillingRecipeLoader.java @@ -36,16 +36,23 @@ public void load(File file) { continue; } + int inputCount = section.getInt("input-count", 8); + int durationTicks = section.getInt("duration-ticks", 60); + if (inputCount <= 0 || durationTicks <= 0) { + Bukkit.getLogger().warning("[Cooking] milling-recipes entry '" + recipeId + "' requires positive input-count and duration-ticks"); + continue; + } + recipes.put(recipeId, new MillingRecipe( recipeId, furnitureId, - section.getInt("input-count", 8), + inputCount, section.getString("input-food"), section.getString("input"), section.getString("vanilla-fallback"), outputFood, section.getInt("revolutions", 4), - section.getInt("duration-ticks", 60))); + durationTicks)); } MillingRecipeRegistry.load(recipes); diff --git a/src/main/java/net/tfminecraft/cooking/milling/MillingStoneHandler.java b/src/main/java/net/tfminecraft/cooking/milling/MillingStoneHandler.java index bee6d30..5c24e25 100644 --- a/src/main/java/net/tfminecraft/cooking/milling/MillingStoneHandler.java +++ b/src/main/java/net/tfminecraft/cooking/milling/MillingStoneHandler.java @@ -78,17 +78,15 @@ public void onBreak(FurnitureBreakEvent event) { } MillingRecipe recipe = MillingRecipeRegistry.getByFurnitureId(furniture.getId()); - MillingStoneAnimation.clearAnimating(furniture); - MillingStoneStage stage = resolveStage(furniture); - if (recipe != null) { - if (stage == MillingStoneStage.READY) { - dropFlour(furniture, recipe); - } else if (stage == MillingStoneStage.LOADED) { - dropWheatRefund(furniture, recipe); - } + if (stage != MillingStoneStage.EMPTY && (recipe == null + || (stage == MillingStoneStage.READY && !dropFlour(furniture, recipe)) + || (stage == MillingStoneStage.LOADED && !dropWheatRefund(furniture, recipe)))) { + event.setCancelled(true); + return; } + MillingStoneAnimation.clearAnimating(furniture); MillingStoneDisplay.clearAll(furniture); MillingStoneState.clear(furniture); } @@ -137,7 +135,7 @@ private void handleMill(Furniture furniture, MillingRecipe recipe, Player player private void handleTake(Furniture furniture, MillingRecipe recipe, Player player) { ItemStack flour = buildFlour(recipe, MillingStoneState.getOutputQuality(furniture), MillingStoneState.getLineage(furniture)); - if (flour == null) { + if (flour == null || flour.getType().isAir()) { player.sendMessage("§cFailed to create flour."); return; } @@ -154,22 +152,25 @@ private void handleTake(Furniture furniture, MillingRecipe recipe, Player player markDirty(furniture); } - private void dropFlour(Furniture furniture, MillingRecipe recipe) { + private boolean dropFlour(Furniture furniture, MillingRecipe recipe) { ItemStack flour = buildFlour(recipe, MillingStoneState.getOutputQuality(furniture), MillingStoneState.getLineage(furniture)); - if (flour != null) { - furniture.getLoc().getWorld().dropItemNaturally(furniture.getLoc(), flour); + if (flour == null || flour.getType().isAir()) { + return false; } + furniture.getLoc().getWorld().dropItemNaturally(furniture.getLoc(), flour); + return true; } - private void dropWheatRefund(Furniture furniture, MillingRecipe recipe) { + private boolean dropWheatRefund(Furniture furniture, MillingRecipe recipe) { ItemStack wheat = buildWheat(MillingStoneState.getOutputQuality(furniture), MillingStoneState.getLineage(furniture)); - if (wheat == null) { - return; + if (wheat == null || wheat.getType().isAir()) { + return false; } wheat.setAmount(recipe.getInputCount()); furniture.getLoc().getWorld().dropItemNaturally(furniture.getLoc(), wheat); + return true; } private static ItemStack buildFlour(MillingRecipe recipe, int quality, IngredientLineage lineage) { diff --git a/src/main/java/net/tfminecraft/cooking/mixing/MixingBowlHandler.java b/src/main/java/net/tfminecraft/cooking/mixing/MixingBowlHandler.java index bb21255..906596e 100644 --- a/src/main/java/net/tfminecraft/cooking/mixing/MixingBowlHandler.java +++ b/src/main/java/net/tfminecraft/cooking/mixing/MixingBowlHandler.java @@ -228,14 +228,6 @@ private void handleIngredientAdd(Furniture furniture, Player player, FurnitureIn String expectedSlot = stage.nextIngredientSlot(); - if (expectedSlot == null) { - - return; - - } - - - if (furniture.hasActiveSlot(expectedSlot)) { return; @@ -276,6 +268,11 @@ private void handleIngredientAdd(Furniture furniture, Player player, FurnitureIn boolean waterCup = MixingBowlSlots.WATER.equals(expectedSlot) && ItemCache.isCupOfWater(hand); + if (!MixingBowlDisplay.showLayer(furniture, expectedSlot)) { + event.setCancelled(true); + return; + } + hand.setAmount(hand.getAmount() - 1); if (waterCup) { @@ -290,14 +287,6 @@ private void handleIngredientAdd(Furniture furniture, Player player, FurnitureIn } } - if (!MixingBowlDisplay.showLayer(furniture, expectedSlot)) { - - return; - - } - - - storeIngredientQuality(furniture, expectedSlot, ingredient); MixingBowlState.setStage(furniture, stage.advance()); @@ -456,6 +445,8 @@ private void handleStir(Furniture furniture, Player player, FurnitureInteractEve if (count >= ItemCache.mixingStirCount) { + event.setCancelled(true); + completeDough(furniture); return; } @@ -502,14 +493,14 @@ private void handleStir(Furniture furniture, Player player, FurnitureInteractEve private void completeDough(Furniture furniture) { + if (!MixingBowlDisplay.showLayer(furniture, MixingBowlSlots.DOUGH)) return; + MixingBowlDisplay.clearLayer(furniture, MixingBowlSlots.FLOUR); MixingBowlDisplay.clearLayer(furniture, MixingBowlSlots.WATER); MixingBowlDisplay.clearLayer(furniture, MixingBowlSlots.YEAST); - MixingBowlDisplay.showLayer(furniture, MixingBowlSlots.DOUGH); - MixingBowlState.setStage(furniture, MixingBowlStage.DOUGH_READY); markDirty(furniture); @@ -652,21 +643,8 @@ private void markDirty(Furniture furniture) { private void sendWrongItemFeedback(Player player, MixingBowlStage stage) { - - switch (stage) { - - case EMPTY -> player.sendMessage("§cAdd flour first."); - - case HAS_FLOUR -> player.sendMessage("§cAdd water next."); - - case HAS_WATER -> player.sendMessage("§cAdd yeast next."); - - default -> player.sendMessage("§cThat doesn't go in the bowl."); - - } - + player.sendMessage("§cAdd " + stage.nextIngredientSlot() + + (stage == MixingBowlStage.EMPTY ? " first." : " next.")); } } - - diff --git a/src/main/java/net/tfminecraft/cooking/nutrition/BowlEatHandler.java b/src/main/java/net/tfminecraft/cooking/nutrition/BowlEatHandler.java index 9c43891..0adaa49 100644 --- a/src/main/java/net/tfminecraft/cooking/nutrition/BowlEatHandler.java +++ b/src/main/java/net/tfminecraft/cooking/nutrition/BowlEatHandler.java @@ -60,6 +60,10 @@ public void onInteract(FurnitureInteractEvent event) { } event.setCancelled(true); + if (!food.isEdible()) { + player.sendMessage("§cThis needs to be prepared before you can eat it."); + return; + } food.updateAge(); NutritionService.tryApplyEat(player, food); diff --git a/src/main/java/net/tfminecraft/cooking/nutrition/NutritionConfig.java b/src/main/java/net/tfminecraft/cooking/nutrition/NutritionConfig.java index 7ba277d..29faea6 100644 --- a/src/main/java/net/tfminecraft/cooking/nutrition/NutritionConfig.java +++ b/src/main/java/net/tfminecraft/cooking/nutrition/NutritionConfig.java @@ -48,7 +48,7 @@ public static void load(FileConfiguration config) { respawnFood = clampFood(section.getInt("respawn-food", DEFAULT_RESPAWN_FOOD)); maxDiet = section.getInt("max-diet", DEFAULT_MAX_DIET); attributeName = section.getString("attribute-name", DEFAULT_ATTRIBUTE_NAME); - drainAmount = section.getInt("drain-amount", DEFAULT_DRAIN_AMOUNT); + drainAmount = Math.max(0, section.getInt("drain-amount", DEFAULT_DRAIN_AMOUNT)); drainIntervalSeconds = readDrainIntervalSeconds(section); lerpStepRate = section.getDouble("lerp-step-rate", DEFAULT_LERP_STEP_RATE); loadVariety(section.getConfigurationSection("variety")); diff --git a/src/main/java/net/tfminecraft/cooking/nutrition/NutritionService.java b/src/main/java/net/tfminecraft/cooking/nutrition/NutritionService.java index 4063aaf..4279e2f 100644 --- a/src/main/java/net/tfminecraft/cooking/nutrition/NutritionService.java +++ b/src/main/java/net/tfminecraft/cooking/nutrition/NutritionService.java @@ -93,13 +93,9 @@ private static int applyFoodGain(RPCharacter character, int gained) { } int priorFood = character.getFoodValue(); - int newValue = Math.min(priorFood + gained, NutritionConfig.maxFood()); - if (newValue == priorFood) { - return 0; - } - + int newValue = (int) Math.min((long) priorFood + gained, NutritionConfig.maxFood()); character.setFoodValue(newValue); - return newValue - priorFood; + return character.getFoodValue() - priorFood; } static int foodAfterDeath(int current, int respawnFood, boolean inBattle) { diff --git a/src/main/java/net/tfminecraft/cooking/nutrition/VarietyMath.java b/src/main/java/net/tfminecraft/cooking/nutrition/VarietyMath.java index 2072b45..d17b6d3 100644 --- a/src/main/java/net/tfminecraft/cooking/nutrition/VarietyMath.java +++ b/src/main/java/net/tfminecraft/cooking/nutrition/VarietyMath.java @@ -32,9 +32,6 @@ public static Result evaluate( double sum = 0; double sumSquares = 0; for (double weight : weights.values()) { - if (weight <= 0) { - continue; - } sum += weight; sumSquares += weight * weight; } @@ -61,9 +58,6 @@ public static Result unmeasured() { private static Map weights(VarietyHistory history, double mainWeight, double extraWeight) { Map weights = new HashMap<>(); for (IngredientLineage meal : history.meals()) { - if (meal == null) { - continue; - } for (String origin : meal.mains()) { add(weights, origin, mainWeight); } diff --git a/src/main/java/net/tfminecraft/cooking/oven/OvenBurnManager.java b/src/main/java/net/tfminecraft/cooking/oven/OvenBurnManager.java index d5e595e..84bc16b 100644 --- a/src/main/java/net/tfminecraft/cooking/oven/OvenBurnManager.java +++ b/src/main/java/net/tfminecraft/cooking/oven/OvenBurnManager.java @@ -85,6 +85,10 @@ private void tick(Furniture furniture) { stopBurning(furniture.getEntityId()); return; } + if (!OvenState.isLit(furniture)) { + stopBurning(furniture.getEntityId()); + return; + } boolean changed = false; String[] activeLayer = OvenState.getActiveBurnLayer(furniture); diff --git a/src/main/java/net/tfminecraft/cooking/oven/OvenCavityManager.java b/src/main/java/net/tfminecraft/cooking/oven/OvenCavityManager.java index 7161453..631d871 100644 --- a/src/main/java/net/tfminecraft/cooking/oven/OvenCavityManager.java +++ b/src/main/java/net/tfminecraft/cooking/oven/OvenCavityManager.java @@ -46,20 +46,16 @@ private void tick() { for (PlacedFurnitureSlot slot : consumer.getActiveFurnitureSlots().values()) { Furniture tray = slot.getNested(); - if (tray == null || !BakingTrayRegistry.isTray(tray)) { + BakingTrayRecipe recipe = BakingTrayRegistry.getByFurniture(tray); + if (recipe == null) { continue; } - tickTray(consumer, tray); + tickTray(consumer, tray, recipe); } } } - private void tickTray(Furniture consumer, Furniture tray) { - BakingTrayRecipe recipe = BakingTrayRegistry.getByFurniture(tray); - if (recipe == null) { - return; - } - + private void tickTray(Furniture consumer, Furniture tray, BakingTrayRecipe recipe) { if (!hasBakeableContent(tray, recipe)) { return; } @@ -91,13 +87,13 @@ private void tickTray(Furniture consumer, Furniture tray) { } TagTrack cooked = foodItem.getTagTrack(COOKED_TRACK); - if (cooked == null || cooked.getValue() >= BURNT) { + if (cooked != null && cooked.getValue() >= BURNT) { continue; } BakingTrayState.incrementSlotElapsed(tray, slotId); int elapsed = BakingTrayState.getSlotElapsed(tray, slotId); - int cookedValue = cooked.getValue(); + int cookedValue = cooked == null ? RAW : cooked.getValue(); if (cookedValue == RAW && elapsed >= bake.getCookSeconds()) { if (BakingTrayBakeApplier.applyCookSlot(tray, slotId)) { diff --git a/src/main/java/net/tfminecraft/cooking/oven/OvenHandler.java b/src/main/java/net/tfminecraft/cooking/oven/OvenHandler.java index dd53d3b..92b0e27 100644 --- a/src/main/java/net/tfminecraft/cooking/oven/OvenHandler.java +++ b/src/main/java/net/tfminecraft/cooking/oven/OvenHandler.java @@ -51,15 +51,10 @@ public void onInteract(FurnitureInteractEvent event) { } private void handleAddFuel(Furniture furniture, Player player, ItemStack hand, FurnitureInteractEvent event) { - if (OvenState.isFull(furniture)) { - event.setCancelled(true); - player.sendMessage("§cThe oven is full of wood."); - return; - } - String slotId = OvenState.findNextFillSlot(furniture); if (slotId == null) { event.setCancelled(true); + player.sendMessage("§cThe oven is full of wood."); return; } diff --git a/src/main/java/net/tfminecraft/cooking/oven/OvenState.java b/src/main/java/net/tfminecraft/cooking/oven/OvenState.java index b25a79c..a169248 100644 --- a/src/main/java/net/tfminecraft/cooking/oven/OvenState.java +++ b/src/main/java/net/tfminecraft/cooking/oven/OvenState.java @@ -71,9 +71,6 @@ public static boolean isFull(Furniture furniture) { } public static String findNextFillSlot(Furniture furniture) { - if (isFull(furniture)) { - return null; - } OvenSlots.WoodStage[] stages = getStages(furniture); for (int i = 0; i < SLOT_COUNT; i++) { if (stages[i] == OvenSlots.WoodStage.EMPTY) { diff --git a/src/main/java/net/tfminecraft/cooking/quality/CompositionConfig.java b/src/main/java/net/tfminecraft/cooking/quality/CompositionConfig.java index 7009b79..a589105 100644 --- a/src/main/java/net/tfminecraft/cooking/quality/CompositionConfig.java +++ b/src/main/java/net/tfminecraft/cooking/quality/CompositionConfig.java @@ -3,6 +3,7 @@ import java.util.HashMap; import java.util.HashSet; import java.util.Map; +import java.util.Locale; import java.util.Set; public final class CompositionConfig { @@ -78,7 +79,7 @@ public static CompositionRole getRole(String category) { if (category == null) { return CompositionRole.MAIN; } - String key = category.toLowerCase(); + String key = category.toLowerCase(Locale.ROOT); if (neutralCategories.contains(key)) { return CompositionRole.NEUTRAL; } @@ -95,7 +96,7 @@ public static CompositionRole getRole(String category, CompositionContext contex if (category == null) { return CompositionRole.MAIN; } - String key = category.toLowerCase(); + String key = category.toLowerCase(Locale.ROOT); if (context != null) { RoleSets override = contextOverrides.get(context); diff --git a/src/main/java/net/tfminecraft/cooking/quality/CompositionQualityResolver.java b/src/main/java/net/tfminecraft/cooking/quality/CompositionQualityResolver.java index 5ba22ba..0c4d8cf 100644 --- a/src/main/java/net/tfminecraft/cooking/quality/CompositionQualityResolver.java +++ b/src/main/java/net/tfminecraft/cooking/quality/CompositionQualityResolver.java @@ -86,7 +86,7 @@ public static int resolveMainsQuality(List mains) { } public static Map resolveMainsFreshness(List mains) { - Map sums = new HashMap<>(); + Map sums = new HashMap<>(); Map counts = new HashMap<>(); if (mains == null) { @@ -99,13 +99,13 @@ public static Map resolveMainsFreshness(List mains) { continue; } String id = track.getId(); - sums.merge(id, track.getValue(), Integer::sum); + sums.merge(id, (long) track.getValue(), Long::sum); counts.merge(id, 1, Integer::sum); } } Map averaged = new HashMap<>(); - for (Map.Entry entry : sums.entrySet()) { + for (Map.Entry entry : sums.entrySet()) { int count = counts.getOrDefault(entry.getKey(), 1); averaged.put(entry.getKey(), (int) Math.round(entry.getValue() / (double) count)); } @@ -212,7 +212,7 @@ private static int applyChefBoost(Player player, int quality) { } if (ThreadLocalRandom.current().nextDouble() < best.chance()) { - return QualityUtils.clamp(quality + best.boost()); + return (int) Math.clamp((long) quality + best.boost(), 1L, 5L); } return quality; diff --git a/src/main/java/net/tfminecraft/cooking/quality/OriginQualityResolver.java b/src/main/java/net/tfminecraft/cooking/quality/OriginQualityResolver.java index a68fa43..02f0d1a 100644 --- a/src/main/java/net/tfminecraft/cooking/quality/OriginQualityResolver.java +++ b/src/main/java/net/tfminecraft/cooking/quality/OriginQualityResolver.java @@ -37,8 +37,8 @@ public static int applyPickupPermissions(Player player, int rolled) { } public static int adjust(int rolled, int minQuality, int rollBias) { - int quality = rolled + rollBias; + long quality = (long) rolled + rollBias; quality = Math.max(quality, minQuality); - return QualityUtils.clamp(quality); + return (int) Math.clamp(quality, 1L, 5L); } } diff --git a/src/main/java/net/tfminecraft/cooking/quality/QualityConfig.java b/src/main/java/net/tfminecraft/cooking/quality/QualityConfig.java index a8b583a..58902be 100644 --- a/src/main/java/net/tfminecraft/cooking/quality/QualityConfig.java +++ b/src/main/java/net/tfminecraft/cooking/quality/QualityConfig.java @@ -3,6 +3,7 @@ import java.util.HashMap; import java.util.HashSet; import java.util.Map; +import java.util.Locale; import java.util.Set; public final class QualityConfig { @@ -34,8 +35,8 @@ private static Map defaultNutritionFromQuality() { } public static void apply(int min, int max, Set excludes, Map nutrition) { - pickupMin = Math.max(1, min); - pickupMax = Math.max(pickupMin, max); + pickupMin = Math.clamp(min, 1, 5); + pickupMax = Math.clamp(max, pickupMin, 5); excludeCategories = excludes == null || excludes.isEmpty() ? defaultExcludes() : Set.copyOf(excludes); @@ -62,7 +63,7 @@ public static boolean isExcludedCategory(String category) { if (category == null) { return false; } - return excludeCategories.contains(category.toLowerCase()); + return excludeCategories.contains(category.toLowerCase(Locale.ROOT)); } public static double nutritionMultiplier(int quality) { diff --git a/src/main/java/net/tfminecraft/cooking/sausagemaker/SausageMakerAnimation.java b/src/main/java/net/tfminecraft/cooking/sausagemaker/SausageMakerAnimation.java index 14e2c59..8096785 100644 --- a/src/main/java/net/tfminecraft/cooking/sausagemaker/SausageMakerAnimation.java +++ b/src/main/java/net/tfminecraft/cooking/sausagemaker/SausageMakerAnimation.java @@ -50,7 +50,7 @@ public static void playWobble(Furniture furniture, Runnable onComplete) { final Transformation startTransform = parent.getTransformation(); final Vector3f startTranslation = new Vector3f(startTransform.getTranslation()); final Quaternionf startRot = new Quaternionf(startTransform.getLeftRotation()); - final int duration = ItemCache.sausageMakerDurationTicks; + final int duration = Math.max(1, ItemCache.sausageMakerDurationTicks); final float wobbleRadians = (float) Math.toRadians(ItemCache.sausageMakerWobbleDegrees); new BukkitRunnable() { diff --git a/src/main/java/net/tfminecraft/cooking/sausagemaker/SausageMakerHandler.java b/src/main/java/net/tfminecraft/cooking/sausagemaker/SausageMakerHandler.java index a53768a..47f87c0 100644 --- a/src/main/java/net/tfminecraft/cooking/sausagemaker/SausageMakerHandler.java +++ b/src/main/java/net/tfminecraft/cooking/sausagemaker/SausageMakerHandler.java @@ -150,7 +150,7 @@ private void finishCrank(Furniture furniture, Player player) { } ItemStack chain = SausageItems.fromMeats(player, meats); - if (chain == null) { + if (chain == null || chain.getType().isAir()) { player.sendMessage("§cCould not make sausage chain."); return; } @@ -190,14 +190,8 @@ private static boolean tryTakeLast(Furniture furniture, Player player) { return false; } - PlacedSlot slot = furniture.getActiveSlot(slotId).orElse(null); - if (slot == null) { - return false; - } + PlacedSlot slot = furniture.getActiveSlot(slotId).orElseThrow(); ItemStack item = slot.getCurrentItem(); - if (item == null || item.getType().isAir()) { - return false; - } ItemStack leftover = InventoryAdder.addItem(player, item.clone()); if (leftover != null) { @@ -255,9 +249,6 @@ private static List collectMeats(Furniture furniture) { private static void clearMeatSlots(Furniture furniture) { for (String slotId : MEAT_SLOTS) { - if (!furniture.hasActiveSlot(slotId)) { - continue; - } furniture.getActiveSlot(slotId).ifPresent(slot -> { slot.clearModel(); furniture.removeActiveSlot(slotId); diff --git a/src/main/java/net/tfminecraft/cooking/trough/TroughAging.java b/src/main/java/net/tfminecraft/cooking/trough/TroughAging.java index 1702392..2818dd4 100644 --- a/src/main/java/net/tfminecraft/cooking/trough/TroughAging.java +++ b/src/main/java/net/tfminecraft/cooking/trough/TroughAging.java @@ -70,6 +70,9 @@ private boolean ageSlot(Furniture furniture, PlacedSlot slot, String slotId) { } ItemStack updated = ItemUpdater.updateItem(item, foodItem, furniture.getId()); + if (updated != null && updated.getType().isAir()) { + updated = null; + } if (updated != null) { item = updated; slot.setCurrentItem(updated); diff --git a/src/main/java/net/tfminecraft/cooking/trough/TroughHandler.java b/src/main/java/net/tfminecraft/cooking/trough/TroughHandler.java index b9483f6..bff978b 100644 --- a/src/main/java/net/tfminecraft/cooking/trough/TroughHandler.java +++ b/src/main/java/net/tfminecraft/cooking/trough/TroughHandler.java @@ -58,6 +58,10 @@ public void onInteract(FurnitureInteractEvent event) { } int perClick = ItemCache.troughItemsPerClick; + if (perClick <= 0) { + player.sendMessage("§cThe trough is not configured correctly."); + return; + } if (hand.getAmount() < perClick) { player.sendMessage("§cAdd " + perClick + " at a time."); return; @@ -117,7 +121,7 @@ private static ItemStack mintFeed() { } try { ItemStack stack = TLibs.getItemAPI().getCreator().getItemFromPath(path); - if (stack == null) { + if (stack == null || stack.getType().isAir()) { return null; } stack.setAmount(1); diff --git a/src/main/java/net/tfminecraft/cooking/utils/DoughMixinRules.java b/src/main/java/net/tfminecraft/cooking/utils/DoughMixinRules.java index f13068c..b37cf50 100644 --- a/src/main/java/net/tfminecraft/cooking/utils/DoughMixinRules.java +++ b/src/main/java/net/tfminecraft/cooking/utils/DoughMixinRules.java @@ -59,8 +59,9 @@ public static boolean canAcceptFruit(List fruitOrigins, FoodItem incomin public static void applyDoughTags(FoodItem dough, boolean hasSugar, List fruitOrigins) { if (hasSugar) { - TagTrack sweet = new TagTrack(TrackLoader.getByString("sweet")); + TagTrack sweet = TrackLoader.getByString("sweet"); if (sweet != null) { + sweet = new TagTrack(sweet); sweet.setValue(0); dough.addOrModifyTrack(sweet); } @@ -68,14 +69,16 @@ public static void applyDoughTags(FoodItem dough, boolean hasSugar, List int fruitCount = fruitOrigins == null ? 0 : fruitOrigins.size(); if (fruitCount >= 2) { - TagTrack richlyFruity = new TagTrack(TrackLoader.getByString("richly_fruity")); + TagTrack richlyFruity = TrackLoader.getByString("richly_fruity"); if (richlyFruity != null) { + richlyFruity = new TagTrack(richlyFruity); richlyFruity.setValue(0); dough.addOrModifyTrack(richlyFruity); } } else if (fruitCount == 1) { - TagTrack fruity = new TagTrack(TrackLoader.getByString("fruity")); + TagTrack fruity = TrackLoader.getByString("fruity"); if (fruity != null) { + fruity = new TagTrack(fruity); fruity.setValue(0); dough.addOrModifyTrack(fruity); } diff --git a/src/main/java/net/tfminecraft/cooking/utils/FoodParser.java b/src/main/java/net/tfminecraft/cooking/utils/FoodParser.java index d64732f..5464497 100644 --- a/src/main/java/net/tfminecraft/cooking/utils/FoodParser.java +++ b/src/main/java/net/tfminecraft/cooking/utils/FoodParser.java @@ -87,34 +87,51 @@ public static Map extractFields(String inside) { // ============================================================ public static Result parse(String input) { - if (input == null || !input.contains("(")) return null; + if (input == null) return null; + input = input.trim(); + int firstParen = input.indexOf('('); + if (firstParen < 0 || !input.endsWith(")")) return null; + int depth = 0; + for (int i = firstParen; i < input.length(); i++) { + char c = input.charAt(i); + if (c == '(') depth++; + if (c == ')') depth--; + if (depth == 0 && i != input.length() - 1) return null; + } + if (depth != 0) return null; + try { + return parseRecipe(input); + } catch (NumberFormatException invalidNumber) { + return null; + } + } + + private static Result parseRecipe(String input) { String cat = input.substring(0, input.indexOf("(")).trim(); int firstParen = input.indexOf("("); String inside = input.substring(firstParen + 1, input.length() - 1); - Map fields = extractFields(inside); + Map fields = new LinkedHashMap<>(); + extractFields(inside).forEach((key, value) -> fields.put(key.toLowerCase(Locale.ROOT), value)); - String foodId = null; String originInput = ""; int amountMin = 1, amountMax = 1; int qualMin = 1, qualMax = 5; boolean unique = true; boolean explicitQuality = false; - FoodItem item = null; + FoodItem base = FoodLoader.getByString(fields.get("type")); + if (base == null) return null; + FoodItem item = new FoodItem(base); // ---------------- FIELD PARSING --------------------- for (Map.Entry e : fields.entrySet()) { - String key = e.getKey().trim().toLowerCase(); + String key = e.getKey(); String value = e.getValue().trim(); switch (key) { case "type": - foodId = value; - FoodItem base = FoodLoader.getByString(foodId); - if (base == null) return null; - item = new FoodItem(base); break; case "origin": @@ -128,21 +145,27 @@ public static Result parse(String input) { case "quality": if (value.contains("-")) { String[] q = value.split("-"); + if (q.length != 2) return null; qualMin = Integer.parseInt(q[0]); qualMax = Integer.parseInt(q[1]); } else { qualMin = qualMax = Integer.parseInt(value); } - item._parsedQualMin = qualMin; - item._parsedQualMax = qualMax; + if (qualMin > qualMax) return null; + qualMin = QualityUtils.clamp(qualMin); + qualMax = QualityUtils.clamp(qualMax); explicitQuality = true; break; case "amount": if (value.contains("-")) { String[] q = value.split("-"); - amountMin = Math.max(1, Integer.parseInt(q[0])); - amountMax = Math.min(64, Integer.parseInt(q[1])); + if (q.length != 2) return null; + amountMin = Integer.parseInt(q[0]); + amountMax = Integer.parseInt(q[1]); + if (amountMin > amountMax) return null; + amountMin = Math.max(1, Math.min(64, amountMin)); + amountMax = Math.max(1, Math.min(64, amountMax)); } else { int fixed = Integer.parseInt(value); amountMin = amountMax = Math.max(1, Math.min(64, fixed)); @@ -206,10 +229,6 @@ public static Result parse(String input) { } // ---------------- FINALIZE --------------------- - if (item == null) { - return null; - } - Result r = new Result(); r.unique = unique; r.explicitQuality = explicitQuality; diff --git a/src/main/java/net/tfminecraft/cooking/utils/IngredientConverter.java b/src/main/java/net/tfminecraft/cooking/utils/IngredientConverter.java index 237694f..c76588b 100644 --- a/src/main/java/net/tfminecraft/cooking/utils/IngredientConverter.java +++ b/src/main/java/net/tfminecraft/cooking/utils/IngredientConverter.java @@ -39,6 +39,9 @@ public static ItemStack convertHarvestDrop(Player player, ItemStack stack, doubl : OriginQualityResolver.resolve(player, parsed.template); quality = HoeQualityBonus.apply(quality, hoeQualityBonusPercent); ItemStack converted = ItemBuilder.buildSingleWithQuality(parsed.template, stack, quality); + if (converted == null || converted.getType().isAir()) { + return stack; + } converted.setAmount(stack.getAmount()); return converted; } diff --git a/src/main/java/net/tfminecraft/cooking/utils/InventoryAdder.java b/src/main/java/net/tfminecraft/cooking/utils/InventoryAdder.java index 32dcd41..446ab58 100644 --- a/src/main/java/net/tfminecraft/cooking/utils/InventoryAdder.java +++ b/src/main/java/net/tfminecraft/cooking/utils/InventoryAdder.java @@ -6,6 +6,7 @@ import org.bukkit.inventory.ItemStack; import org.bukkit.inventory.PlayerInventory; import net.tfminecraft.cooking.item.FoodItem; +import net.tfminecraft.cooking.manager.ConversionManager; public class InventoryAdder { public static ItemStack addItem(Player p, ItemStack in) { @@ -27,8 +28,9 @@ public static ItemStack addItem(Player p, ItemStack in) { if (fCur == null) continue; if (!equalsFood(fIn, fCur)) continue; + if (!ConversionManager.sameApartFromAging(in, cur)) continue; - int space = 64 - cur.getAmount(); + int space = Math.min(cur.getMaxStackSize(), inv.getMaxStackSize()) - cur.getAmount(); if (space <= 0) continue; int add = Math.min(space, amount); @@ -60,4 +62,3 @@ public static boolean equalsFood(FoodItem a, FoodItem b) { return true; } } - diff --git a/src/main/java/net/tfminecraft/cooking/utils/ItemBuilder.java b/src/main/java/net/tfminecraft/cooking/utils/ItemBuilder.java index ae18046..c631e16 100644 --- a/src/main/java/net/tfminecraft/cooking/utils/ItemBuilder.java +++ b/src/main/java/net/tfminecraft/cooking/utils/ItemBuilder.java @@ -85,7 +85,7 @@ private static ItemStack buildSingleWithQuality(FoodItem template, ItemStack bas if (displayNameOverride == null) { if (origin != null) { - OverrideData od = item.getOverrides().get(origin.toUpperCase()); + OverrideData od = item.getOverrides().get(origin.toUpperCase(Locale.ROOT)); if (od != null) { if (od.getName() != null) displayName = od.getName(); } @@ -97,7 +97,10 @@ private static ItemStack buildSingleWithQuality(FoodItem template, ItemStack bas } } - if(item.getModel() == null) item.setModel(new FoodModel(base)); + if (item.getModel() == null) { + if (base == null || base.getType().isAir()) return null; + item.setModel(new FoodModel(base)); + } if (item.getCarveSequenceId() != null) { CarveSequence seq = CarveSequenceLoader.get(item.getCarveSequenceId()); if (seq != null) { @@ -105,8 +108,10 @@ private static ItemStack buildSingleWithQuality(FoodItem template, ItemStack bas } } ModelData model = item.getModelData(); + if (model == null) return null; ItemStack stack = model.apply(null, new ItemStack(Material.DIRT)); + if (stack == null || stack.getType().isAir()) return null; stamp(stack, item, displayName); if (item.getCarveSequenceId() != null) { @@ -236,20 +241,17 @@ public static ItemStack stamp(ItemStack stack, FoodItem item, String displayName pdc.remove(Keys.CUSTOM_FISHING_ID); } - if (!indexMap.isEmpty()) { - StringBuilder sb = new StringBuilder(); - boolean first = true; - for (var e : indexMap.entrySet()) { - if (!first) { - sb.append(";"); - } - sb.append(e.getKey()).append(".").append(e.getValue()); - first = false; + // Every lore includes food and nutrition, so their indexes are always present. + StringBuilder sb = new StringBuilder(); + boolean first = true; + for (var e : indexMap.entrySet()) { + if (!first) { + sb.append(";"); } - pdc.set(Keys.LORE_INDEX_MAP, PersistentDataType.STRING, sb.toString()); - } else { - pdc.remove(Keys.LORE_INDEX_MAP); + sb.append(e.getKey()).append(".").append(e.getValue()); + first = false; } + pdc.set(Keys.LORE_INDEX_MAP, PersistentDataType.STRING, sb.toString()); if (item.getModel() != null) { pdc.set(Keys.MODEL, PersistentDataType.STRING, item.getModel().getId()); @@ -415,6 +417,7 @@ public static void buildFromString(Player p, String string, ItemStack base) { public static ItemStack buildSingleString(String string, ItemStack base) { FoodParser.Result parsed = FoodParser.parse(CookingPathHandler.stripPrefix(string)); + if (parsed == null || parsed.template == null) return null; FoodItem template = parsed.template; if (parsed.explicitQuality) { return ItemBuilder.buildSingle(template, base); diff --git a/src/main/java/net/tfminecraft/cooking/utils/ItemRef.java b/src/main/java/net/tfminecraft/cooking/utils/ItemRef.java index b3903c1..61466d0 100644 --- a/src/main/java/net/tfminecraft/cooking/utils/ItemRef.java +++ b/src/main/java/net/tfminecraft/cooking/utils/ItemRef.java @@ -5,7 +5,6 @@ import org.bukkit.persistence.PersistentDataContainer; import net.tfminecraft.tlibs.TLibs; -import org.bukkit.persistence.PersistentDataType; public class ItemRef { @@ -14,7 +13,9 @@ public static ItemStack resolve(String ref) { } public static ItemStack apply(String ref, ItemStack source) { - ItemStack out = resolve(ref).clone(); + ItemStack template = resolve(ref); + if (template == null) return null; + ItemStack out = template.clone(); mergeMeta(out, source); return out; } @@ -34,17 +35,7 @@ public static void mergeMeta(ItemStack out, ItemStack source) { PersistentDataContainer srcPdc = srcMeta.getPersistentDataContainer(); PersistentDataContainer outPdc = outMeta.getPersistentDataContainer(); - srcPdc.getKeys().forEach(key -> { - if (srcPdc.has(key, PersistentDataType.STRING)) { - outPdc.set(key, PersistentDataType.STRING, srcPdc.get(key, PersistentDataType.STRING)); - } else if (srcPdc.has(key, PersistentDataType.INTEGER)) { - outPdc.set(key, PersistentDataType.INTEGER, srcPdc.get(key, PersistentDataType.INTEGER)); - } else if (srcPdc.has(key, PersistentDataType.LONG)) { - outPdc.set(key, PersistentDataType.LONG, srcPdc.get(key, PersistentDataType.LONG)); - } else if (srcPdc.has(key, PersistentDataType.DOUBLE)) { - outPdc.set(key, PersistentDataType.DOUBLE, srcPdc.get(key, PersistentDataType.DOUBLE)); - } - }); + srcPdc.copyTo(outPdc, true); out.setItemMeta(outMeta); } diff --git a/src/main/java/net/tfminecraft/cooking/utils/ItemUpdater.java b/src/main/java/net/tfminecraft/cooking/utils/ItemUpdater.java index 3a61a22..860c5ab 100644 --- a/src/main/java/net/tfminecraft/cooking/utils/ItemUpdater.java +++ b/src/main/java/net/tfminecraft/cooking/utils/ItemUpdater.java @@ -45,7 +45,7 @@ public static boolean applyAging(FoodItem fi, long deltaSeconds) { } TagStep after = track.getCurrentStep(); - if (before != null && after != null && !before.getTag().equals(after.getTag())) { + if (before != null && after != null && !before.getId().equals(after.getId())) { changed = true; } } @@ -74,7 +74,7 @@ public static ItemStack applyItemUpdate(ItemStack stack, FoodItem fi, String fur ModelData newModel = fi.getModel() != null ? fi.getModelData() : null; if (newModel != null) { updated = newModel.apply(furniture, stack); - if (updated == null) { + if (updated == null || updated.getType().isAir()) { updated = stack; } } @@ -109,9 +109,6 @@ public static ItemStack updateItem(ItemStack stack, FoodItem fi, String furnitur if (!broken) { return null; } - if (!shouldWriteToSlot(held, true, false)) { - return null; - } return applyItemUpdate(stack, fi, furniture); } @@ -131,13 +128,11 @@ public static ItemStack updateItem(ItemStack stack, FoodItem fi, String furnitur changed = applyAging(fi, deltaSeconds); } boolean visual = changed || expired || broken; - boolean normalizeNeeded = StackNormalizer.needsNormalize(fi); boolean clockOff = fi.getLastUpdate() != now; - boolean silent = normalizeNeeded || clockOff; + boolean silent = clockOff; if (!shouldWriteToSlot(held, visual, silent)) { return null; } - StackNormalizer.normalize(fi); fi.setLastUpdate(now); return applyItemUpdate(stack, fi, furniture); } @@ -179,7 +174,7 @@ public static boolean needsLoreRebuild(List lore, boolean hasLoreIndex) // Keep the existing legacy text representation, formatting, and exact-string comparisons. @SuppressWarnings("deprecation") private static String existingDisplayName(ItemStack stack) { - if (stack == null || !stack.hasItemMeta()) { + if (!stack.hasItemMeta()) { return null; } ItemMeta meta = stack.getItemMeta(); diff --git a/src/main/java/net/tfminecraft/cooking/utils/NameComposer.java b/src/main/java/net/tfminecraft/cooking/utils/NameComposer.java index d7a9c71..4edde60 100644 --- a/src/main/java/net/tfminecraft/cooking/utils/NameComposer.java +++ b/src/main/java/net/tfminecraft/cooking/utils/NameComposer.java @@ -30,7 +30,16 @@ public static String compose(FoodItem item, Map extras) { String prefixes = formatPrefixes(item); String fillers = formatFillerPhrase(item.getIngredients()); - String ingredients = formatFillerPhrase(item.getIngredients(), inferTypeLabel(template)); + String typeLabel = ""; + int ingredientToken = template.lastIndexOf("{ingredients}"); + if (ingredientToken >= 0) { + String suffix = template.substring(ingredientToken + "{ingredients}".length()); + if (!suffix.isBlank() && !suffix.contains("{") && !suffix.contains("}")) { + typeLabel = suffix.strip(); + template = template.substring(0, ingredientToken + "{ingredients}".length()); + } + } + String ingredients = formatFillerPhrase(item.getIngredients(), typeLabel); String result = template .replace("{colour}", colour) @@ -48,9 +57,6 @@ public static String formatPrefixes(FoodItem item) { StringBuilder prefixes = new StringBuilder(); for (String trackId : NamingConfig.getPrefixTracks()) { - if (!item.hasTagTrack(trackId)) { - continue; - } TagTrack track = item.getTagTrack(trackId); if (track == null) { continue; @@ -148,14 +154,4 @@ private static String stripHexFromTaggedName(String name) { return name; } - private static String inferTypeLabel(String template) { - if (template == null) { - return ""; - } - int idx = template.lastIndexOf('}'); - if (idx >= 0 && idx + 1 < template.length()) { - return template.substring(idx + 1); - } - return ""; - } } diff --git a/src/main/java/net/tfminecraft/cooking/utils/QualityUtils.java b/src/main/java/net/tfminecraft/cooking/utils/QualityUtils.java index 843fd39..dcb0ae1 100644 --- a/src/main/java/net/tfminecraft/cooking/utils/QualityUtils.java +++ b/src/main/java/net/tfminecraft/cooking/utils/QualityUtils.java @@ -16,7 +16,7 @@ public static int average(int... qualities) { if (qualities == null || qualities.length == 0) { return MIN_QUALITY; } - int sum = 0; + long sum = 0; for (int quality : qualities) { sum += quality; } diff --git a/src/main/java/net/tfminecraft/cooking/utils/StackNormalizer.java b/src/main/java/net/tfminecraft/cooking/utils/StackNormalizer.java index 6df18cb..bd9d839 100644 --- a/src/main/java/net/tfminecraft/cooking/utils/StackNormalizer.java +++ b/src/main/java/net/tfminecraft/cooking/utils/StackNormalizer.java @@ -24,9 +24,6 @@ public static boolean needsNormalize(FoodItem fi) { continue; } TagStep step = track.getCurrentStep(); - if (step == null) { - continue; - } if (track.getValue() != (int) step.getRequiredValue()) { return true; } @@ -46,9 +43,6 @@ public static void normalize(FoodItem fi) { continue; } TagStep step = track.getCurrentStep(); - if (step == null) { - continue; - } track.forceSetValue((int) step.getRequiredValue()); } fi.clearAgeRemainders(); diff --git a/src/main/java/net/tfminecraft/cooking/utils/StationAddonRules.java b/src/main/java/net/tfminecraft/cooking/utils/StationAddonRules.java index 06b22b0..3094ab6 100644 --- a/src/main/java/net/tfminecraft/cooking/utils/StationAddonRules.java +++ b/src/main/java/net/tfminecraft/cooking/utils/StationAddonRules.java @@ -2,6 +2,7 @@ import java.util.ArrayList; import java.util.List; +import java.util.Locale; import java.util.Map; import org.bukkit.entity.Player; @@ -59,10 +60,11 @@ public static void applySweetTag(FoodItem product, Map slots) if (!hasSweetener(slots)) { return; } - TagTrack track = new TagTrack(TrackLoader.getByString("sweet")); + TagTrack track = TrackLoader.getByString("sweet"); if (track == null) { return; } + track = new TagTrack(track); track.setValue(0); product.addOrModifyTrack(track); } @@ -73,10 +75,7 @@ public static boolean hasDuplicateOrigin(Map slots, FoodItem i return false; } for (FoodItem existing : slots.values()) { - if (existing == null || existing.getOrigin() == null) { - continue; - } - if (existing.getOrigin().equalsIgnoreCase(origin)) { + if (existing != null && existing.getOrigin() != null && existing.getOrigin().equalsIgnoreCase(origin)) { return true; } } @@ -126,7 +125,7 @@ public static AddonProfile classifyAddons(Map slots) { boolean hasGarnish = false; boolean hasSpice = false; for (FoodItem item : addons) { - String category = item.getCategory().toLowerCase(); + String category = item.getCategory().toLowerCase(Locale.ROOT); if (category.equals("garnish")) { hasGarnish = true; } @@ -156,10 +155,11 @@ public static void applyFlavourfulTag(FoodItem product, Map sl if (product == null || !hasValuable(slots)) { return; } - TagTrack track = new TagTrack(TrackLoader.getByString("flavourful")); + TagTrack track = TrackLoader.getByString("flavourful"); if (track == null) { return; } + track = new TagTrack(track); track.setValue(0); product.addOrModifyTrack(track); } @@ -167,14 +167,16 @@ public static void applyFlavourfulTag(FoodItem product, Map sl public static void applyAddonTags(FoodItem product, Map slots) { AddonProfile profile = classifyAddons(slots); if (profile == AddonProfile.AROMATIC) { - TagTrack track = new TagTrack(TrackLoader.getByString("aromatic")); + TagTrack track = TrackLoader.getByString("aromatic"); if (track != null) { + track = new TagTrack(track); track.setValue(0); product.addOrModifyTrack(track); } } else if (profile == AddonProfile.ROUNDED) { - TagTrack track = new TagTrack(TrackLoader.getByString("rounded")); + TagTrack track = TrackLoader.getByString("rounded"); if (track != null) { + track = new TagTrack(track); track.setValue(0); product.addOrModifyTrack(track); } @@ -196,10 +198,11 @@ public static void applySeasoningTag(FoodItem product, Map slo if (count == 0) { return; } - TagTrack track = new TagTrack(TrackLoader.getByString("seasoning")); + TagTrack track = TrackLoader.getByString("seasoning"); if (track == null) { return; } + track = new TagTrack(track); track.setValue(count >= 2 ? 1 : 0); product.addOrModifyTrack(track); } diff --git a/src/test/java/net/tfminecraft/cooking/CookingLifecycleTest.java b/src/test/java/net/tfminecraft/cooking/CookingLifecycleTest.java new file mode 100644 index 0000000..a31c511 --- /dev/null +++ b/src/test/java/net/tfminecraft/cooking/CookingLifecycleTest.java @@ -0,0 +1,275 @@ +package net.tfminecraft.cooking; + +import static org.junit.jupiter.api.Assertions.*; +import static org.mockito.ArgumentMatchers.*; +import static org.mockito.Mockito.*; + +import java.io.File; +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.ArrayList; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; + +import net.tfminecraft.cooking.baking.*; +import net.tfminecraft.cooking.churn.*; +import net.tfminecraft.cooking.crops.CropCustomCropsBridge; +import net.tfminecraft.cooking.crops.CropsLoader; +import net.tfminecraft.cooking.farming.FarmingLoader; +import net.tfminecraft.cooking.fishing.CustomFishingBridge; +import net.tfminecraft.cooking.fishing.CustomFishingCatalog; +import net.tfminecraft.cooking.fishing.LegacyFishScan; +import net.tfminecraft.cooking.hook.MeatHookHandler; +import net.tfminecraft.cooking.husbandry.*; +import net.tfminecraft.cooking.item.CookingPathHandler; +import net.tfminecraft.cooking.liquid.*; +import net.tfminecraft.cooking.loader.*; +import net.tfminecraft.cooking.manager.*; +import net.tfminecraft.cooking.milling.*; +import net.tfminecraft.cooking.mixing.MixingBowlHandler; +import net.tfminecraft.cooking.nutrition.NutritionDrainTask; +import net.tfminecraft.cooking.nutrition.SaturationGuard; +import net.tfminecraft.cooking.oven.*; +import net.tfminecraft.cooking.sausagemaker.SausageMakerHandler; +import net.tfminecraft.cooking.trough.*; +import net.tfminecraft.tlibs.TLibs; +import net.tfminecraft.tlibs.itemscan.ItemScanService; +import net.tfminecraft.tlibs.objects.api.ItemAPI; +import org.bukkit.configuration.file.YamlConfiguration; +import org.bukkit.event.HandlerList; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.io.TempDir; +import org.mockbukkit.mockbukkit.MockBukkit; +import org.mockbukkit.mockbukkit.ServerMock; +import org.mockito.MockedConstruction; +import org.mockito.MockedStatic; + +class CookingLifecycleTest { + private final Map, MockedConstruction> constructions = new LinkedHashMap<>(); + private final List> statics = new ArrayList<>(); + private ServerMock server; + private Cooking previousPlugin; + private ItemAPI items; + private HusbandryRepository repository; + private MockedStatic repositories; + private MockedStatic scans; + private MockedStatic saturation; + private MockedStatic nutrition; + private MockedStatic husbandry; + private MockedStatic husbandryTicks; + private MockedStatic locator; + private MockedStatic liquidAging; + private MockedStatic churnAging; + @TempDir Path directory; + + @BeforeEach void setUp() { + server = MockBukkit.mock(); + previousPlugin = Cooking.plugin; + for (Class type : List.of(ModelLoader.class, FoodLoader.class, CraftingStationLoader.class, + TrackLoader.class, ConfigLoader.class, ConversionLoader.class, CarveSequenceLoader.class, + NamingLoader.class, QualityConfigLoader.class, CompositionConfigLoader.class, + PermissionEffectsLoader.class, FarmingLoader.class, CropsLoader.class, HusbandryLoader.class, + TagManager.class, LegacyFishScan.class, CookingManager.class, PlateManager.class, + MixingBowlHandler.class, ButterChurnHandler.class, LiquidContainerHandler.class, + MeatHookHandler.class, SausageMakerHandler.class, BakingTrayLoader.class, + MillingRecipeLoader.class, BakingTrayHandler.class, MillingStoneHandler.class, + TroughHandler.class, TroughAging.class, BakingTrayAging.class, OvenCavityManager.class, + OvenBurnManager.class, OvenLifecycleHandler.class, OvenHandler.class, CraftingManager.class)) { + constructions.put(type, mockConstruction(type)); + } + items = mock(ItemAPI.class); + staticMock(TLibs.class).when(TLibs::getItemAPI).thenReturn(items); + repository = mock(HusbandryRepository.class); + repositories = staticMock(HusbandryRepository.class); + repositories.when(() -> HusbandryRepository.open(any(File.class))).thenReturn(repository); + scans = staticMock(ItemScanService.class); + saturation = staticMock(SaturationGuard.class); + nutrition = staticMock(NutritionDrainTask.class); + husbandry = staticMock(HusbandryLifecycleListener.class); + husbandryTicks = staticMock(HusbandryTickTask.class); + locator = staticMock(HusbandryLocator.class); + liquidAging = staticMock(LiquidContainerAging.class); + churnAging = staticMock(ButterChurnAging.class); + staticMock(CustomFishingCatalog.class); + staticMock(CropCustomCropsBridge.class); + staticMock(CustomFishingBridge.class); + } + + @AfterEach void tearDown() { + MockBukkit.unmock(); + for (int i = statics.size() - 1; i >= 0; i--) statics.get(i).close(); + List> scopes = new ArrayList<>(constructions.values()); + for (int i = scopes.size() - 1; i >= 0; i--) scopes.get(i).close(); + Cooking.plugin = previousPlugin; + } + + @Test void enablingCreatesDefaultResourcesLoadsEveryConfigAndRegistersCommandsAndListeners() throws Exception { + Cooking plugin = loadPlugin(); + + assertSame(plugin, Cooking.plugin); + assertSame(repository, plugin.getHusbandryRepository()); + assertSame(built(OvenBurnManager.class), plugin.getOvenBurnManager()); + assertTrue(new File(plugin.getDataFolder(), "Data").isDirectory()); + for (String name : List.of("models.yml", "types.yml", "crafting-stations.yml", "baking-trays.yml", + "milling-recipes.yml", "cookware.yml", "tags.yml", "naming.yml", "config.yml", "conversions.yml", + "carve-sequences.yml", "quality.yml", "composition.yml", "permission_effects.yml", "farming.yml", + "crops.yml", "husbandry.yml", "custom-fishing.yml")) { + Path resource = plugin.getDataFolder().toPath().resolve(name); + assertTrue(Files.isRegularFile(resource), name); + assertFalse(Files.readString(resource).isBlank(), name); + new YamlConfiguration().load(resource.toFile()); + } + assertConfigLoaded(plugin, ConfigLoader.class, "config.yml"); + assertConfigLoaded(plugin, ModelLoader.class, "models.yml"); + assertConfigLoaded(plugin, FoodLoader.class, "types.yml"); + assertConfigLoaded(plugin, BakingTrayLoader.class, "baking-trays.yml"); + assertConfigLoaded(plugin, MillingRecipeLoader.class, "milling-recipes.yml"); + assertConfigLoaded(plugin, CraftingStationLoader.class, "crafting-stations.yml"); + assertConfigLoaded(plugin, TrackLoader.class, "tags.yml"); + assertConfigLoaded(plugin, NamingLoader.class, "naming.yml"); + assertConfigLoaded(plugin, QualityConfigLoader.class, "quality.yml"); + assertConfigLoaded(plugin, CompositionConfigLoader.class, "composition.yml"); + assertConfigLoaded(plugin, PermissionEffectsLoader.class, "permission_effects.yml"); + assertConfigLoaded(plugin, ConversionLoader.class, "conversions.yml"); + assertConfigLoaded(plugin, FarmingLoader.class, "farming.yml"); + assertConfigLoaded(plugin, CropsLoader.class, "crops.yml"); + assertConfigLoaded(plugin, HusbandryLoader.class, "husbandry.yml"); + assertConfigLoaded(plugin, CarveSequenceLoader.class, "carve-sequences.yml"); + repositories.verify(() -> HusbandryRepository.open(new File(plugin.getDataFolder(), "Data/husbandry.db"))); + verify(items).registerPathHandler("c", CookingPathHandler.INSTANCE); + assertInstanceOf(CommandManager.class, plugin.getCommand("cooking").getExecutor()); + assertSame(plugin.getCommand("cooking").getExecutor(), plugin.getCommand("cooking").getTabCompleter()); + assertInstanceOf(HusbandryAnimalsCommand.class, plugin.getCommand("animals").getExecutor()); + assertSame(plugin.getCommand("animals").getExecutor(), plugin.getCommand("animals").getTabCompleter()); + var caller = server.addPlayer(); + assertFalse(plugin.getCommand("cooking").testPermissionSilent(caller)); + caller.setOp(true); + assertTrue(plugin.getCommand("cooking").testPermissionSilent(caller)); + assertTrue(plugin.getCommand("animals").getAliases().contains("livestock")); + assertFalse(HandlerList.getRegisteredListeners(plugin).isEmpty()); + verify(built(CookingManager.class)).start(); + verify(built(PlateManager.class), never()).start(); + } + + @Test void nextTickResumesLoadedStateAndShutdownReleasesScansTasksDatabaseAndPathHandler() { + ItemScanService scan = mock(ItemScanService.class); + scans.when(ItemScanService::get).thenReturn(scan); + Cooking plugin = loadPlugin(); + verify(scan).subscribe(built(TagManager.class)); + verify(scan).subscribe(built(LegacyFishScan.class)); + + server.getScheduler().performOneTick(); + + verify(built(PlateManager.class)).start(); + verify(built(MeatHookHandler.class)).start(); + verify(built(BakingTrayAging.class)).start(); + verify(built(TroughAging.class)).start(); + verify(built(OvenCavityManager.class)).start(); + verify(built(OvenLifecycleHandler.class)).resumeLoadedOvens(); + verify(built(CraftingManager.class)).resumeLoadedStations(); + verify(built(CookingManager.class)).resumeLoadedStations(); + verify(built(ButterChurnHandler.class)).resumeLoadedChurns(); + verify(built(LiquidContainerHandler.class)).resumeLoadedContainers(); + verify(built(MeatHookHandler.class)).resumeLoadedHooks(); + saturation.verify(SaturationGuard::start); + nutrition.verify(NutritionDrainTask::start); + husbandry.verify(HusbandryLifecycleListener::resumeLoadedWorlds); + husbandryTicks.verify(HusbandryTickTask::start); + locator.verify(HusbandryLocator::scanUnloaded); + + server.getPluginManager().disablePlugin(plugin); + + saturation.verify(SaturationGuard::stop); + nutrition.verify(NutritionDrainTask::stop); + husbandryTicks.verify(HusbandryTickTask::stop); + husbandry.verify(HusbandryLifecycleListener::flushLoadedForDisable); + verify(built(OvenBurnManager.class)).stopAll(); + liquidAging.verify(LiquidContainerAging::stopAll); + churnAging.verify(ButterChurnAging::stopAll); + verify(scan).unsubscribe(built(TagManager.class)); + verify(scan).unsubscribe(built(LegacyFishScan.class)); + verify(repository).close(); + assertNull(plugin.getHusbandryRepository()); + verify(items).unregisterPathHandler("c"); + plugin.onDisable(); + verify(repository, times(1)).close(); + } + + @Test void reloadRefreshesConfigStationsAndPeriodicNutritionAndHusbandryTasks() { + Cooking plugin = loadPlugin(); + server.getScheduler().performOneTick(); + + plugin.reloadAll(); + + verify(built(ConfigLoader.class), times(2)).loadConfig(new File(plugin.getDataFolder(), "config.yml")); + verify(built(CraftingManager.class)).rebuildStations(); + husbandry.verify(HusbandryLifecycleListener::applyStatsRevision, times(2)); + nutrition.verify(NutritionDrainTask::stop); + nutrition.verify(NutritionDrainTask::start, times(2)); + husbandryTicks.verify(HusbandryTickTask::stop); + husbandryTicks.verify(HusbandryTickTask::start, times(2)); + } + + @Test void configGenerationPreservesExistingUserFilesAndFolderCreationIsIdempotent() throws Exception { + Cooking plugin = loadPlugin(); + Path config = plugin.getDataFolder().toPath().resolve("config.yml"); + Files.writeString(config, "custom-setting: keep-me\n"); + plugin.createConfigs(); + assertEquals("custom-setting: keep-me\n", Files.readString(config)); + + Cooking folders = mock(Cooking.class, CALLS_REAL_METHODS); + File root = directory.resolve("fresh-plugin-data").toFile(); + doReturn(root).when(folders).getDataFolder(); + folders.createFolders(); + assertTrue(root.isDirectory()); + assertTrue(new File(root, "Data").isDirectory()); + folders.createFolders(); + assertTrue(new File(root, "Data").isDirectory()); + } + + @Test void databaseOpenFailureIsPropagatedInsteadOfEnablingAPartialPlugin() { + IllegalStateException failure = new IllegalStateException("database unavailable"); + repositories.when(() -> HusbandryRepository.open(any(File.class))).thenThrow(failure); + Throwable error = assertThrows(RuntimeException.class, this::loadPlugin); + while (error.getCause() != null && error != failure) error = error.getCause(); + assertSame(failure, error); + assertNotNull(Cooking.plugin); + assertNull(Cooking.plugin.getHusbandryRepository()); + verify(built(CookingManager.class), never()).start(); + } + + @Test void databaseCloseFailureStillClearsTheRepositoryAndUnregistersThePath() { + Cooking plugin = loadPlugin(); + doThrow(new IllegalStateException("close failed")).when(repository).close(); + + assertDoesNotThrow(() -> server.getPluginManager().disablePlugin(plugin)); + + assertNull(plugin.getHusbandryRepository()); + verify(items).unregisterPathHandler("c"); + } + + private Cooking loadPlugin() { + MockBukkit.createMockPlugin("TLibs"); + MockBukkit.createMockPlugin("InteractibleFurniture"); + return MockBukkit.load(Cooking.class); + } + + private MockedStatic staticMock(Class type) { + MockedStatic value = mockStatic(type); statics.add(value); return value; + } + + private T built(Class type) { + List values = constructions.get(type).constructed(); + assertEquals(1, values.size(), type.getSimpleName()); + return type.cast(values.getFirst()); + } + + private void assertConfigLoaded(Cooking plugin, Class type, String name) { + assertTrue(mockingDetails(built(type)).getInvocations().stream() + .anyMatch(call -> call.getArguments().length == 1 + && new File(plugin.getDataFolder(), name).equals(call.getArgument(0))), name); + } +} diff --git a/src/test/java/net/tfminecraft/cooking/baking/BakingTrayCoverageTest.java b/src/test/java/net/tfminecraft/cooking/baking/BakingTrayCoverageTest.java new file mode 100644 index 0000000..45c0d4a --- /dev/null +++ b/src/test/java/net/tfminecraft/cooking/baking/BakingTrayCoverageTest.java @@ -0,0 +1,496 @@ +package net.tfminecraft.cooking.baking; + +import static org.junit.jupiter.api.Assertions.*; +import static org.mockito.ArgumentMatchers.*; +import static org.mockito.Mockito.*; + +import java.io.ByteArrayOutputStream; +import java.io.PrintStream; +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.ArrayList; +import java.util.HashSet; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; +import java.util.Set; +import java.util.UUID; + +import org.bukkit.Location; +import org.bukkit.Material; +import org.bukkit.inventory.ItemStack; +import org.bukkit.util.Vector; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.io.TempDir; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.ValueSource; +import org.mockito.MockedStatic; + +import net.tfminecraft.cooking.Cooking; +import net.tfminecraft.cooking.heat.HeatSources; +import net.tfminecraft.cooking.item.FoodItem; +import net.tfminecraft.cooking.item.IngredientLineage; +import net.tfminecraft.cooking.item.tag.TagTrack; +import net.tfminecraft.cooking.loader.TrackLoader; +import net.tfminecraft.cooking.sausagemaker.SausageMakerCoverageTest.Station; +import net.tfminecraft.cooking.sausagemaker.SausageMakerCoverageTest.Workshop; +import net.tfminecraft.cooking.utils.InventoryAdder; +import net.tfminecraft.cooking.utils.ItemBuilder; +import net.tfminecraft.cooking.utils.ItemUpdater; +import net.tfminecraft.cooking.utils.WarmthUtils; +import net.tfminecraft.interactiblefurniture.events.FurnitureInteractEvent; +import net.tfminecraft.interactiblefurniture.furniture.Furniture; +import net.tfminecraft.interactiblefurniture.furniture.PlacedFurnitureSlot; +import net.tfminecraft.interactiblefurniture.furniture.PlacedSlot; + +public class BakingTrayCoverageTest { + Bakery bakery; + Workshop env; + Station tray; + BakingTrayHandler handler; + @BeforeEach void open() { bakery = new Bakery(); env = bakery.env; tray = bakery.tray; handler = new BakingTrayHandler(); } + @AfterEach void close() { bakery.close(); } + + @Test + void recipesExposeMoldsFillAndBakingSettingsAndRegistryMatchesIgnoringCase() { + BakingTrayRecipe recipe = bakery.recipe; + assertEquals("bread", recipe.getId()); assertEquals("bread_tray", recipe.getFurnitureId()); + assertEquals(List.of("left", "right"), recipe.getMoldIds()); + assertEquals(List.of("left_a", "left_b", "right_a", "right_b"), recipe.getAllSlotIds()); + assertEquals("left", recipe.getMoldForSlot("LEFT_B")); assertNull(recipe.getMoldForSlot("missing")); + assertEquals(List.of("right_a", "right_b"), recipe.getMoldSlotIds("RIGHT")); assertTrue(recipe.getMoldSlotIds("missing").isEmpty()); + assertEquals(2, recipe.getMolds().size()); + BakingTrayFill fill = recipe.getFill(); + assertEquals("food:dough", fill.getInput()); assertEquals("dough", fill.getInputFood()); assertEquals(2, fill.getOutputsPerMold()); + assertEquals("bread", fill.getFood()); assertEquals("grain", fill.getCategory()); assertEquals("cooked.0:freshness.0", fill.getTags()); + assertEquals("grain(type=bread;tags=cooked.0:freshness.0)", fill.buildSpawnString()); + assertEquals(2, recipe.getBake().getCookSeconds()); assertEquals(4, recipe.getBake().getBurnSeconds()); assertFalse(recipe.getBake().isSync()); + assertSame(recipe, BakingTrayRegistry.getById("BREAD")); assertSame(recipe, BakingTrayRegistry.getByFurniture(tray.furniture)); + assertTrue(BakingTrayRegistry.isTray(tray.furniture)); assertFalse(BakingTrayRegistry.isTray(bakery.station("chair").furniture)); + assertNull(BakingTrayRegistry.getById(null)); assertNull(BakingTrayRegistry.getByFurniture(null)); + assertEquals(1, BakingTrayRegistry.getAll().size()); + } + + @Test + void loaderReadsDefaultsAndExplicitBakingAndSkipsIncompleteEntries(@TempDir Path directory) throws Exception { + Path file = directory.resolve("baking.yml"); + Files.writeString(file, """ + scalar: ignored + missing-furniture: + category: grain + blank-furniture: + furniture: ' ' + missing-molds: + furniture: tray + empty-molds: + furniture: tray + molds: + empty: [] + missing-fill: + furniture: tray + molds: + mold: [a] + missing-input: + furniture: tray + molds: + mold: [a] + fill: + food: bread + missing-output: + furniture: tray + molds: + mold: [a] + fill: + input: food:dough + defaults: + furniture: default_tray + molds: + mold: [a, b] + fill: + input: food:dough + food: bread + explicit: + furniture: custom_tray + category: baked + molds: + mold: [a] + fill: + input: food:dough + input-food: dough + food: roll + outputs-per-mold: 1 + tags: cooked.0 + bake: + cook-seconds: 7 + burn-seconds: 14 + sync: false + """); + new BakingTrayLoader().load(file.toFile()); + assertEquals(2, BakingTrayRegistry.getAll().size()); + BakingTrayRecipe defaults = BakingTrayRegistry.getById("defaults"); + assertEquals(45, defaults.getBake().getCookSeconds()); assertEquals(90, defaults.getBake().getBurnSeconds()); assertTrue(defaults.getBake().isSync()); + assertEquals("grain", defaults.getFill().getCategory()); assertEquals(2, defaults.getFill().getOutputsPerMold()); + BakingTrayRecipe explicit = BakingTrayRegistry.getById("explicit"); + assertEquals(7, explicit.getBake().getCookSeconds()); assertEquals(14, explicit.getBake().getBurnSeconds()); assertFalse(explicit.getBake().isSync()); + assertEquals("dough", explicit.getFill().getInputFood()); assertEquals("roll", explicit.getFill().getFood()); + assertEquals("baked", explicit.getFill().getCategory()); assertEquals(1, explicit.getFill().getOutputsPerMold()); + } + + @Test + void unreadableRecipeFilesRemoveOldDefinitionsAndReportTheFailure(@TempDir Path directory) { + PrintStream before = System.err; ByteArrayOutputStream error = new ByteArrayOutputStream(); + try (PrintStream capture = new PrintStream(error)) { + System.setErr(capture); new BakingTrayLoader().load(directory.resolve("missing.yml").toFile()); + } finally { System.setErr(before); } + assertTrue(BakingTrayRegistry.getAll().isEmpty()); assertTrue(error.toString().contains("missing.yml")); + } + + @Test + void bakingClocksAreIndependentAndCleanupPreservesOtherFurnitureVariables() { + assertEquals(0, BakingTrayState.getSlotElapsed(tray.furniture, "left_a")); + tray.variables.put("bake.slot.left_a.elapsed", "invalid"); + assertEquals(0, BakingTrayState.getSlotElapsed(tray.furniture, "left_a")); + BakingTrayState.setSlotElapsed(tray.furniture, "left_a", 4); BakingTrayState.incrementSlotElapsed(tray.furniture, "left_a"); + BakingTrayState.setSlotElapsed(tray.furniture, "right_a", 2); + assertEquals(5, BakingTrayState.getSlotElapsed(tray.furniture, "left_a")); assertEquals(2, BakingTrayState.getSlotElapsed(tray.furniture, "right_a")); + BakingTrayState.clearSlots(tray.furniture, List.of("left_a")); assertEquals(0, BakingTrayState.getSlotElapsed(tray.furniture, "left_a")); + for (String old : List.of("bake.elapsed", "bake.cook_applied", "bake.burn_applied")) tray.variables.put(old, 1); + tray.variables.put("unrelated", 3); BakingTrayState.clear(tray.furniture); + assertEquals(Map.of("unrelated", 3), tray.variables); + } + + @Test + void doughTransformationPreservesIngredientsLineageAndAgingButResetsCookingAndRising() { + FoodItem dough = bakery.food("dough", null); dough.setOrigin("Rye"); dough.setCategory("baked"); + dough.setLineage(IngredientLineage.ofMain("Rye")); dough.addIngredient("Walnut"); + bakery.track(dough, "cooked", 2); bakery.track(dough, "rising", 1); bakery.track(dough, "freshness", 4); + BakingTrayFill fill = new BakingTrayFill("food:dough", "dough", 2, "bread", "cooked.0:freshness.6:unknown.3:bad:cooked.nope", "grain"); + FoodItem loaf = BakingTrayTransform.doughToLoaf(dough, fill); + assertEquals("bread", loaf.getId()); assertEquals("baked", loaf.getCategory()); assertEquals("Rye", loaf.getOrigin()); + assertEquals(dough.getEffectiveQuality(), loaf.getQualityMin()); assertEquals(dough.getLineage(), loaf.getLineage()); + assertTrue(loaf.getIngredients().contains("Walnut")); assertEquals(0, loaf.getTagTrack("cooked").getValue()); + assertEquals(6, loaf.getTagTrack("freshness").getValue()); assertFalse(loaf.hasTagTrack("rising")); + assertEquals(2, dough.getTagTrack("cooked").getValue()); + assertNull(BakingTrayTransform.doughToLoaf(null, fill)); assertNull(BakingTrayTransform.doughToLoaf(dough, null)); + env.templates.remove("bread"); assertNull(BakingTrayTransform.doughToLoaf(dough, fill)); + } + + @ParameterizedTest + @ValueSource(strings = {"", "NULL"}) + void missingDoughCategoryAndTagSpecUseTheFillAndTemplateDefaults(String value) { + FoodItem dough = bakery.food("dough", null); dough.setCategory(value.equals("NULL") ? null : value); dough.setOrigin(null); + BakingTrayFill fill = new BakingTrayFill(null, "dough", 2, "bread", value.equals("NULL") ? null : value, "grain"); + FoodItem loaf = BakingTrayTransform.doughToLoaf(dough, fill); + assertEquals("grain", loaf.getCategory()); assertEquals("Wheat", loaf.getOrigin()); assertFalse(loaf.hasTagTrack("cooked")); + } + + @Test + void slotSelectionUsesClickDistanceAndFindsTheNextAvailableSlotOfTheRequestedKind() { + env.display(tray.id); + assertNull(BakingTraySlots.findClosestSlot(null, bakery.recipe, new Vector())); + assertNull(BakingTraySlots.findClosestSlot(tray.furniture, null, new Vector())); + assertEquals("right_b", BakingTraySlots.findClosestSlot(tray.furniture, bakery.recipe, bakery.click(3))); + tray.place("right_b", bakery.loaf(0, 1)); tray.place("left_a", bakery.loaf(0, 1)); + assertEquals("right_b", BakingTraySlots.findClosestOccupiedSlot(tray.furniture, bakery.recipe, bakery.click(3))); + assertEquals("right_a", BakingTraySlots.findClosestEmptySlot(tray.furniture, bakery.recipe, bakery.click(3))); + assertEquals("left_a", BakingTraySlots.findClosestOccupiedSlot(tray.furniture, bakery.recipe, bakery.click(1))); + assertEquals("left_b", BakingTraySlots.findClosestEmptySlot(tray.furniture, bakery.recipe, bakery.click(1))); + assertFalse(BakingTraySlots.isMoldEmpty(tray.furniture, bakery.recipe, "left")); + assertNull(BakingTraySlots.findFillableMold(tray.furniture, bakery.recipe, "right")); + tray.active.clear(); + assertEquals("right", BakingTraySlots.findFillableMold(tray.furniture, bakery.recipe, "right")); + assertEquals("left", BakingTraySlots.findFirstFillableMold(tray.furniture, bakery.recipe)); + } + + @Test + void selectionFallbacksHandleMissingDisplaysDefinitionsAndEmptyMolds() { + assertEquals("left_a", BakingTraySlots.findClosestSlot(tray.furniture, bakery.recipe, null)); + assertEquals("left_a", BakingTraySlots.findClosestSlot(tray.furniture, bakery.recipe, bakery.click(3))); + assertNull(BakingTraySlots.findClosestOccupiedSlot(tray.furniture, bakery.recipe, null)); + tray.place("left_a", new ItemStack(Material.AIR)); tray.place("left_b", null); + assertEquals("left", BakingTraySlots.findFirstFillableMold(tray.furniture, bakery.recipe)); + for (String slot : bakery.recipe.getAllSlotIds()) tray.place(slot, bakery.loaf(0, 1)); + assertEquals("left_a", BakingTraySlots.findClosestOccupiedSlot(tray.furniture, bakery.recipe, null)); + assertNull(BakingTraySlots.findClosestEmptySlot(tray.furniture, bakery.recipe, null)); + assertNull(BakingTraySlots.findFirstFillableMold(tray.furniture, bakery.recipe)); + env.display(tray.id); tray.definitions.clear(); + assertEquals("left_a", BakingTraySlots.findClosestSlot(tray.furniture, bakery.recipe, bakery.click(0))); + assertEquals("left_a", BakingTraySlots.findClosestSlot(tray.furniture, bakery.recipe, null)); + tray.active.clear(); + assertNull(BakingTraySlots.findClosestOccupiedSlot(tray.furniture, bakery.recipe, bakery.click(0))); + when(tray.furniture.getType()).thenReturn(null); + assertEquals("left_a", BakingTraySlots.findClosestEmptySlot(tray.furniture, bakery.recipe, bakery.click(0))); + BakingTrayRecipe noSlots = bakery.recipe(Map.of(), bakery.recipe.getFill()); + assertNull(BakingTraySlots.findClosestSlot(tray.furniture, noSlots, null)); + BakingTrayRecipe emptyMold = bakery.recipe(Map.of("empty", List.of()), bakery.recipe.getFill()); + assertNull(BakingTraySlots.findClosestSlot(tray.furniture, emptyMold, null)); + } + + @Test + void slotSelectionSkipsMissingDefinitionsAndUsesAnotherEmptyMold() { + env.display(tray.id); + tray.place("left_a", bakery.loaf(0, 1)); tray.place("right_b", bakery.loaf(0, 1)); + tray.definitions.remove("right_b"); + assertEquals("left_a", BakingTraySlots.findClosestOccupiedSlot(tray.furniture, bakery.recipe, bakery.click(1))); + tray.definitions.remove("left_b"); + assertEquals("right_a", BakingTraySlots.findClosestEmptySlot(tray.furniture, bakery.recipe, bakery.click(0))); + tray.active.remove("right_b"); + assertEquals("right", BakingTraySlots.findFillableMold(tray.furniture, bakery.recipe, "left")); + } + + @Test + void recipesWithoutAnInputMatcherDoNotTreatUnrelatedItemsAsDough() { + bakery.register(bakery.recipe(bakery.recipe.getMolds(), new BakingTrayFill(null, null, 2, "bread", null, "grain"))); + ItemStack hand = env.hold(new ItemStack(Material.STICK)); + var event = bakery.interact(null); handler.onInteract(event); + assertFalse(event.isCancelled()); assertEquals(1, hand.getAmount()); assertTrue(tray.active.isEmpty()); + } + + @Test + void fillingAMoldConsumesOneDoughBuildsIndividualLoavesAndClearsTheirClocks() { + ItemStack hand = env.hold(env.stack(bakery.food("dough", null), Material.WHEAT, 2)); + BakingTrayState.setSlotElapsed(tray.furniture, "left_a", 7); BakingTrayState.setSlotElapsed(tray.furniture, "right_a", 9); + var event = bakery.interact(null); handler.onInteract(event); + assertTrue(event.isCancelled()); assertEquals(1, hand.getAmount()); + assertEquals(2, tray.active.size()); + ItemStack first = tray.active.get("left_a").getCurrentItem(), second = tray.active.get("left_b").getCurrentItem(); + assertNotSame(first, second); assertEquals(1, first.getAmount()); assertEquals("bread", env.resolve(first).getId()); + assertEquals(0, env.resolve(first).getTagTrack("cooked").getValue()); + assertEquals(0, BakingTrayState.getSlotElapsed(tray.furniture, "left_a")); + assertEquals(9, BakingTrayState.getSlotElapsed(tray.furniture, "right_a")); + verify(env.manager).markDirty(tray.furniture); + handler.onInteract(bakery.interact(null)); + assertEquals(4, tray.active.size()); + env.hold(env.stack(bakery.food("dough", null), Material.WHEAT, 1)); handler.onInteract(bakery.interact(null)); + assertTrue(env.messages.contains("§cThe tray molds are full.")); + } + + @ParameterizedTest + @ValueSource(strings = {"unreadable-dough", "missing-template", "null-loaf", "nonfood-loaf"}) + void fillingRejectsUnreadableOrUnbuildableFoodsWithoutTakingTheDough(String failure) { + ItemStack plain = new ItemStack(Material.WHEAT, 2); + ItemStack hand = env.hold(failure.equals("unreadable-dough") ? plain : env.stack(bakery.food("dough", null), Material.WHEAT, 2)); + if (failure.equals("missing-template")) env.templates.remove("bread"); + if (failure.equals("null-loaf")) env.builder.when(() -> ItemBuilder.buildComposedWithQuality(any(), anyInt())).thenReturn(null); + ItemStack stone = new ItemStack(Material.STONE); + if (failure.equals("nonfood-loaf")) env.builder.when(() -> ItemBuilder.buildComposedWithQuality(any(), anyInt())).thenReturn(stone); + var event = bakery.interact(null); handler.onInteract(event); + assertTrue(event.isCancelled()); assertEquals(2, hand.getAmount()); assertTrue(tray.active.isEmpty()); + } + + @Test + void attachedTraysRejectNewDoughButAllowSingleLoavesAndRespectSneaking() { + when(tray.furniture.isAttached()).thenReturn(true); + ItemStack dough = env.hold(env.stack(bakery.food("dough", null), Material.WHEAT, 1)); + var fill = bakery.interact(null); handler.onInteract(fill); assertTrue(fill.isCancelled()); assertEquals(1, dough.getAmount()); + assertTrue(env.messages.contains("§cRemove the tray from the oven before adding dough.")); + env.hold(bakery.loaf(0, 2)); + var place = bakery.interact(null); handler.onInteract(place); assertTrue(place.isCancelled()); + assertEquals(1, env.player.getInventory().getItemInMainHand().getAmount()); assertEquals(1, tray.active.size()); + env.hold(null); when(env.player.isSneaking()).thenReturn(true); + var sneaking = bakery.interact(null); handler.onInteract(sneaking); assertFalse(sneaking.isCancelled()); assertEquals(1, tray.active.size()); + when(env.player.isSneaking()).thenReturn(false); + var take = bakery.interact(null); handler.onInteract(take); assertTrue(take.isCancelled()); assertTrue(tray.active.isEmpty()); + env.hold(new ItemStack(Material.STICK)); handler.onInteract(bakery.interact(null)); assertTrue(tray.active.isEmpty()); + } + + @Test + void looseLoavesFillIndividualSlotsAndFullTraysKeepTheHeldItem() { + ItemStack hand = env.hold(bakery.loaf(0, 5)); + for (int n = 0; n < 4; n++) handler.onInteract(bakery.interact(null)); + assertEquals(1, hand.getAmount()); assertEquals(4, tray.active.size()); + handler.onInteract(bakery.interact(null)); assertEquals(1, hand.getAmount()); assertTrue(env.messages.contains("§cThe tray is full.")); + Station other = bakery.station("chair"); var ignored = other.interact(); handler.onInteract(ignored); assertFalse(ignored.isCancelled()); + env.hold(env.stack(bakery.food("other", null), Material.PAPER, 1)); + var unrelated = bakery.interact(null); handler.onInteract(unrelated); assertFalse(unrelated.isCancelled()); + } + + @Test + void takingLoavesReturnsOneAtATimeAndHeatedBreadGetsWarmth() { + ItemStack raw = bakery.loaf(0, 2); tray.place("left_a", raw); BakingTrayState.setSlotElapsed(tray.furniture, "left_a", 1); + var first = bakery.interact(null); handler.onInteract(first); assertTrue(first.isCancelled()); + assertEquals(1, raw.getAmount()); assertEquals(1, env.player.getInventory().getItemInMainHand().getAmount()); + assertEquals(1, BakingTrayState.getSlotElapsed(tray.furniture, "left_a")); + env.hold(null); handler.onInteract(bakery.interact(null)); assertFalse(tray.active.containsKey("left_a")); + assertEquals(0, BakingTrayState.getSlotElapsed(tray.furniture, "left_a")); + env.hold(null); ItemStack cooked = bakery.loaf(1, 1); tray.place("left_b", cooked); + try (MockedStatic delivery = mockStatic(InventoryAdder.class)) { + delivery.when(() -> InventoryAdder.addItem(eq(env.player), any())).thenAnswer(inv -> inv.getArgument(1)); + handler.onInteract(bakery.interact(null)); + } + assertEquals(1, env.dropped.size()); assertTrue(env.resolve(env.dropped.get(0)).hasTagTrack("warmth")); + assertFalse(tray.active.containsKey("left_b")); + } + + @Test + void takingSkipsUnrecognizedFoodFailedUpdatesAndSneakingWithoutMutation() { + var empty = bakery.interact(null); handler.onInteract(empty); assertFalse(empty.isCancelled()); + when(env.player.isSneaking()).thenReturn(true); handler.onInteract(bakery.interact(null)); + when(env.player.isSneaking()).thenReturn(false); + PlacedSlot plain = tray.place("left_a", new ItemStack(Material.STONE)); handler.onInteract(bakery.interact(null)); + verify(plain, never()).clearModel(); + ItemStack cooked = bakery.loaf(1, 1); PlacedSlot slot = tray.place("left_a", cooked); + bakery.updater.when(() -> ItemUpdater.applyItemUpdate(any(), any(), any())).thenReturn(null); + var rejected = bakery.interact(null); handler.onInteract(rejected); assertFalse(rejected.isCancelled()); + assertSame(cooked, slot.getCurrentItem()); verify(slot, never()).clearModel(); + } + + @Test + void cookingAndBurningChangeOnlyLoavesAtTheExpectedStage() { + assertFalse(BakingTrayBakeApplier.applyCook(tray.furniture, bakery.recipe)); + tray.place("left_a", bakery.loaf(0, 1)); tray.place("left_b", bakery.loaf(1, 1)); + tray.place("right_a", bakery.loaf(2, 1)); tray.place("right_b", new ItemStack(Material.STONE)); + assertTrue(BakingTrayBakeApplier.applyCook(tray.furniture, bakery.recipe)); + assertEquals(1, env.resolve(tray.active.get("left_a").getCurrentItem()).getTagTrack("cooked").getValue()); + assertFalse(BakingTrayBakeApplier.applyCook(tray.furniture, bakery.recipe)); + assertTrue(BakingTrayBakeApplier.applyBurn(tray.furniture, bakery.recipe)); + assertEquals(2, env.resolve(tray.active.get("left_b").getCurrentItem()).getTagTrack("cooked").getValue()); + assertFalse(BakingTrayBakeApplier.applyBurnSlot(tray.furniture, "right_a")); + tray.place("left_a", null); assertFalse(BakingTrayBakeApplier.applyCookSlot(tray.furniture, "left_a")); + tray.place("left_a", new ItemStack(Material.AIR)); assertFalse(BakingTrayBakeApplier.applyCookSlot(tray.furniture, "left_a")); + } + + @Test + void firstCookingTransitionPersistsANewCookedTrackOnUntaggedBread() { + FoodItem bread = bakery.food("bread", null); tray.place("left_a", env.stack(bread, Material.BREAD, 1)); + assertTrue(BakingTrayBakeApplier.applyCookSlot(tray.furniture, "left_a")); + FoodItem cooked = env.resolve(tray.active.get("left_a").getCurrentItem()); + assertEquals(1, cooked.getTagTrack("cooked").getValue(), "The stored track, not a detached copy, must be advanced"); + } + + @Test + void unavailableCookingTracksAndFailedRenderingPreserveTheDisplayedLoaf() { + ItemStack bread = env.stack(bakery.food("bread", null), Material.BREAD, 1); PlacedSlot slot = tray.place("left_a", bread); + TrackLoader.oList.removeIf(track -> track.getId().equals("cooked")); + assertFalse(BakingTrayBakeApplier.applyCookSlot(tray.furniture, "left_a")); assertSame(bread, slot.getCurrentItem()); + bakery.track(env.resolve(bread), "freshness", 0); + FoodItem tagged = bakery.food("bread", null); + tagged.addOrModifyTrack(new TagTrack("cooked", false, List.of())); + ItemStack raw = env.stack(tagged, Material.BREAD, 1); slot = tray.place("left_a", raw); + bakery.updater.when(() -> ItemUpdater.applyItemUpdate(any(), any(), any())).thenReturn(null); + assertFalse(BakingTrayBakeApplier.applyCookSlot(tray.furniture, "left_a")); assertSame(raw, slot.getCurrentItem()); + verify(slot, never()).forceModel(any()); + } + + @ParameterizedTest + @ValueSource(strings = {"take", "cook", "age"}) + void airRenderingResultsNeverConsumeOrReplaceBread(String operation) { + ItemStack original = bakery.loaf(operation.equals("cook") ? 0 : 1, 1); + PlacedSlot slot = tray.place("left_a", original); + ItemStack invalid = new ItemStack(Material.AIR); + bakery.updater.when(() -> ItemUpdater.applyItemUpdate(any(), any(), any())).thenReturn(invalid); + bakery.updater.when(() -> ItemUpdater.updateItem(any(), any(), any())).thenReturn(invalid); + if (operation.equals("take")) { + var event = bakery.interact(null); handler.onInteract(event); + assertFalse(event.isCancelled()); + } else if (operation.equals("cook")) { + assertFalse(BakingTrayBakeApplier.applyCookSlot(tray.furniture, "left_a")); + } else { + new BakingTrayAging().start(); env.server.getScheduler().performTicks(20); + } + assertSame(original, slot.getCurrentItem()); assertSame(slot, tray.active.get("left_a")); + verify(slot, never()).clearModel(); verify(slot, never()).forceModel(any()); + verify(env.manager, never()).markDirty(tray.furniture); + } + + @Test + void agingVisitsLooseAndNestedTraysOnceAndIgnoresUnsupportedContents() { + Station consumer = bakery.consumer(); + when(tray.furniture.isAttached()).thenReturn(true); bakery.nest(consumer, "tray", tray.furniture); + bakery.nest(consumer, "empty", null); bakery.nest(consumer, "chair", bakery.station("chair").furniture); + Station loose = bakery.station("bread_tray"); bakery.defineTraySlots(loose); + ItemStack bread = bakery.loaf(0, 1); PlacedSlot nested = tray.place("left_a", bread); + PlacedSlot separate = loose.place("right_a", bakery.loaf(0, 1)); + tray.place("left_b", null); tray.place("right_a", new ItemStack(Material.AIR)); tray.place("right_b", new ItemStack(Material.STONE)); + bakery.placed.put(UUID.randomUUID(), loose.furniture); + new BakingTrayAging().start(); env.server.getScheduler().performTicks(20); + verify(nested, times(1)).forceModel(any()); verify(separate, times(1)).forceModel(any()); + verify(env.manager).markDirty(tray.furniture); verify(env.manager).markDirty(loose.furniture); + bakery.updater.when(() -> ItemUpdater.updateItem(any(), any(), any())).thenReturn(null); + env.server.getScheduler().performTicks(20); + verify(nested, times(1)).forceModel(any()); verify(separate, times(1)).forceModel(any()); + } + + @Test + void emptyEntriesInThePublicSlotMapDoNotPreventOtherLoavesFromAging() { + tray.furniture.getActiveSlots().put("left_a", null); + PlacedSlot bread = tray.place("left_b", bakery.loaf(0, 1)); + assertFalse(BakingTrayBakeApplier.applyCookSlot(tray.furniture, "left_a")); + new BakingTrayAging().start(); env.server.getScheduler().performTicks(20); + assertNull(tray.furniture.getActiveSlots().get("left_a")); + verify(bread).forceModel(any()); verify(env.manager).markDirty(tray.furniture); + } + + /** Reuses processor item boundaries and adds tray definitions, attachment state and the heat service. */ + public static final class Bakery implements AutoCloseable { + public final Workshop env = new Workshop(); + public final Map placed = new LinkedHashMap<>(); + public final Set consumers = new HashSet<>(); + public final Map> nested = new LinkedHashMap<>(); + public final MockedStatic updater; + public final MockedStatic heat; + public final Station tray; + public BakingTrayRecipe recipe; + public boolean heated = true; + final Map previous = new LinkedHashMap<>(); + public Bakery() { + for (BakingTrayRecipe old : BakingTrayRegistry.getAll()) previous.put(old.getId(), old); + when(env.manager.getPlacedFurniture()).thenReturn(placed); + env.templates.put("bread", food("bread", null)); env.templates.put("dough", food("dough", null)); + when(env.api.getChecker().checkItemWithPath(any(), eq("food:dough"))).thenAnswer(inv -> { + ItemStack stack = inv.getArgument(0); return stack != null && stack.getType() == Material.WHEAT; + }); + updater = env.scoped(mockStatic(ItemUpdater.class)); + updater.when(() -> ItemUpdater.applyItemUpdate(any(), any(), any())).thenAnswer(inv -> updated(inv.getArgument(0), inv.getArgument(1))); + updater.when(() -> ItemUpdater.updateItem(any(), any(), any())).thenAnswer(inv -> updated(inv.getArgument(0), inv.getArgument(1))); + heat = env.scoped(mockStatic(HeatSources.class)); + heat.when(() -> HeatSources.isConsumer(any())).thenAnswer(inv -> consumers.contains(inv.getArgument(0))); + heat.when(() -> HeatSources.consumerHasHeat(any())).thenAnswer(inv -> heated); + tray = station("bread_tray"); defineTraySlots(tray); + Map> molds = new LinkedHashMap<>(); + molds.put("left", List.of("left_a", "left_b")); molds.put("right", List.of("right_a", "right_b")); + recipe = recipe(molds, new BakingTrayFill("food:dough", "dough", 2, "bread", "cooked.0:freshness.0", "grain")); + register(recipe); + } + ItemStack updated(ItemStack source, FoodItem food) { return env.stack(food, source.getType(), source.getAmount()); } + public BakingTrayRecipe recipe(Map> molds, BakingTrayFill fill) { + return new BakingTrayRecipe("bread", "bread_tray", molds, fill, new BakingTrayBake(2, 4, false)); + } + public void register(BakingTrayRecipe recipe) { BakingTrayRegistry.load(Map.of(recipe.getId(), recipe)); this.recipe = recipe; } + public Station station(String id) { + Station station = env.station(id); placed.put(station.id, station.furniture); + Map attachments = new LinkedHashMap<>(); nested.put(station.furniture, attachments); + when(station.furniture.getActiveFurnitureSlots()).thenReturn(attachments); + return station; + } + public Station consumer() { Station station = station("oven_top"); consumers.add(station.furniture); return station; } + public void nest(Station parent, String id, Furniture child) { + PlacedFurnitureSlot slot = mock(PlacedFurnitureSlot.class); when(slot.getNested()).thenReturn(child); + nested.get(parent.furniture).put(id, slot); + } + public void defineTraySlots(Station station) { + int index = 0; + for (String id : List.of("left_a", "left_b", "right_a", "right_b")) { + var definition = station.define(id); Location location = new Location(env.world, index++ * 2, 64, 1); + when(definition.computeDisplayLocation(any(), any(), any())).thenReturn(location); + } + } + public FoodItem food(String id, Integer cooked) { + FoodItem food = env.food(id, "Wheat", 4); food.setCategory("grain"); + if (cooked != null) track(food, "cooked", cooked); + return food; + } + public void track(FoodItem food, String id, int value) { + TagTrack track = new TagTrack(TrackLoader.getByString(id)); track.setValue(value); food.addOrModifyTrack(track); + } + public ItemStack loaf(int cooked, int amount) { return env.stack(food("bread", cooked), Material.BREAD, amount); } + public Vector click(int slot) { return new Vector(slot * 2, 64, 1); } + public FurnitureInteractEvent interact(Vector click) { return new FurnitureInteractEvent(env.player, tray.furniture, null, click); } + @Override public void close() { + env.server.getScheduler().cancelTasks(Cooking.plugin); + BakingTrayRegistry.load(previous); env.close(); + } + } +} diff --git a/src/test/java/net/tfminecraft/cooking/cache/CachesCoverageTest.java b/src/test/java/net/tfminecraft/cooking/cache/CachesCoverageTest.java new file mode 100644 index 0000000..53d09bd --- /dev/null +++ b/src/test/java/net/tfminecraft/cooking/cache/CachesCoverageTest.java @@ -0,0 +1,174 @@ +package net.tfminecraft.cooking.cache; + +import static org.junit.jupiter.api.Assertions.*; +import static org.mockito.ArgumentMatchers.*; +import static org.mockito.Mockito.*; + +import java.lang.reflect.Field; +import java.lang.reflect.Modifier; +import java.util.ArrayList; +import java.util.HashMap; +import java.util.List; +import java.util.Locale; +import java.util.Map; +import java.util.function.Predicate; +import net.tfminecraft.cooking.Cooking; +import net.tfminecraft.cooking.enums.Method; +import net.tfminecraft.cooking.item.FoodItem; +import net.tfminecraft.cooking.loader.FoodLoader; +import net.tfminecraft.cooking.utils.ItemBuilder; +import net.tfminecraft.interactiblefurniture.furniture.Furniture; +import net.tfminecraft.tlibs.TLibs; +import net.tfminecraft.tlibs.objects.api.ItemAPI; +import org.bukkit.Material; +import org.bukkit.configuration.MemoryConfiguration; +import org.bukkit.inventory.ItemStack; +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.mockito.MockedStatic; + +class CachesCoverageTest { + final Map globals=new HashMap<>(); + List foods; + Cooking plugin; + MockedStatic tlibs; + ItemAPI api; + ItemStack plain; + + @BeforeEach void setup() throws Exception { + MockBukkit.mock(); plugin=Cooking.plugin; Cooking.plugin=mock(Cooking.class); + when(Cooking.plugin.getName()).thenReturn("Cooking"); when(Cooking.plugin.namespace()).thenReturn("cooking"); + foods=new ArrayList<>(FoodLoader.oList); FoodLoader.oList.clear(); + for(Class type:List.of(ItemCache.class,FurnitureCache.class,CategoryDictionary.class)) { + for(Field field:type.getFields()) if(Modifier.isStatic(field.getModifiers())&&!Modifier.isFinal(field.getModifiers())) { + Object value=field.get(null); globals.put(field,value); + if(value instanceof Map) field.set(null,new HashMap<>()); + if(value instanceof List) field.set(null,new ArrayList<>()); + if(field.getType()==String.class) field.set(null,field.getName()); + } + } + api=mock(ItemAPI.class,RETURNS_DEEP_STUBS); tlibs=mockStatic(TLibs.class); tlibs.when(TLibs::getItemAPI).thenReturn(api); + plain=new ItemStack(Material.PAPER); + } + @AfterEach void cleanup() throws Exception { + for(var entry:globals.entrySet()) entry.getKey().set(null,entry.getValue()); + FoodLoader.oList.clear(); FoodLoader.oList.addAll(foods); + tlibs.close(); MockBukkit.unmock(); Cooking.plugin=plugin; + } + + @Test void configuredToolsAndCupsUseTheExternalItemMatcher() { + when(api.getChecker().checkItemWithPath(plain,"ladle")).thenReturn(true); + when(api.getChecker().checkItemWithPath(plain,"masher")).thenReturn(true); + when(api.getChecker().checkItemWithPath(plain,"flour")).thenReturn(true); + when(api.getChecker().checkItemWithPath(plain,"bag")).thenReturn(true); + when(api.getChecker().checkItemWithPath(plain,"water")).thenReturn(true); + when(api.getChecker().checkItemWithPath(plain,"emptyCup")).thenReturn(true); + when(api.getChecker().checkItemWithPath(plain,"cupOfWater")).thenReturn(true); + when(api.getChecker().checkItemWithPath(plain,"cupOfMilk")).thenReturn(true); + when(api.getChecker().checkItemWithPath(plain,"butter")).thenReturn(true); + assertTrue(ItemCache.isLadle(plain)); assertTrue(ItemCache.isMasher(plain)); assertTrue(ItemCache.isFlour(plain)); + assertTrue(ItemCache.isBag(plain)); assertTrue(ItemCache.isWater(plain)); assertTrue(ItemCache.isEmptyCup(plain)); + assertTrue(ItemCache.isCupOfWater(plain)); assertTrue(ItemCache.isCupOfMilk(plain)); assertTrue(ItemCache.isButter(plain)); + assertFalse(ItemCache.isCupOfWater(null)); assertFalse(ItemCache.isCupOfMilk(null)); assertFalse(ItemCache.isButter(null)); + assertFalse(ItemCache.isMilkBucket(null)); assertFalse(ItemCache.isMilkBucket(plain)); + assertTrue(ItemCache.isMilkBucket(new ItemStack(Material.MILK_BUCKET))); + assertFalse(ItemCache.isPotWaterInput(null)); assertTrue(ItemCache.isPotWaterInput(new ItemStack(Material.WATER_BUCKET))); + when(api.getChecker().checkItemWithPath(plain,"potWaterInput")).thenReturn(true); assertTrue(ItemCache.isPotWaterInput(plain)); + ItemCache.potWaterInput=null; ItemCache.cupOfMilk=null; assertFalse(ItemCache.isPotWaterInput(plain)); assertFalse(ItemCache.isCupOfMilk(plain)); + assertNotNull(new ItemCache()); + } + + @Test void foodMetadataRecognizesButterMilkAndBucketsIndependentlyOfConfiguredPaths() { + assertTrue(ItemCache.isButter(food("butter",null))); assertTrue(ItemCache.isCupOfMilk(food("cup_of_milk",null))); + assertTrue(ItemCache.isMilkBucket(food("milk_bucket",null))); assertFalse(ItemCache.isCupOfWater(food("other",null))); + assertFalse(ItemCache.isButter(food("other",null))); + } + + @Test void liquidsColoursAndOvenFuelsMatchConfiguredPathsWithFallbacks() { + ItemCache.liquidModels.put("water","display.water"); + assertFalse(ItemCache.isLiquid(plain)); assertNull(ItemCache.getLiquidModel(plain)); + when(api.getChecker().checkItemWithPath(plain,"water")).thenReturn(true); + assertTrue(ItemCache.isLiquid(plain)); assertEquals("display.water",ItemCache.getLiquidModel(plain)); + assertEquals("display.water",ItemCache.getLiquidModel("water")); assertNull(ItemCache.getLiquidModel("missing")); + assertEquals("000000",ItemCache.getColour(null)); assertEquals("000000",ItemCache.getColour(plain)); + ItemCache.colourMap.put("origin.Red_Apple","ff0000"); assertEquals("000000",ItemCache.getColour(plain)); + assertEquals("ff0000",ItemCache.getColour(food("apple","Red Apple"))); + assertEquals("000000",ItemCache.getColour(food("pear","pear"))); + ItemCache.colourMap.put("water","0000ff"); assertEquals("0000ff",ItemCache.getColour(plain)); + assertEquals("000000",ItemCache.getColour(new ItemStack(Material.STONE))); + assertFalse(ItemCache.matchesOvenFuel(null)); assertFalse(ItemCache.matchesOvenFuel(plain)); + ItemCache.ovenFuel.add("wood"); assertFalse(ItemCache.matchesOvenFuel(plain)); + when(api.getChecker().checkItemWithPath(plain,"wood")).thenReturn(true); assertTrue(ItemCache.matchesOvenFuel(plain)); + assertEquals("",ItemCache.normalizeOrigin(null)); assertTrue(ItemCache.originsMatch("Red Apple","red_apple")); + assertFalse(ItemCache.originsMatch(null,"apple")); assertFalse(ItemCache.originsMatch(" ","apple")); + assertFalse(ItemCache.originsMatch("apple",null)); assertFalse(ItemCache.originsMatch("apple"," ")); + } + + @Test void mixingInputsAcceptEitherFoodIdOrAnExplicitFallbackPath() { + MixingIngredient ingredient=new MixingIngredient("input.flour","flour","display.flour","dough"); + ItemCache.mixingIngredients.put("flour",ingredient); + assertSame(ingredient,ItemCache.getMixingIngredient("flour")); assertEquals("flour",ingredient.getInputFood()); + assertEquals("input.flour",ingredient.getInput()); assertEquals("display.flour",ItemCache.getMixingModel("flour")); + assertEquals("dough",ItemCache.getMixingOutput("flour")); assertNull(ItemCache.getMixingModel("missing")); + assertNull(ItemCache.getMixingOutput("missing")); assertFalse(ItemCache.matchesMixingInput("missing",plain)); + assertFalse(ItemCache.matchesMixingInput("flour",null)); assertFalse(ItemCache.matchesMixingInput("flour",plain)); + assertTrue(ItemCache.matchesMixingInput("flour",food("flour",null))); + assertFalse(ItemCache.matchesMixingInput("flour",food("bread",null))); + when(api.getChecker().checkItemWithPath(plain,"input.flour")).thenReturn(true); assertTrue(ItemCache.matchesMixingInput("flour",plain)); + ItemCache.mixingIngredients.put("flour",new MixingIngredient(null,null,null,null)); + assertFalse(ItemCache.matchesMixingInput("flour",plain)); + } + + @Test void furnitureIdsSelectTheirRolesAndCookingMethod() { + Furniture furniture=mock(Furniture.class,RETURNS_DEEP_STUBS); + Map> roles=Map.ofEntries(Map.entry("butterChurn",FurnitureCache::isButterChurn), + Map.entry("butterPlate",FurnitureCache::isButterPlate),Map.entry("firePit",FurnitureCache::isFirePit), + Map.entry("meatHook",FurnitureCache::isMeatHook),Map.entry("sausageMaker",FurnitureCache::isSausageMaker), + Map.entry("mixingBowl",FurnitureCache::isMixingBowl),Map.entry("millingStone",FurnitureCache::isMillingStone), + Map.entry("ovenBottom",FurnitureCache::isOvenBottom),Map.entry("ovenTop",FurnitureCache::isOvenTop), + Map.entry("breadTray",FurnitureCache::isBreadTray),Map.entry("liquidContainer",FurnitureCache::isLiquidContainer), + Map.entry("trough",FurnitureCache::isTrough),Map.entry("plate",FurnitureCache::isPlate),Map.entry("bowl",FurnitureCache::isBowl)); + for(var role:roles.entrySet()) { + when(furniture.getId()).thenReturn(role.getKey().toUpperCase(Locale.ROOT)); assertTrue(role.getValue().test(furniture)); + when(furniture.getId()).thenReturn("unrelated"); assertFalse(role.getValue().test(furniture)); + } + when(furniture.getId()).thenReturn("fryingPan"); assertEquals(Method.FRYING_PAN,FurnitureCache.getByFurniture(furniture)); + when(furniture.getId()).thenReturn("saucePan"); assertEquals(Method.SAUCEPAN,FurnitureCache.getByFurniture(furniture)); + when(furniture.getId()).thenReturn("pot"); assertEquals(Method.POT,FurnitureCache.getByFurniture(furniture)); + when(furniture.getId()).thenReturn("other"); assertEquals(Method.NONE,FurnitureCache.getByFurniture(furniture)); assertFalse(FurnitureCache.isMealHolder(furniture)); + when(furniture.getId()).thenReturn("bowl"); assertTrue(FurnitureCache.isMealHolder(furniture)); + when(furniture.getId()).thenReturn("plate"); assertTrue(FurnitureCache.isMealHolder(furniture)); + when(furniture.getType().getItemPath()).thenReturn("ia.tfmc_cooking:pot"); assertTrue(FurnitureCache.isCookingFurniture(furniture)); + when(furniture.getType().getItemPath()).thenReturn("ia.other:table"); assertFalse(FurnitureCache.isCookingFurniture(furniture)); + when(furniture.getType()).thenReturn(null); assertFalse(FurnitureCache.isCookingFurniture(furniture)); + assertNotNull(new FurnitureCache()); + } + + @Test void cookingFurnitureRecognitionDoesNotDependOnTheServersLocale() { + Furniture furniture=mock(Furniture.class,RETURNS_DEEP_STUBS); when(furniture.getType().getItemPath()).thenReturn("IA.TFMC_COOKING:OVEN"); + Locale old=Locale.getDefault(); + try { Locale.setDefault(Locale.forLanguageTag("tr-TR")); assertTrue(FurnitureCache.isCookingFurniture(furniture)); } + finally { Locale.setDefault(old); } + } + + @Test void categoryNamesAndSauceColoursUseConfiguredValuesOrFallbacks() { + CategoryDictionary.dictionary.put("meat","Custom Meat"); assertEquals("Custom Meat",CategoryDictionary.getName("meat")); + assertTrue(CategoryDictionary.getName("garden_vegetable").endsWith("Garden Vegetable")); + ItemCache.liquidFallback="fallback"; CategoryDictionary.sauceDict.put("ff0000","red|deep.red"); + CategoryDictionary.sauceDict.put("000000","black"); CategoryDictionary.sauceDict.put("bad","invalid"); + assertEquals("red",CategoryDictionary.getSauceItemPath("#fe0101",0)); assertEquals("deep.red",CategoryDictionary.getSauceItemPath("ff0000",1)); + for(String colour:new String[]{null,"000000","bad"}) assertEquals("fallback",CategoryDictionary.getSauceItemPath(colour,0)); + assertEquals("fallback",CategoryDictionary.getSauceItemPath("ff0000",-1)); assertEquals("fallback",CategoryDictionary.getSauceItemPath("ff0000",2)); + CategoryDictionary.sauceDict.put("ff0000","red| "); assertEquals("fallback",CategoryDictionary.getSauceItemPath("ff0000",1)); + CategoryDictionary.sauceDict.clear(); assertEquals("fallback",CategoryDictionary.getSauceItemPath("ff0000",0)); + assertNotNull(new CategoryDictionary()); + } + + private ItemStack food(String id,String origin) { + FoodItem food=new FoodItem(id,new MemoryConfiguration()); food.setOrigin(origin); + FoodLoader.oList.removeIf(f->f.getId().equals(id)); FoodLoader.oList.add(food); + return ItemBuilder.stamp(new ItemStack(Material.PAPER),food,id); + } +} diff --git a/src/test/java/net/tfminecraft/cooking/carve/CarvingAndMeatHookCoverageTest.java b/src/test/java/net/tfminecraft/cooking/carve/CarvingAndMeatHookCoverageTest.java new file mode 100644 index 0000000..4c72b47 --- /dev/null +++ b/src/test/java/net/tfminecraft/cooking/carve/CarvingAndMeatHookCoverageTest.java @@ -0,0 +1,448 @@ +package net.tfminecraft.cooking.carve; + +import static org.junit.jupiter.api.Assertions.*; +import static org.mockito.ArgumentMatchers.*; +import static org.mockito.Mockito.*; + +import java.io.ByteArrayOutputStream; +import java.io.PrintStream; +import java.lang.reflect.Field; +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.HashMap; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Locale; +import java.util.Map; +import java.util.UUID; +import java.util.logging.Logger; + +import org.bukkit.Chunk; +import org.bukkit.Location; +import org.bukkit.Material; +import org.bukkit.configuration.MemoryConfiguration; +import org.bukkit.entity.Item; +import org.bukkit.event.world.ChunkLoadEvent; +import org.bukkit.inventory.ItemStack; +import org.bukkit.persistence.PersistentDataType; +import org.bukkit.util.Vector; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.io.TempDir; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.CsvSource; +import org.junit.jupiter.params.provider.ValueSource; +import org.mockito.ArgumentCaptor; +import org.mockito.MockedStatic; + +import net.tfminecraft.cooking.Cooking; +import net.tfminecraft.cooking.cache.FurnitureCache; +import net.tfminecraft.cooking.cache.ItemCache; +import net.tfminecraft.cooking.hook.MeatHookHandler; +import net.tfminecraft.cooking.item.FoodItem; +import net.tfminecraft.cooking.item.IngredientLineage; +import net.tfminecraft.cooking.item.model.FoodModel; +import net.tfminecraft.cooking.item.model.ModelData; +import net.tfminecraft.cooking.item.tag.TagStep; +import net.tfminecraft.cooking.item.tag.TagTrack; +import net.tfminecraft.cooking.loader.CarveSequenceLoader; +import net.tfminecraft.cooking.loader.TrackLoader; +import net.tfminecraft.cooking.quality.CompositionContext; +import net.tfminecraft.cooking.sausagemaker.SausageMakerCoverageTest.Station; +import net.tfminecraft.cooking.sausagemaker.SausageMakerCoverageTest.Workshop; +import net.tfminecraft.cooking.utils.FoodParser; +import net.tfminecraft.cooking.utils.InventoryAdder; +import net.tfminecraft.cooking.utils.ItemBuilder; +import net.tfminecraft.cooking.utils.ItemRef; +import net.tfminecraft.cooking.utils.ItemUpdater; +import net.tfminecraft.cooking.utils.Keys; +import net.tfminecraft.interactiblefurniture.events.FurniturePunchEvent; +import net.tfminecraft.interactiblefurniture.furniture.Furniture; +import net.tfminecraft.interactiblefurniture.furniture.PlacedSlot; + +class CarvingAndMeatHookCoverageTest { + @TempDir Path directory; + Workshop env; + Station hook; + MeatHookHandler hooks; + Map placed; + Map registry, previousSequences; + MockedStatic parser; + MockedStatic refs; + MockedStatic updater; + String oldHook, oldTool; + FoodItem lastPart; + + @BeforeEach @SuppressWarnings("unchecked") void open() throws Exception { + // Snapshot and restore the loader's registry; scenarios themselves use its public YAML loader. + Field field = CarveSequenceLoader.class.getDeclaredField("sequences"); field.setAccessible(true); + registry = (Map) field.get(null); previousSequences = new HashMap<>(registry); + oldHook = FurnitureCache.meatHook; oldTool = ItemCache.carveTool; + env = new Workshop(); when(Cooking.plugin.getLogger()).thenReturn(Logger.getAnonymousLogger()); + FurnitureCache.meatHook = "meat_hook"; ItemCache.carveTool = "tool:knife"; + hook = env.station("meat_hook"); hook.define("content"); hooks = new MeatHookHandler(); + placed = new LinkedHashMap<>(); placed.put(hook.id, hook.furniture); when(env.manager.getPlacedFurniture()).thenReturn(placed); + when(env.api.getChecker().checkItemWithPath(any(), eq("tool:knife"))).thenAnswer(inv -> { + ItemStack item = inv.getArgument(0); return item != null && item.getType() == Material.IRON_AXE; + }); + parser = env.scoped(mockStatic(FoodParser.class)); + FoodParser.Result parsed = new FoodParser.Result(); parsed.template = env.food("cut", "Cow", 3); + parser.when(() -> FoodParser.parse("meat(type=cut)")).thenReturn(parsed); + refs = env.scoped(mockStatic(ItemRef.class)); + refs.when(() -> ItemRef.resolve("v.bone")).thenAnswer(inv -> new ItemStack(Material.BONE)); + env.builder.when(() -> ItemBuilder.buildSingleWithQuality(any(), any(ItemStack.class), anyInt())).thenAnswer(inv -> { + lastPart = inv.getArgument(0); return env.stack(new FoodItem(lastPart), Material.PAPER, 1); + }); + updater = env.scoped(mockStatic(ItemUpdater.class)); + updater.when(() -> ItemUpdater.applyItemUpdate(any(), any(), any())).thenAnswer(inv -> updated(inv.getArgument(1))); + updater.when(() -> ItemUpdater.updateItem(any(), any(), any())).thenAnswer(inv -> updated(inv.getArgument(1))); + load(""" + roast: + start-remaining: 3 + cuts: + - output: meat(type=cut) + food: 2 + nutrition: 3 + - output: meat(type=cut) + food: 4 + nutrition: 5 + - item: v.bone + amount: 2 + food: 0 + nutrition: 0 + """); + } + + @AfterEach void close() { + env.server.getScheduler().cancelTasks(Cooking.plugin); env.close(); + registry.clear(); registry.putAll(previousSequences); + FurnitureCache.meatHook = oldHook; ItemCache.carveTool = oldTool; + } + + void load(String yaml) throws Exception { + Path file = directory.resolve("carve.yml"); Files.writeString(file, yaml); new CarveSequenceLoader().load(file.toFile()); + } + FoodItem roast(String sequence) { + MemoryConfiguration config = new MemoryConfiguration(); config.set("carve-sequence", sequence); config.set("food", 12); config.set("nutrition", 7); + FoodItem food = new FoodItem("roast", config); food.setOrigin("Cow"); food.setCategory("meat"); food.setQualityRange(3, 3); + food.setModel(new FoodModel(new ItemStack(Material.CARROT))); return food; + } + ItemStack updated(FoodItem food) { return env.stack(new FoodItem(food), Material.ROTTEN_FLESH, 1); } + ItemStack whole(FoodItem food) { + ItemStack stack = env.stack(food, Material.ROTTEN_FLESH, 1); CarvableRoastUtils.initCarveState(stack, food); return stack; + } + PlacedSlot placeRoast() { return hook.place("content", whole(roast("roast"))); } + ItemStack knife() { return new ItemStack(Material.IRON_AXE); } + boolean carve(PlacedSlot slot) { return CarveHandler.tryCarve(env.player, hook.furniture, slot, knife()); } + long carried(Material material) { + long count = 0; for (ItemStack item : env.player.getInventory().getStorageContents()) if (item != null && item.getType() == material) count += item.getAmount(); + return count; + } + void track(FoodItem food, String id, int value) { + TagTrack track = new TagTrack(TrackLoader.getByString(id)); track.setValue(value); food.addOrModifyTrack(track); + } + + @Test + void cutsSequencesAndLoaderExposeAmountsNutritionAndCaseInsensitiveIds() throws Exception { + CarveSequence sequence = CarveSequenceLoader.get("ROAST"); + assertEquals("roast", sequence.getId()); assertEquals(3, sequence.getStartRemaining()); assertEquals(1, sequence.getMinFoodCuts()); + assertEquals(6, sequence.sumRemainingFood(0)); assertEquals(4, sequence.sumRemainingFood(1)); assertEquals(0, sequence.sumRemainingFood(3)); + assertEquals(8, sequence.sumRemainingNutrition(0)); assertEquals(5, sequence.sumRemainingNutrition(1)); + assertNull(sequence.getCut(-1)); assertNull(sequence.getCut(3)); assertNull(CarveSequenceLoader.get(null)); assertNull(CarveSequenceLoader.get("missing")); + CarveCut food = sequence.getCut(0), bone = sequence.getCut(2); + assertTrue(food.isFoodCut()); assertFalse(food.isItemCut()); assertEquals("meat(type=cut)", food.getOutput()); + assertTrue(bone.isItemCut()); assertFalse(bone.isFoodCut()); assertEquals("v.bone", bone.getItemRef()); assertEquals(2, bone.getAmount()); + assertEquals(3, food.getNutrition()); assertFalse(CarveCut.food(" ", 1, 1).isFoodCut()); assertFalse(CarveCut.item(" ", 1, 1, 1).isItemCut()); + load(""" + scalar: ignored + poultry: + start-remaining: 2 + cuts: + - output: food + - item: v.bone + - output: food + item: v.bone + - food: 1 + other: + min-food-cuts: 2 + """); + CarveSequence poultry = CarveSequenceLoader.get("poultry"); assertEquals(3, poultry.getMinFoodCuts()); assertEquals(2, poultry.getCuts().size()); + assertEquals(1, poultry.getCut(0).getFood()); assertEquals(1, poultry.getCut(0).getNutrition()); assertEquals(1, poultry.getCut(1).getAmount()); + assertEquals(2, CarveSequenceLoader.get("other").getMinFoodCuts()); assertNull(CarveSequenceLoader.get("roast")); + } + + @Test + void unreadableSequenceFilesClearOldRecipesAndReportTheFile() { + PrintStream before = System.err; ByteArrayOutputStream diagnostics = new ByteArrayOutputStream(); + try (PrintStream capture = new PrintStream(diagnostics)) { + System.setErr(capture); new CarveSequenceLoader().load(directory.resolve("missing.yml").toFile()); + } finally { System.setErr(before); } + assertNull(CarveSequenceLoader.get("roast")); assertTrue(diagnostics.toString().contains("missing.yml")); + } + + @ParameterizedTest + @ValueSource(strings = {"food: invalid", "nutrition: invalid", "amount: invalid", "amount: 0", "amount: -2", "amount: 1.5", + "food: .nan", "nutrition: .inf", "amount: .inf", "amount: 2147483648", "food: -1", "nutrition: -1"}) + void malformedNumericCutFieldsDoNotPreventValidCutsFromLoading(String setting) throws Exception { + String config = "safe:\n cuts:\n - item: v.bone\n " + setting + "\n - item: v.bone\n amount: 2\n"; + assertDoesNotThrow(() -> load(config)); + assertEquals(1, CarveSequenceLoader.get("safe").getCuts().size()); assertEquals(2, CarveSequenceLoader.get("safe").getCut(0).getAmount()); + } + + @Test + void recipeIdsRemainCaseInsensitiveUnderTurkishLocale() throws Exception { + Locale previous = Locale.getDefault(); + try { + Locale.setDefault(Locale.forLanguageTag("tr-TR")); + load("FILET:\n cuts:\n - item: v.bone\n"); + assertSame(CarveSequenceLoader.get("filet"), CarveSequenceLoader.get("FILET")); + assertNotNull(CarveSequenceLoader.get("filet")); + } finally { Locale.setDefault(previous); } + } + + @Test + void persistedCarveStateRoundTripsAndInitializesOnlyKnownSequences() { + FoodItem original = roast("roast"); ItemStack stack = whole(original); + FoodItem restored = roast(null); CarvableRoastUtils.readCarveState(restored, stack); + assertEquals("roast", restored.getCarveSequencePdc()); assertEquals(0, restored.getCarveNextIndex()); assertEquals(3, restored.getCarveRemaining()); + restored.setCarveNextIndex(1); restored.setCarveRemaining(2); CarvableRoastUtils.writeCarveState(stack, restored); + CarvableRoastUtils.readCarveState(original, stack); assertEquals(1, original.getCarveNextIndex()); assertEquals(2, original.getCarveRemaining()); + ItemStack partial = env.stack(restored, Material.PAPER, 1); var meta = partial.getItemMeta(); + meta.getPersistentDataContainer().set(Keys.CARVE_SEQUENCE, PersistentDataType.STRING, "roast"); partial.setItemMeta(meta); + CarvableRoastUtils.readCarveState(original, partial); assertEquals(0, original.getCarveNextIndex()); assertEquals(0, original.getCarveRemaining()); + CarvableRoastUtils.readCarveState(original, null); CarvableRoastUtils.readCarveState(original, new ItemStack(Material.AIR)); + CarvableRoastUtils.readCarveState(original, new ItemStack(Material.STONE)); + CarvableRoastUtils.writeCarveState(null, original); CarvableRoastUtils.writeCarveState(new ItemStack(Material.AIR), original); + FoodItem plain = roast(null); CarvableRoastUtils.writeCarveState(stack, plain); CarvableRoastUtils.initCarveState(stack, plain); assertFalse(plain.hasCarveState()); + FoodItem unknown = roast("missing"); CarvableRoastUtils.initCarveState(stack, unknown); assertFalse(unknown.hasCarveState()); + assertTrue(CarvableRoastUtils.isCarvable(stack)); assertFalse(CarvableRoastUtils.isCarvable(new ItemStack(Material.STONE))); + assertFalse(CarvableRoastUtils.isCarvable((FoodItem) null)); assertFalse(CarvableRoastUtils.isCarvable(plain)); + original.setCarveState("roast", 3, 1); assertFalse(CarvableRoastUtils.isCarvable(original)); + } + + @ParameterizedTest + @CsvSource({"removed_recipe,0", "roast,-1"}) + void roastsWithRemovedRecipesOrNegativeIndexesAreNotCarvable(String sequence, int index) { + FoodItem unknown = roast("roast"); unknown.setCarveState(sequence, index, 3); + assertFalse(CarvableRoastUtils.isCarvable(unknown)); + } + + @Test + void remainingFoodUsesEdibleCutBudgetsAndKeepsBatchNutrition() { + CarveSequence sequence = CarveSequenceLoader.get("roast"); FoodItem food = roast("roast"); whole(food); + assertEquals(6, CarvableRoastUtils.getRemainingFood(food)); assertEquals(7, CarvableRoastUtils.getRemainingNutrition(food)); + assertEquals(12, CarvableRoastUtils.remainingFood(food, null)); + assertEquals(2, CarvableRoastUtils.countFoodCuts(sequence)); assertEquals(2, CarvableRoastUtils.countFoodCutsFrom(sequence, -3)); + assertEquals(1, CarvableRoastUtils.countFoodCutsFrom(sequence, 1)); assertEquals(0, CarvableRoastUtils.countFoodCuts(null)); + food.setCarveState("roast", 0, 2); assertEquals(2, CarvableRoastUtils.getRemainingFood(food)); + CarvableRoastUtils.advanceAfterCarve(food); assertEquals(2, food.getCarveNextIndex()); assertEquals(1, food.getCarveRemaining()); + assertEquals(0, CarvableRoastUtils.getRemainingFood(food)); + food.setBaseFood(12); food.setCarveState("roast", 1, 2); assertEquals(6, CarvableRoastUtils.getRemainingFood(food)); + assertEquals(6, CarvableRoastUtils.portionFood(12, 2)); assertEquals(12, CarvableRoastUtils.portionFood(12, 0)); + CarveSequence allMeat = new CarveSequence("meat", 2, List.of(CarveCut.food("part", 2, 1), CarveCut.food("part", 4, 1))); + FoodItem unmodified = roast(null); unmodified.setCarveState("meat", 0, 2); assertEquals(6, CarvableRoastUtils.remainingFood(unmodified, allMeat)); + unmodified.setCarveRemaining(0); assertEquals(0, CarvableRoastUtils.remainingFood(unmodified, allMeat)); + CarvableRoastUtils.advanceAfterCarve(unmodified, null); assertEquals(0, unmodified.getCarveRemaining()); + } + + @Test + void portionBudgetsRespectMinimumMeatCutsAndKeepOnlyTheClosingBone() { + CarveSequence sequence = CarveSequenceLoader.get("roast"); + assertEquals(new CarvableRoastUtils.RoastPortion(0, 2), CarvableRoastUtils.portion(sequence, -2)); + assertEquals(new CarvableRoastUtils.RoastPortion(0, 3), CarvableRoastUtils.portion(sequence, 10)); + assertEquals(new CarvableRoastUtils.RoastPortion(0, 0), CarvableRoastUtils.portion(null, 3)); + CarveSequence empty = new CarveSequence("empty", 0, List.of()); assertEquals(0, CarvableRoastUtils.portion(empty, 3).remaining()); + assertEquals(-1, CarvableRoastUtils.trailingItemIndex(empty)); assertEquals(-1, CarvableRoastUtils.trailingItemIndex(null)); + CarveSequence noBone = new CarveSequence("meat", 1, List.of(CarveCut.food("part", 1, 1))); + assertEquals(-1, CarvableRoastUtils.trailingItemIndex(noBone)); assertEquals(1, CarvableRoastUtils.portion(noBone, 5).remaining()); + CarveSequence blankAfterBone = new CarveSequence("bone", 2, List.of(CarveCut.item("v.bone", 1, 0, 0), CarveCut.food(" ", 0, 0))); + assertEquals(0, CarvableRoastUtils.trailingItemIndex(blankAfterBone)); + assertEquals(-1, CarvableRoastUtils.trailingItemIndex(new CarveSequence("blank", 1, List.of(CarveCut.food(" ", 0, 0))))); + } + + @Test + void visualStagesSpanTheRemainingMeatAndJumpToTheClosingBone() { + CarveSequence sequence = CarveSequenceLoader.get("roast"); FoodItem food = roast("roast"); whole(food); + assertEquals(1, CarvableRoastUtils.visualCarveStage(null, sequence)); assertEquals(1, CarvableRoastUtils.getVisualCarveStage(food)); + food.setCarveState("roast", 1, 2); assertEquals(2, CarvableRoastUtils.getVisualCarveStage(food)); + food.setCarveState("roast", 2, 1); assertEquals(3, CarvableRoastUtils.getVisualCarveStage(food)); + food.setCarveState("roast", 3, 0); assertEquals(3, CarvableRoastUtils.getVisualCarveStage(food)); + assertEquals(4, CarvableRoastUtils.visualCarveStage(food, null)); + assertEquals(4, CarvableRoastUtils.visualCarveStage(food, new CarveSequence("empty", 0, List.of()))); + CarveSequence allMeat = new CarveSequence("meat", 3, List.of(CarveCut.food("part", 1, 1), CarveCut.food("part", 1, 1), CarveCut.food("part", 1, 1))); + food.setCarveState("meat", 1, 2); assertEquals(2, CarvableRoastUtils.visualCarveStage(food, allMeat)); + food.setCarveState("meat", 0, 0); assertEquals(1, CarvableRoastUtils.visualCarveStage(food, allMeat)); + } + + @Test + void modelStagesRespectCookingAndRottenTagsAndInheritedTracksAreCopied() { + FoodItem food = roast("roast"); whole(food); + assertEquals("raw", CarvableRoastUtils.resolveCookTag(food)); + food.addOrModifyTrack(new TagTrack("cooked", false, List.of())); assertEquals("raw", CarvableRoastUtils.resolveCookTag(food)); + track(food, "cooked", 1); assertEquals("cooked", CarvableRoastUtils.resolveCookTag(food)); + food.addOrModifyTrack(new TagTrack("freshness", true, List.of())); assertEquals("cooked", CarvableRoastUtils.resolveCookTag(food)); + food.addOrModifyTrack(new TagTrack("freshness", true, List.of(new TagStep("rotten", "Rotten", 0, 1, 1)))); + assertEquals("rotten", CarvableRoastUtils.resolveCookTag(food)); + MemoryConfiguration model = new MemoryConfiguration(); model.set("rotten.stage", 1); model.set("rotten.tags", List.of("rotten")); + FoodModel staged = new FoodModel("staged", model); food.setModel(staged); + assertSame(staged.getStates().get("rotten"), CarvableRoastUtils.getStageModelData(food)); + food.setCarveRemaining(0); assertSame(staged.getStates().get("rotten"), CarvableRoastUtils.getStageModelData(food)); + FoodItem child = env.food("part", "Pig", 1); food.setLineage(IngredientLineage.ofMain("Cow")); track(food, "warmth", 1); + CarvableRoastUtils.copyInheritedTracks(food, child); + assertEquals("Cow", child.getOrigin()); assertEquals(food.getLineage(), child.getLineage()); assertEquals(3, child.getQualityMin()); + assertTrue(child.hasTagTrack("cooked")); assertTrue(child.hasTagTrack("freshness")); assertTrue(child.hasTagTrack("warmth")); + assertNotSame(food.getTagTrack("cooked"), child.getTagTrack("cooked")); + FoodItem plain = roast(null); plain.setOrigin(null); CarvableRoastUtils.copyInheritedTracks(plain, child); assertEquals("Cow", child.getOrigin()); + } + + @ParameterizedTest + @ValueSource(booleans = {false, true}) + void missingRoastModelsAreReportedAsUnavailableRatherThanThrowing(boolean carved) { + FoodItem food = roast("roast"); if (carved) whole(food); food.setModel(null); + assertNull(CarvableRoastUtils.getStageModelData(food)); + } + + @Test + void emptyModelDefinitionsAreUnavailableAndCannotConsumeACarvingPortion() { + FoodItem food = roast("roast"); ItemStack stack = whole(food); + food.setModel(new FoodModel("empty", new MemoryConfiguration())); + assertNull(CarvableRoastUtils.getStageModelData(food)); + PlacedSlot slot = hook.place("content", stack); assertFalse(carve(slot)); + assertSame(stack, slot.getCurrentItem()); assertEquals(0, carried(Material.PAPER)); + } + + @Test + void missingModelsDoNotPayCarvingRewardsOrBreakHookDisplayRefresh() { + FoodItem food = roast("roast"); ItemStack stack = whole(food); food.setModel(null); + PlacedSlot slot = hook.place("content", stack); + assertDoesNotThrow(() -> hooks.resumeLoadedHooks()); verify(slot, never()).applyDisplayData(any()); + assertFalse(carve(slot)); assertSame(stack, slot.getCurrentItem()); assertEquals(0, carried(Material.PAPER)); + } + + @Test + void carvingProducesTwoInheritedFoodPortionsThenTheBoneAndClearsTheHook() { + FoodItem food = roast("roast"); food.setBaseFood(12); food.setBaseNutrition(7); track(food, "cooked", 1); track(food, "freshness", 2); + food.setLineage(IngredientLineage.ofMain("Cow")); PlacedSlot slot = hook.place("content", whole(food)); + assertTrue(carve(slot)); assertEquals(1, carried(Material.PAPER)); + assertEquals(6, lastPart.getBaseFood()); assertEquals(7, lastPart.getBaseNutrition()); assertEquals("Cow", lastPart.getOrigin()); + assertEquals(1, lastPart.getTagTrack("cooked").getValue()); assertEquals(2, lastPart.getTagTrack("freshness").getValue()); + FoodItem remaining = env.resolve(slot.getCurrentItem()); assertEquals(1, remaining.getCarveNextIndex()); assertEquals(2, remaining.getCarveRemaining()); + assertEquals(1, slot.getCurrentItem().getItemMeta().getPersistentDataContainer().get(Keys.CARVE_NEXT_INDEX, PersistentDataType.INTEGER)); + assertTrue(carve(slot)); assertEquals(2, carried(Material.PAPER)); assertTrue(carve(slot)); assertEquals(2, carried(Material.BONE)); + assertFalse(hook.active.containsKey("content")); verify(slot).clearModel(); verify(hook.furniture).removeActiveSlot("content"); + verify(env.manager, times(3)).markDirty(hook.furniture); + env.composer.verify(() -> net.tfminecraft.cooking.quality.CompositionQualityResolver.compose(eq(env.player), any(), eq(CompositionContext.CARVE)), times(2)); + } + + @Test + void itemRewardsDropWhenInventoryDeliveryFailsAndTheirVelocityIsBounded() { + FoodItem food = roast("roast"); ItemStack stack = whole(food); food.setCarveState("roast", 2, 1); CarvableRoastUtils.writeCarveState(stack, food); + PlacedSlot slot = hook.place("content", stack); Item entity = mock(Item.class); + when(env.world.dropItemNaturally(any(), any())).thenReturn(entity); + try (MockedStatic inventory = mockStatic(InventoryAdder.class)) { + inventory.when(() -> InventoryAdder.addItem(any(), any())).thenAnswer(inv -> inv.getArgument(1)); assertTrue(carve(slot)); + } + ArgumentCaptor velocity = ArgumentCaptor.forClass(Vector.class); verify(entity).setVelocity(velocity.capture()); + assertEquals(0.1, velocity.getValue().getY()); assertTrue(Math.abs(velocity.getValue().getX()) <= 0.1); assertTrue(Math.abs(velocity.getValue().getZ()) <= 0.1); + assertTrue(hook.active.isEmpty()); + } + + @Test + void carvingRejectsWrongToolsMissingInputsAndNoncarvableItems() { + PlacedSlot slot = placeRoast(); + assertFalse(CarveHandler.tryCarve(null, hook.furniture, slot, knife())); assertFalse(CarveHandler.tryCarve(env.player, null, slot, knife())); + assertFalse(CarveHandler.tryCarve(env.player, hook.furniture, null, knife())); assertFalse(CarveHandler.tryCarve(env.player, hook.furniture, slot, null)); + assertFalse(CarveHandler.tryCarve(env.player, hook.furniture, slot, new ItemStack(Material.STICK))); + ItemCache.carveTool = null; assertFalse(carve(slot)); ItemCache.carveTool = "tool:knife"; + assertFalse(carve(hook.place("content", null))); assertFalse(carve(hook.place("content", new ItemStack(Material.STONE)))); + assertFalse(carve(hook.place("content", env.stack(roast(null), Material.PAPER, 1)))); assertEquals(0, carried(Material.PAPER)); + slot = placeRoast(); CarveSequenceLoader.get("roast").getCuts().set(0, null); assertFalse(carve(slot)); + } + + @ParameterizedTest + @ValueSource(strings = {"missing-item", "blank-cut", "missing-parser", "missing-template", "missing-food", "air-food", "air-item"}) + void invalidRewardsNeverConsumeTheRoast(String failure) { + CarveSequence sequence = CarveSequenceLoader.get("roast"); + if (failure.endsWith("item")) sequence.getCuts().set(0, CarveCut.item("v.bone", 1, 0, 0)); + if (failure.equals("missing-item")) refs.when(() -> ItemRef.resolve("v.bone")).thenReturn(null); + if (failure.equals("air-item")) refs.when(() -> ItemRef.resolve("v.bone")).thenReturn(new ItemStack(Material.AIR)); + if (failure.equals("blank-cut")) sequence.getCuts().set(0, CarveCut.food(" ", 0, 0)); + if (failure.equals("missing-parser")) parser.when(() -> FoodParser.parse(anyString())).thenReturn(null); + if (failure.equals("missing-template")) parser.when(() -> FoodParser.parse(anyString())).thenReturn(new FoodParser.Result()); + if (failure.equals("missing-food")) env.builder.when(() -> ItemBuilder.buildSingleWithQuality(any(), any(ItemStack.class), anyInt())).thenReturn(null); + if (failure.equals("air-food")) env.builder.when(() -> ItemBuilder.buildSingleWithQuality(any(), any(ItemStack.class), anyInt())).thenReturn(new ItemStack(Material.AIR)); + PlacedSlot slot = placeRoast(); ItemStack original = slot.getCurrentItem(); + assertFalse(carve(slot)); assertSame(original, slot.getCurrentItem()); + assertEquals(0, carried(Material.PAPER)); assertEquals(0, carried(Material.BONE)); verify(slot, never()).clearModel(); + } + + @ParameterizedTest + @ValueSource(strings = {"null", "air"}) + void failedRoastRenderingCannotPayRepeatedRewards(String failure) { + PlacedSlot slot = placeRoast(); ItemStack original = slot.getCurrentItem(); + ItemStack invalid = failure.equals("air") ? new ItemStack(Material.AIR) : null; + updater.when(() -> ItemUpdater.applyItemUpdate(any(), any(), any())).thenReturn(invalid); + boolean first = carve(slot), second = carve(slot); + assertEquals(0, carried(Material.PAPER), "No rewards may be paid before the remaining roast is renderable"); + assertFalse(first); assertFalse(second); assertSame(original, slot.getCurrentItem()); + assertEquals(0, original.getItemMeta().getPersistentDataContainer().get(Keys.CARVE_NEXT_INDEX, PersistentDataType.INTEGER)); + verify(slot, never()).setCurrentItem(any()); verify(slot, never()).clearModel(); + } + + @Test + void firstCarvableSlotSkipsEmptyEntriesAndNonFoodWithoutChangingThem() { + hook.furniture.getActiveSlots().put("empty", null); PlacedSlot decoration = hook.place("decoration", new ItemStack(Material.STONE)); + assertFalse(CarveHandler.tryCarveFirstCarvableSlot(env.player, hook.furniture, knife())); + PlacedSlot food = placeRoast(); assertTrue(CarveHandler.tryCarveFirstCarvableSlot(env.player, hook.furniture, knife())); + assertEquals(1, carried(Material.PAPER)); verify(decoration, never()).setCurrentItem(any()); verify(food).setCurrentItem(any()); + } + + @Test + void meatHookTimersAgeOnlyReadableRoastsAndRefreshTheirStageDisplay() { + Station chair = env.station("chair"); placed.put(chair.id, chair.furniture); + hooks.start(); env.server.getScheduler().performTicks(20); + hook.furniture.getActiveSlots().put("content", null); env.server.getScheduler().performTicks(20); + hook.place("content", null); env.server.getScheduler().performTicks(20); + hook.place("content", new ItemStack(Material.STONE)); env.server.getScheduler().performTicks(20); + PlacedSlot slot = placeRoast(); ItemStack original = slot.getCurrentItem(); env.server.getScheduler().performTicks(20); + assertNotSame(original, slot.getCurrentItem()); verify(slot).applyDisplayData(any()); + verify(env.manager).markDirty(hook.furniture); + updater.when(() -> ItemUpdater.updateItem(any(), any(), any())).thenReturn(null); env.server.getScheduler().performTicks(20); + verify(slot).setCurrentItem(any()); verify(slot).applyDisplayData(any()); verify(env.manager).markDirty(hook.furniture); + } + + @Test + void airAgingResultsCannotReplaceAHangingRoast() { + PlacedSlot slot = placeRoast(); ItemStack original = slot.getCurrentItem(); + updater.when(() -> ItemUpdater.updateItem(any(), any(), any())).thenReturn(new ItemStack(Material.AIR)); + hooks.start(); env.server.getScheduler().performTicks(20); + assertSame(original, slot.getCurrentItem()); verify(slot, never()).setCurrentItem(any()); + } + + @Test + void meatHooksResumeLoadedChunksAndDelaySlotAdditionDisplayUntilTheNextTick() { + Station chair = env.station("chair"); placed.put(chair.id, chair.furniture); + hooks.resumeLoadedHooks(); + hook.place("content", null); hooks.resumeLoadedHooks(); + hook.place("content", new ItemStack(Material.STONE)); hooks.resumeLoadedHooks(); + PlacedSlot slot = placeRoast(); hooks.resumeLoadedHooks(); verify(slot).applyDisplayData(any()); + clearInvocations(slot); hooks.onRoastAdd(chair.add("content", new ItemStack(Material.STONE))); + hooks.onRoastAdd(hook.add("decoration", new ItemStack(Material.STONE))); verifyNoInteractions(slot); + hooks.onRoastAdd(hook.add("content", slot.getCurrentItem())); verify(slot, never()).applyDisplayData(any()); + env.server.getScheduler().performOneTick(); verify(slot).applyDisplayData(any()); + Chunk chunk = mock(Chunk.class), far = mock(Chunk.class); when(env.world.getChunkAt(any(Location.class))).thenReturn(chunk); + Location remote = mock(Location.class); when(remote.getChunk()).thenReturn(far); when(chair.furniture.getLoc()).thenReturn(remote); + hooks.onChunkLoad(new ChunkLoadEvent(chunk, false)); env.server.getScheduler().performOneTick(); verify(slot, times(2)).applyDisplayData(any()); + } + + @Test + void meatHookPunchesCancelOnlyAfterSuccessfulCarving() { + Station chair = env.station("chair"); var unrelated = new FurniturePunchEvent(env.player, chair.furniture); hooks.onPunch(unrelated); assertFalse(unrelated.isCancelled()); + var empty = new FurniturePunchEvent(env.player, hook.furniture); hooks.onPunch(empty); assertFalse(empty.isCancelled()); + placeRoast(); env.hold(new ItemStack(Material.STICK)); + var wrongTool = new FurniturePunchEvent(env.player, hook.furniture); hooks.onPunch(wrongTool); assertFalse(wrongTool.isCancelled()); + env.hold(knife()); var carved = new FurniturePunchEvent(env.player, hook.furniture); hooks.onPunch(carved); assertTrue(carved.isCancelled()); + assertEquals(1, carried(Material.PAPER)); + } +} diff --git a/src/test/java/net/tfminecraft/cooking/churn/ButterChurnCoverageTest.java b/src/test/java/net/tfminecraft/cooking/churn/ButterChurnCoverageTest.java new file mode 100644 index 0000000..70e01f7 --- /dev/null +++ b/src/test/java/net/tfminecraft/cooking/churn/ButterChurnCoverageTest.java @@ -0,0 +1,507 @@ +package net.tfminecraft.cooking.churn; + +import static org.junit.jupiter.api.Assertions.*; +import static org.mockito.ArgumentMatchers.*; +import static org.mockito.Mockito.*; + +import java.io.File; +import java.util.*; + +import net.tfminecraft.cooking.Cooking; +import net.tfminecraft.cooking.cache.FurnitureCache; +import net.tfminecraft.cooking.cache.ItemCache; +import net.tfminecraft.cooking.cup.*; +import net.tfminecraft.cooking.item.FoodItem; +import net.tfminecraft.cooking.item.IngredientLineage; +import net.tfminecraft.cooking.item.model.FoodModel; +import net.tfminecraft.cooking.item.tag.TagTrack; +import net.tfminecraft.cooking.loader.FoodLoader; +import net.tfminecraft.cooking.loader.TrackLoader; +import net.tfminecraft.cooking.mixing.MixingBowlAnimation; +import net.tfminecraft.cooking.quality.*; +import net.tfminecraft.cooking.utils.*; +import net.tfminecraft.interactiblefurniture.InteractibleFurniture; +import net.tfminecraft.interactiblefurniture.events.*; +import net.tfminecraft.interactiblefurniture.furniture.*; +import net.tfminecraft.interactiblefurniture.manager.FurnitureManager; +import net.tfminecraft.tlibs.TLibs; +import net.tfminecraft.tlibs.objects.api.ItemAPI; +import org.bukkit.*; +import org.bukkit.configuration.MemoryConfiguration; +import org.bukkit.entity.Player; +import org.bukkit.event.world.ChunkLoadEvent; +import org.bukkit.inventory.ItemStack; +import org.bukkit.persistence.PersistentDataType; +import org.bukkit.util.Transformation; +import org.joml.Quaternionf; +import org.joml.Vector3f; +import org.junit.jupiter.api.*; +import org.mockbukkit.mockbukkit.MockBukkit; +import org.mockbukkit.mockbukkit.ServerMock; +import org.mockbukkit.mockbukkit.entity.ItemDisplayMock; +import org.mockito.MockedStatic; + +/** Public only so the second station suite can reuse the same external integration fixture. */ +public class ButterChurnCoverageTest { + private KitchenFixture e; + private Station s; + private ButterChurnHandler handler; + @BeforeEach void setUp() { e = new KitchenFixture(); s = e.station("churn"); handler = new ButterChurnHandler(); } + @AfterEach void tearDown() { e.close(); } + + @Test void emptyAndMalformedSnapshotsHaveSafeDefaults() { + assertEquals(0, ButterChurnState.getChurnCount(s.f)); + assertEquals(1, ButterChurnState.getMilkQuality(s.f)); + assertEquals(DairyOrigin.COW, ButterChurnState.getMilkOrigin(s.f)); + assertEquals(0, ButterChurnState.getDairyFreshness(s.f)); + long before = System.currentTimeMillis(); + assertTrue(ButterChurnState.getLastUpdate(s.f) >= before); + assertFalse(ButterChurnState.hasSalt(s.f)); + assertEquals(1, ButterChurnState.getSaltQuality(s.f)); + assertFalse(ButterChurnState.hasSpice(s.f)); + assertNull(ButterChurnState.getSpiceOrigin(s.f)); + assertEquals(1, ButterChurnState.getSpiceQuality(s.f)); + assertEquals(0, ButterChurnState.getSpiceFreshness(s.f)); + assertFalse(ButterChurnState.hasExtras(s.f)); + assertTrue(ButterChurnState.getMilkLineage(s.f).isEmpty()); + assertTrue(ButterChurnState.getSaltLineage(s.f).isEmpty()); + assertTrue(ButterChurnState.getSpiceLineage(s.f).isEmpty()); + s.variables.put(ButterChurnState.VAR_HAS_SALT, false); + s.variables.put(ButterChurnState.VAR_SPICE_ORIGIN, " "); + assertFalse(ButterChurnState.hasExtras(s.f)); + } + + @Test void snapshotsPreserveOriginsLineageAndExtrasAndClearOnlyTheirOwnKeys() { + s.variables.put("unrelated", "keep"); + IngredientLineage milk = IngredientLineage.ofMain("Goat"), salt = IngredientLineage.ofExtra("Salt"), spice = IngredientLineage.ofExtra("Rosemary"); + ButterChurnState.setMilkSnapshot(s.f, 4, -2, "Goat"); + ButterChurnState.setMilkLineage(s.f, milk); + ButterChurnState.setSalt(s.f, 2); ButterChurnState.setSaltLineage(s.f, salt); + ButterChurnState.setSpice(s.f, "Rosemary", 3, 8); ButterChurnState.setSpiceLineage(s.f, spice); + assertEquals(4, ButterChurnState.getMilkQuality(s.f)); + assertEquals("Goat", ButterChurnState.getMilkOrigin(s.f)); + assertEquals(0, ButterChurnState.getDairyFreshness(s.f)); + assertEquals(milk, ButterChurnState.getMilkLineage(s.f)); + assertTrue(ButterChurnState.hasExtras(s.f)); + assertEquals(2, ButterChurnState.getSaltQuality(s.f)); + assertEquals(salt, ButterChurnState.getSaltLineage(s.f)); + assertEquals("Rosemary", ButterChurnState.getSpiceOrigin(s.f)); + assertEquals(3, ButterChurnState.getSpiceQuality(s.f)); + assertEquals(8, ButterChurnState.getSpiceFreshness(s.f)); + assertEquals(spice, ButterChurnState.getSpiceLineage(s.f)); + assertEquals(1, ButterChurnState.incrementChurnCount(s.f)); + assertFalse(ButterChurnState.isReady(s.f, 2)); + assertEquals(2, ButterChurnState.incrementChurnCount(s.f)); + assertTrue(ButterChurnState.isReady(s.f, 2)); + ButterChurnState.clearExtras(s.f); + assertFalse(ButterChurnState.hasExtras(s.f)); + assertEquals(milk, ButterChurnState.getMilkLineage(s.f)); + ButterChurnState.setSpice(s.f, "Pepper", 1, -1); + assertTrue(ButterChurnState.hasExtras(s.f)); + assertEquals(0, ButterChurnState.getSpiceFreshness(s.f)); + ButterChurnState.clear(s.f); + assertEquals(Map.of("unrelated", "keep"), s.variables); + } + + @Test void agingIgnoresMissingMilkFutureClocksAndSubsecondIntervalsThenRetainsFractions() { + ButterChurnState.tickAge(s.f); assertTrue(s.variables.isEmpty()); + s.place("input_1", new ItemStack(Material.MILK_BUCKET)); + s.variables.put(ButterChurnState.VAR_LAST_UPDATE, System.currentTimeMillis() + 60_000); + ButterChurnState.tickAge(s.f); assertEquals(0, ButterChurnState.getDairyFreshness(s.f)); + s.variables.put(ButterChurnState.VAR_LAST_UPDATE, System.currentTimeMillis() - 50); + ButterChurnState.tickAge(s.f); assertEquals(0, ButterChurnState.getDairyFreshness(s.f)); + MemoryConfiguration milkConfig = new MemoryConfiguration(); + milkConfig.set("age.freshness", 4); + e.templates.put("milk_bucket", new FoodItem("milk_bucket", milkConfig)); + s.variables.put(ButterChurnState.VAR_LAST_UPDATE, System.currentTimeMillis() - 2_100); + ButterChurnState.tickAge(s.f); + assertEquals(0, ButterChurnState.getDairyFreshness(s.f)); + assertEquals(0.5, s.variables.get(ButterChurnState.VAR_FRESHNESS_REMAINDER)); + s.variables.put(ButterChurnState.VAR_LAST_UPDATE, System.currentTimeMillis() - 2_100); + ButterChurnState.tickAge(s.f); + assertEquals(1, ButterChurnState.getDairyFreshness(s.f)); + assertEquals(0.0, s.variables.get(ButterChurnState.VAR_FRESHNESS_REMAINDER)); + } + + @Test void placementCreatesTheMissingStickButPreservesExistingModels() { + handler.onPlace(new FurniturePlaceEvent(e.station("other").f, e.player)); + s.define("stick"); handler.onPlace(new FurniturePlaceEvent(s.f, e.player)); + assertEquals(Material.STICK, s.active.get("stick").getCurrentItem().getType()); + PlacedSlot stick = s.active.get("stick"); clearInvocations(stick); + handler.onPlace(new FurniturePlaceEvent(s.f, e.player)); verify(stick, never()).forceModel(any()); + s.active.clear(); s.definitions.clear(); + handler.onPlace(new FurniturePlaceEvent(s.f, e.player)); assertTrue(s.active.isEmpty()); + when(s.f.getType()).thenReturn(null); + handler.onPlace(new FurniturePlaceEvent(s.f, e.player)); assertTrue(s.active.isEmpty()); + } + + @Test void aLoadedStickSlotWithoutItsItemIsRebuiltAndUnusableCarriedFurnitureCannotCollect() { + milk(); s.define("stick"); s.place("stick", null); + handler.onPlace(new FurniturePlaceEvent(s.f, e.player)); + assertEquals(Material.STICK, s.active.get("stick").getCurrentItem().getType()); + Station carried = e.station("other"); when(e.manager.getByCarrier(e.player)).thenReturn(carried.f); + handler.onInteract(s.interact()); assertTrue(s.active.containsKey("input_1")); assertTrue(carried.active.isEmpty()); + Station missingType = e.station("plate"); when(missingType.f.getType()).thenReturn(null); when(e.manager.getByCarrier(e.player)).thenReturn(missingType.f); + handler.onInteract(s.interact()); assertTrue(s.active.containsKey("input_1")); assertTrue(missingType.active.isEmpty()); + } + + @Test void addingMilkSnapshotsItsMetadataAndReturnsTheEmptyBucket() { + s.define("input_1"); + FoodItem milk = e.food("milk_bucket", "dairy", "Goat", 4, 9); + milk.setLineage(IngredientLineage.ofMain("Goat")); + var event = s.add(e.stack(milk, Material.MILK_BUCKET, 1), "input_1"); + handler.onMilkAdd(event); + assertFalse(event.isCancelled()); + assertEquals(4, ButterChurnState.getMilkQuality(s.f)); + assertEquals(9, ButterChurnState.getDairyFreshness(s.f)); + assertEquals("Goat", ButterChurnState.getMilkOrigin(s.f)); + assertEquals(milk.getLineage(), ButterChurnState.getMilkLineage(s.f)); + assertEquals(Material.BUCKET, e.player.getInventory().getItemInMainHand().getType()); + verify(e.manager).markDirty(s.f); + s.place("input_1", event.getItem()); + var duplicate = s.add(event.getItem(), "input_1"); handler.onMilkAdd(duplicate); + assertTrue(duplicate.isCancelled()); assertTrue(e.messages.contains("The churn already has milk.")); + s.active.clear(); handler.onMilkAdd(s.add(new ItemStack(Material.MILK_BUCKET), "input_1")); + assertEquals(DairyOrigin.COW, ButterChurnState.getMilkOrigin(s.f)); + handler.onMilkAdd(s.add(new ItemStack(Material.STONE), "decor")); + handler.onMilkAdd(e.station("other").add(new ItemStack(Material.STONE), "input_1")); + } + + @Test void plateAndChurnEventsRejectWrongFoodsAndKeepRenderedButter() { + Station plate = e.station("plate"); + var churnTake = s.take(null, "input_1"); handler.onTake(churnTake); assertTrue(churnTake.isCancelled()); + var empty = plate.take(null, "input_1"); handler.onTake(empty); assertTrue(empty.isCancelled()); + var stone = plate.add(new ItemStack(Material.STONE), "input_1"); handler.onPlateAdd(stone); assertTrue(stone.isCancelled()); + FoodItem carrot = e.food("carrot", "vegetable", "Carrot", 3, 0); + var wrong = plate.take(e.stack(carrot, Material.CARROT, 1), "input_1"); handler.onTake(wrong); assertTrue(wrong.isCancelled()); + var wrongAdd = plate.add(wrong.getItem(), "input_1"); handler.onPlateAdd(wrongAdd); assertTrue(wrongAdd.isCancelled()); + FoodItem butter = e.food("butter", "dairy", "Cow", 4, 0); + ItemStack stack = e.stack(butter, Material.GOLD_NUGGET, 1); + var add = plate.add(stack, "input_1"); handler.onPlateAdd(add); assertFalse(add.isCancelled()); assertNotNull(add.getItem()); + var take = plate.take(stack, "input_1"); handler.onTake(take); assertFalse(take.isCancelled()); assertNotNull(take.getItem()); + e.updater.when(() -> ItemUpdater.applyItemUpdate(any(), any(), any())).thenReturn(null); + handler.onPlateAdd(plate.add(stack, "input_1")); handler.onTake(plate.take(stack, "input_1")); + Station other = e.station("other"); + handler.onPlateAdd(other.add(stack, "input_1")); handler.onTake(other.take(stack, "input_1")); + } + + @Test void anEmptyChurnHidesItsStickAndCannotConsumeHeldExtras() { + s.define("stick"); + handler.onInteract(s.interact()); assertTrue(s.active.isEmpty()); + e.hold(new ItemStack(Material.STONE)); + handler.onInteract(s.interact()); assertEquals(1, e.player.getInventory().getItemInMainHand().getAmount()); + handler.onInteract(e.station("other").interact()); + } + + @Test void saltAndSpiceAreConsumedOnceAndPreserveTheirQualityFreshnessAndLineage() { + milk(); + FoodItem salt = e.food("salt", "salt", "Salt", 4, 0); salt.setLineage(IngredientLineage.ofExtra("Salt")); + e.hold(e.stack(salt, Material.SUGAR, 2)); handler.onInteract(s.interact()); + assertTrue(ButterChurnState.hasSalt(s.f)); assertEquals(4, ButterChurnState.getSaltQuality(s.f)); + assertEquals(1, e.player.getInventory().getItemInMainHand().getAmount()); + assertEquals(salt.getLineage(), ButterChurnState.getSaltLineage(s.f)); + handler.onInteract(s.interact()); assertTrue(e.messages.contains("Already added salt.")); + FoodItem spice = e.food("spice", "spice", "Rosemary", 3, 7); spice.setLineage(IngredientLineage.ofExtra("Rosemary")); + e.hold(e.stack(spice, Material.GREEN_DYE, 1)); handler.onInteract(s.interact()); + assertEquals("Rosemary", ButterChurnState.getSpiceOrigin(s.f)); assertEquals(7, ButterChurnState.getSpiceFreshness(s.f)); + assertEquals(spice.getLineage(), ButterChurnState.getSpiceLineage(s.f)); + assertTrue(e.player.getInventory().getItemInMainHand().getType().isAir()); + e.hold(e.stack(spice, Material.GREEN_DYE, 2)); handler.onInteract(s.interact()); assertTrue(e.messages.contains("Already added spice.")); + ButterChurnState.clearExtras(s.f); + for (String origin : Arrays.asList(null, "")) { + e.hold(e.stack(e.food("bad-spice", "spice", origin, 2, 0), Material.GREEN_DYE, 2)); + handler.onInteract(s.interact()); assertFalse(ButterChurnState.hasSpice(s.f)); + } + e.hold(new ItemStack(Material.STONE)); handler.onInteract(s.interact()); assertFalse(ButterChurnState.hasExtras(s.f)); + } + + @Test void churningRequiresItsDisplayUsesCooldownAndAnimatesBackToTheOriginalPosition() { + milk(); s.define("stick"); PlacedSlot stick = s.place("stick", new ItemStack(Material.STICK)); + UUID displayId = UUID.randomUUID(); when(stick.getDisplayStandId()).thenReturn(displayId); + handler.onInteract(s.interact()); assertEquals(0, ButterChurnState.getChurnCount(s.f)); + ItemDisplayMock display = e.display(displayId); + Transformation initial = display.getTransformation(); + handler.onInteract(s.interact()); assertEquals(1, ButterChurnState.getChurnCount(s.f)); assertTrue(e.messages.contains("Churn 1/3")); + handler.onInteract(s.interact()); assertEquals(1, ButterChurnState.getChurnCount(s.f)); + e.server.getScheduler().performTicks(14); + assertEquals(initial.getTranslation(), display.getTransformation().getTranslation()); + handler.onBreak(new FurnitureBreakEvent(s.f, e.player)); + ButterChurnState.setChurnCount(s.f, 2); handler.onInteract(s.interact()); + assertEquals(3, ButterChurnState.getChurnCount(s.f)); assertTrue(e.messages.contains("Butter ready - use a butter plate to collect.")); + display.remove(); e.server.getScheduler().performOneTick(); + handler.onBreak(new FurnitureBreakEvent(s.f, e.player)); + ItemDisplayMock replacement = e.display(displayId); handler.onInteract(s.interact()); + assertTrue(e.messages.contains("Use a butter plate to collect.")); + handler.onBreak(new FurnitureBreakEvent(e.station("other").f, e.player)); + } + + @Test void aChurnDefinitionWithoutAStickCannotAdvanceTheBatch() { + milk(); s.define("decoration"); s.place("decoration", new ItemStack(Material.OAK_PLANKS)); + handler.onInteract(s.interact()); + assertEquals(0, ButterChurnState.getChurnCount(s.f)); assertTrue(s.active.containsKey("input_1")); + } + + @Test void collectingButterWaitsForReadinessThenFillsTheCarriedPlateAndClearsMilk() { + milk(); s.define("stick"); + Station plate = e.station("plate"); plate.define("butter"); + when(e.manager.getByCarrier(e.player)).thenReturn(plate.f); + handler.onInteract(s.interact()); assertTrue(e.messages.getLast().contains("times first")); + ButterChurnState.setChurnCount(s.f, 3); + e.templates.remove("butter"); handler.onInteract(s.interact()); assertTrue(e.messages.contains("Could not create butter.")); + e.templates.put("butter", e.food("butter", "dairy", "Cow", 1, 0)); + e.display(s.id); + handler.onInteract(s.interact()); + assertNotNull(plate.active.get("butter").getCurrentItem()); + assertFalse(s.active.containsKey("input_1")); assertTrue(s.variables.isEmpty()); + verify(plate.active.get("butter")).followParentTransform(any()); + verify(e.manager).markDirty(plate.f); + } + + @Test void collectingCannotOverwriteButterAlreadyOnThePlate() { + milk(); s.define("stick"); ButterChurnState.setChurnCount(s.f, 3); + Station plate = e.station("plate"); plate.define("butter"); + ItemStack previous = e.stack(e.food("butter", "dairy", "Goat", 4, 6), Material.GOLD_NUGGET, 1); + plate.place("butter", previous); when(e.manager.getByCarrier(e.player)).thenReturn(plate.f); + handler.onInteract(s.interact()); + assertEquals(previous, plate.active.get("butter").getCurrentItem(), "An occupied plate must retain its existing butter"); + assertTrue(s.active.containsKey("input_1")); assertEquals(3, ButterChurnState.getChurnCount(s.f)); + } + + @Test void collectingRejectsABuiltItemWithoutCookingMetadata() { + milk(); s.define("stick"); ButterChurnState.setChurnCount(s.f, 3); + Station plate=e.station("plate"); plate.define("butter"); when(e.manager.getByCarrier(e.player)).thenReturn(plate.f); + e.builder.when(()->ItemBuilder.buildComposedWithQuality(any(),anyInt())).thenReturn(new ItemStack(Material.PAPER)); + handler.onInteract(s.interact()); + assertTrue(s.active.containsKey("input_1")); assertTrue(plate.active.isEmpty()); + assertEquals("Could not create butter.",e.messages.getLast()); + } + + @Test void collectingOntoAPlateWithoutOutputSlotsCannotConsumeTheMilk() { + milk(); s.define("stick"); ButterChurnState.setChurnCount(s.f, 3); + Station plate = e.station("plate"); when(e.manager.getByCarrier(e.player)).thenReturn(plate.f); + handler.onInteract(s.interact()); + assertTrue(s.active.containsKey("input_1"), "A plate with no configured output cannot receive or consume a batch"); + assertEquals(3, ButterChurnState.getChurnCount(s.f)); assertTrue(plate.active.isEmpty()); + } + + @Test void collectingKeepsTheBuiltButterWhenItsOptionalFurnitureRenderIsUnavailable() { + milk(); s.define("stick"); ButterChurnState.setChurnCount(s.f, 3); + Station plate = e.station("plate"); plate.define("butter"); when(e.manager.getByCarrier(e.player)).thenReturn(plate.f); + e.updater.when(() -> ItemUpdater.applyItemUpdate(any(), any(), any())).thenReturn(null); + handler.onInteract(s.interact()); + assertNotNull(plate.active.get("butter").getCurrentItem(), "Rendering failure must not replace a completed batch with null"); + assertEquals("butter", e.resolve(plate.active.get("butter").getCurrentItem()).getId()); assertFalse(s.active.containsKey("input_1")); + } + + @Test void missingOptionalIngredientTemplatesLeaveTheBatchAvailableForRepair() { + e.templates.remove("seasoning_1"); + assertNull(ButterItems.fromMilkSnapshot(e.player, 3, 0, "Cow", true, 2, null, 1, 0)); + e.templates.put("seasoning_1", e.food("seasoning_1", "salt", "Salt", 2, 0)); e.templates.remove("spice_1"); + assertNull(ButterItems.fromMilkSnapshot(e.player, 3, 0, "Cow", false, 2, "Rosemary", 1, 0)); + } + + @Test void anAirModelBuiltByTheRealItemBuilderCannotConsumeACompletedBatch() { + milk(); s.define("stick"); ButterChurnState.setChurnCount(s.f, 3); + Station plate = e.station("plate"); plate.define("butter"); when(e.manager.getByCarrier(e.player)).thenReturn(plate.f); + e.templates.get("butter").setModel(new FoodModel(new ItemStack(Material.AIR))); + e.builder.close(); // Exercise the actual model renderer and metadata writer for this invalid config. + handler.onInteract(s.interact()); + assertTrue(s.active.containsKey("input_1")); assertEquals(3, ButterChurnState.getChurnCount(s.f)); assertTrue(plate.active.isEmpty()); assertTrue(e.messages.contains("Could not create butter.")); + } + + @Test void missingSaltedTrackLeavesTheBatchAvailableForConfigurationRepair() { + TrackLoader.oList.removeIf(track -> track.getId().equals("butter_salted")); + assertNull(ButterItems.fromMilkSnapshot(e.player, 3, 0, "Cow", true, 2, null, 1, 0)); + } + + @Test void missingSpicedTrackLeavesTheBatchAvailableForConfigurationRepair() { + TrackLoader.oList.removeIf(track -> track.getId().equals("butter_spiced")); + assertNull(ButterItems.fromMilkSnapshot(e.player, 3, 0, "Cow", false, 2, "Rosemary", 1, 0)); + } + + @Test void emptyAndAirPlateSlotsReceiveIndependentCopiesOfTheFinishedButter() { + milk(); s.define("stick"); ButterChurnState.setChurnCount(s.f, 3); + Station plate = e.station("plate"); plate.define("first"); plate.define("second"); + plate.place("first", new ItemStack(Material.AIR)); plate.place("second", null); + when(e.manager.getByCarrier(e.player)).thenReturn(plate.f); handler.onInteract(s.interact()); + ItemStack first = plate.active.get("first").getCurrentItem(), second = plate.active.get("second").getCurrentItem(); + assertNotNull(first); assertEquals(first, second); assertNotSame(first, second); + first.setAmount(2); assertEquals(1, second.getAmount()); assertFalse(s.active.containsKey("input_1")); + } + + @Test void loadedChurnsResumeAgingAndEmptyStaleChurnsResetOnTheirOwnChunk() { + s.define("stick"); milk(); + Station stale = e.station("churn-stale"); ButterChurnState.setChurnCount(stale.f, 1); + Station other = e.station("other"); + Station distant = e.station("churn-distant"); + when(distant.f.getLoc()).thenReturn(new Location(e.world, 80, 64, 80)); + Chunk far = mock(Chunk.class); when(e.world.getChunkAt(5, 5)).thenReturn(far); + handler.resumeLoadedChurns(); + assertEquals(0, ButterChurnState.getChurnCount(stale.f)); verify(e.manager).markDirty(stale.f); + clearInvocations(e.manager); + handler.onChunkLoad(new ChunkLoadEvent(e.chunk, false)); e.server.getScheduler().performOneTick(); + verify(e.manager).markDirty(s.f); verify(e.manager, never()).markDirty(distant.f); + e.server.getScheduler().performTicks(20); verify(e.manager, atLeast(2)).markDirty(s.f); + s.active.remove("input_1"); e.server.getScheduler().performTicks(20); + ButterChurnAging.start(null); ButterChurnAging.stop(null); ButterChurnAging.start(s.f); + s.place("input_1", new ItemStack(Material.MILK_BUCKET)); ButterChurnAging.start(s.f); ButterChurnAging.start(s.f); + ButterChurnAging.stopAll(); + } + + @Test void butterCompositionSupportsPlainSaltedSpicedMilkAndMissingTemplates() { + e.templates.remove("milk_bucket"); assertNull(ButterItems.fromMilkSnapshot(e.player, 3, 10)); + e.templates.put("milk_bucket", e.food("milk_bucket", "dairy", "Cow", 3, 0)); + assertNotNull(ButterItems.fromMilkSnapshot(e.player, 3, 10)); + assertEquals("Cow", e.lastBuilt.getOrigin()); assertEquals(10, e.lastBuilt.getTagTrack("freshness").getValue()); + assertNotNull(ButterItems.fromMilkSnapshot(e.player, 4, 12, "Goat", true, 2, "Rosemary", 3, 7)); + assertEquals("Goat", e.lastBuilt.getOrigin()); assertTrue(e.lastBuilt.hasTagTrack("butter_salted")); assertTrue(e.lastBuilt.hasTagTrack("butter_spiced")); + assertEquals(List.of("dairy", "salt", "spice"), e.composedInputs.stream().map(FoodItem::getCategory).toList()); + IngredientLineage milk = IngredientLineage.ofMain("Goat"), salt = IngredientLineage.ofExtra("Salt"), spice = IngredientLineage.ofExtra("Rosemary"); + ButterItems.fromMilkSnapshot(e.player, 4, -1, "Goat", true, 2, "Rosemary", 3, -1, milk, salt, spice); + assertEquals(List.of(milk, salt, spice), e.composedInputs.stream().map(FoodItem::getLineage).toList()); + ButterItems.fromMilkSnapshot(e.player, 4, 0, null, false, 2, " ", 3, 0); + assertEquals(1, e.composedInputs.size()); + assertEquals(0, ChurnExtras.readFreshness(null)); + assertEquals(0, ChurnExtras.readFreshness(e.food("plain", "salt", null, 1, -1))); + assertFalse(ChurnExtras.isChurnSalt(null)); assertFalse(ChurnExtras.isChurnSpice(null)); + assertFalse(ChurnExtras.isChurnSpice(e.stack(e.food("salt", "salt", "Salt", 3, 0), Material.SUGAR, 1))); + assertNotNull(ButterItems.fromMilkSnapshot(e.player, 3, 1, "Cow", true, 2, "Pepper", 3, 4, null, null, null)); + } + + private void milk() { s.place("input_1", new ItemStack(Material.MILK_BUCKET)); ButterChurnState.setMilkSnapshot(s.f, 3, 0, "Cow"); } + + /** Mock only external furniture/metadata rendering; items, tracks, inventories and clocks are real. */ + public static final class KitchenFixture implements AutoCloseable { + public final ServerMock server = MockBukkit.mock(); + public final Player player = mock(Player.class); + public final World world = mock(World.class); + public final Chunk chunk = mock(Chunk.class); + public final FurnitureManager manager = mock(FurnitureManager.class); + public final List messages = new ArrayList<>(); + public final Map placed = new LinkedHashMap<>(); + public final Map templates = new HashMap<>(); + public final MockedStatic cache; + public final MockedStatic foods; + public final MockedStatic updater; + public final MockedStatic converter; + public final MockedStatic builder; + public final ItemAPI itemApi = mock(ItemAPI.class, RETURNS_DEEP_STUBS); + public FoodItem lastBuilt; + public List composedInputs = List.of(); + private final List> mocks = new ArrayList<>(); + private final Map coded = new HashMap<>(); + private final List oldTracks = TrackLoader.oList; + private final Cooking oldPlugin = Cooking.plugin; + private final int oldChurnCount = ItemCache.butterChurnCount, oldMixCount = ItemCache.mixingStirCount, oldDuration = ItemCache.mixingStirDurationTicks; + private final float oldPivot = ItemCache.mixingStirPivotY; + private final NamespacedKey key = NamespacedKey.fromString("test:kitchen_food"); + public KitchenFixture() { + Cooking.plugin = mock(Cooking.class); + 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.getWorld()).thenReturn(world); when(player.getUniqueId()).thenReturn(UUID.randomUUID()); + doAnswer(call -> { messages.add(call.getArgument(0)); return null; }).when(player).sendMessage(anyString()); + when(world.getName()).thenReturn("kitchen"); when(world.getUID()).thenReturn(UUID.randomUUID()); when(world.getChunkAt(0, 0)).thenReturn(chunk); + when(world.getChunkAt(any(Location.class))).thenAnswer(call -> { Location loc = call.getArgument(0); return world.getChunkAt(loc.getBlockX() >> 4, loc.getBlockZ() >> 4); }); + when(manager.getPlacedFurniture()).thenReturn(placed); + InteractibleFurniture plugin = mock(InteractibleFurniture.class); when(plugin.getFurnitureManager()).thenReturn(manager); + scoped(InteractibleFurniture.class).when(InteractibleFurniture::getInstance).thenReturn(plugin); + MockedStatic furnitureCache = scoped(FurnitureCache.class); + furnitureCache.when(() -> FurnitureCache.isButterChurn(any())).thenAnswer(call -> kind(call.getArgument(0), "churn")); + furnitureCache.when(() -> FurnitureCache.isButterPlate(any())).thenAnswer(call -> kind(call.getArgument(0), "plate")); + furnitureCache.when(() -> FurnitureCache.isMixingBowl(any())).thenAnswer(call -> kind(call.getArgument(0), "bowl")); + TrackLoader.oList = new ArrayList<>(); new TrackLoader().load(new File("src/main/resources/tags.yml")); + for (String id : List.of("milk_bucket", "butter", "seasoning_1", "spice_1", "dough")) templates.put(id, food(id, "ingredient", null, 3, 0)); + scoped(FoodLoader.class).when(() -> FoodLoader.getByString(anyString())).thenAnswer(call -> templates.get(call.getArgument(0))); + foods = mockStatic(FoodItem.class, CALLS_REAL_METHODS); mocks.add(foods); + foods.when(() -> FoodItem.fromItem(any())).thenAnswer(call -> resolve(call.getArgument(0))); + updater = scoped(ItemUpdater.class); updater.when(() -> ItemUpdater.applyItemUpdate(any(), any(), any())).thenAnswer(call -> call.getArgument(0)); + converter = scoped(IngredientConverter.class); converter.when(() -> IngredientConverter.convertIfNeeded(any(), any())).thenAnswer(call -> call.getArgument(1)); + scoped(MilkBucketConverter.class).when(() -> MilkBucketConverter.convertIfNeeded(any(), any())).thenAnswer(call -> call.getArgument(1)); + MockedStatic snapshot = scoped(MilkBucketSnapshot.class); + snapshot.when(() -> MilkBucketSnapshot.readQuality(any(), any())).thenAnswer(call -> { FoodItem f = resolve(call.getArgument(1)); return f == null ? 1 : f.getQualityMin(); }); + snapshot.when(() -> MilkBucketSnapshot.readOrigin(any(), any())).thenAnswer(call -> { FoodItem f = resolve(call.getArgument(1)); return f == null ? "Cow" : f.getOrigin(); }); + snapshot.when(() -> MilkBucketSnapshot.readDairyFreshness(any(), any())).thenAnswer(call -> ChurnExtras.readFreshness(resolve(call.getArgument(1)))); + scoped(BucketItems.class).when(BucketItems::empty).thenAnswer(call -> new ItemStack(Material.BUCKET)); + scoped(CupItems.class).when(CupItems::emptyCup).thenAnswer(call -> new ItemStack(Material.GLASS_BOTTLE)); + scoped(TLibs.class).when(TLibs::getItemAPI).thenReturn(itemApi); + when(itemApi.getCreator().getItemFromPath(anyString())).thenAnswer(call -> new ItemStack(Material.STONE)); + cache = scoped(ItemCache.class); + cache.when(() -> ItemCache.getMixingModel(anyString())).thenAnswer(call -> "model." + call.getArgument(0)); + cache.when(() -> ItemCache.matchesMixingInput(anyString(), any())).thenAnswer(call -> { + String slot = call.getArgument(0); ItemStack item = call.getArgument(1); + return item != null && switch (slot) { case "flour" -> item.getType() == Material.WHEAT; case "water" -> item.getType() == Material.WATER_BUCKET || item.getType() == Material.HONEY_BOTTLE; case "yeast" -> item.getType() == Material.BROWN_MUSHROOM; default -> false; }; + }); + cache.when(() -> ItemCache.isCupOfWater(any())).thenAnswer(call -> { ItemStack item = call.getArgument(0); return item != null && item.getType() == Material.HONEY_BOTTLE; }); + scoped(CompositionQualityResolver.class).when(() -> CompositionQualityResolver.compose(any(), anyList(), any())).thenAnswer(call -> { + composedInputs = List.copyOf(call.getArgument(1)); + return new CompositionResult(3, 4, Map.of(), composedInputs, List.of(), List.of(), IngredientLineage.empty()); + }); + builder = scoped(ItemBuilder.class); builder.when(() -> ItemBuilder.buildComposedWithQuality(any(), anyInt())).thenAnswer(call -> { lastBuilt = call.getArgument(0); return stack(lastBuilt, Material.PAPER, 1); }); + ItemCache.butterChurnCount = 3; ItemCache.mixingStirCount = 3; ItemCache.mixingStirDurationTicks = 3; ItemCache.mixingStirPivotY = 0; + } + private static boolean kind(Furniture f, String prefix) { return f != null && f.getId() != null && f.getId().startsWith(prefix); } + public MockedStatic scoped(Class type) { MockedStatic mock = mockStatic(type); mocks.add(mock); return mock; } + public Station station(String id) { return new Station(this, id); } + public void hold(ItemStack item) { player.getInventory().setItemInMainHand(item); } + public FoodItem food(String id, String category, String origin, int quality, int freshness) { + MemoryConfiguration config = new MemoryConfiguration(); config.set("name", id); config.set("update", false); + FoodItem food = new FoodItem(id, config); food.setCategory(category); food.setOrigin(origin); food.setQualityRange(quality, quality); + food.setModel(new FoodModel(new ItemStack(Material.CARROT))); + if (freshness >= 0) { TagTrack track = new TagTrack(TrackLoader.getByString("freshness")); track.forceSetValue(freshness); food.addOrModifyTrack(track); } + return food; + } + public ItemStack stack(FoodItem food, Material material, int amount) { + ItemStack stack = new ItemStack(material, amount); String id = UUID.randomUUID().toString(); coded.put(id, food); + var meta = stack.getItemMeta(); meta.getPersistentDataContainer().set(key, PersistentDataType.STRING, id); stack.setItemMeta(meta); return stack; + } + public FoodItem resolve(ItemStack stack) { + if (stack == null || stack.getType().isAir() || !stack.hasItemMeta()) return null; + return coded.get(stack.getItemMeta().getPersistentDataContainer().get(key, PersistentDataType.STRING)); + } + public ItemDisplayMock display(UUID id) { + ItemDisplayMock display = new ItemDisplayMock(server, id); display.setTransformation(new Transformation(new Vector3f(), new Quaternionf(), new Vector3f(1, 1, 1), new Quaternionf())); + server.registerEntity(display); return display; + } + @Override public void close() { + ButterChurnAging.stopAll(); for (Furniture f : placed.values()) MixingBowlAnimation.clearAnimating(f); + MockBukkit.unmock(); for (int i = mocks.size() - 1; i >= 0; i--) if (!mocks.get(i).isClosed()) mocks.get(i).close(); + TrackLoader.oList = oldTracks; Cooking.plugin = oldPlugin; + ItemCache.butterChurnCount = oldChurnCount; ItemCache.mixingStirCount = oldMixCount; ItemCache.mixingStirDurationTicks = oldDuration; ItemCache.mixingStirPivotY = oldPivot; + } + } + + public static final class Station { + public final Furniture f = mock(Furniture.class); + public final FurnitureType type = mock(FurnitureType.class); + public final UUID id = UUID.randomUUID(); + public final Map variables = new LinkedHashMap<>(); + public final Map definitions = new LinkedHashMap<>(); + public final Map active = new LinkedHashMap<>(); + private final KitchenFixture e; + Station(KitchenFixture e, String name) { + this.e = e; when(f.getId()).thenReturn(name); when(f.getEntityId()).thenReturn(id); when(f.getType()).thenReturn(type); + when(f.getLoc()).thenReturn(new Location(e.world, 1, 64, 1)); when(f.getVariables()).thenReturn(variables); + when(type.getSlots()).thenReturn(definitions); when(type.getSlot(anyString())).thenAnswer(call -> definitions.get(call.getArgument(0))); + when(f.getActiveSlots()).thenReturn(active); when(f.hasActiveSlot(anyString())).thenAnswer(call -> active.containsKey(call.getArgument(0))); + when(f.getActiveSlot(anyString())).thenAnswer(call -> Optional.ofNullable(active.get(call.getArgument(0)))); + when(f.getOrCreatePlacedSlot(anyString())).thenAnswer(call -> { String id = call.getArgument(0); return active.containsKey(id) ? active.get(id) : place(id, null); }); + doAnswer(call -> { active.remove(call.getArgument(0)); return null; }).when(f).removeActiveSlot(anyString()); + e.placed.put(id, f); + } + public SlotDefinition define(String id) { + return definitions.computeIfAbsent(id, key -> { SlotDefinition def = mock(SlotDefinition.class); when(def.getId()).thenReturn(key); return def; }); + } + public PlacedSlot place(String id, ItemStack item) { + PlacedSlot slot = mock(PlacedSlot.class); ItemStack[] value = {item}; + when(slot.getDisplayStandId()).thenReturn(UUID.randomUUID()); + when(slot.getCurrentItem()).thenAnswer(call -> value[0]); + doAnswer(call -> { value[0] = call.getArgument(0); active.put(id, slot); return null; }).when(slot).forceModel(any()); + doAnswer(call -> { value[0] = null; active.remove(id); return null; }).when(slot).clearModel(); + active.put(id, slot); return slot; + } + public FurnitureInteractEvent interact() { return new FurnitureInteractEvent(e.player, f); } + public FurnitureSlotItemAddEvent add(ItemStack stack, String slot) { return new FurnitureSlotItemAddEvent(e.player, f, define(slot), stack); } + public FurnitureSlotItemTakeEvent take(ItemStack stack, String slot) { return new FurnitureSlotItemTakeEvent(e.player, f, define(slot), stack); } + } +} diff --git a/src/test/java/net/tfminecraft/cooking/cooking/CookingReferencesTest.java b/src/test/java/net/tfminecraft/cooking/cooking/CookingReferencesTest.java new file mode 100644 index 0000000..23ffd49 --- /dev/null +++ b/src/test/java/net/tfminecraft/cooking/cooking/CookingReferencesTest.java @@ -0,0 +1,777 @@ +package net.tfminecraft.cooking.cooking; + +import static org.junit.jupiter.api.Assertions.*; +import static org.mockito.ArgumentMatchers.*; +import static org.mockito.Mockito.*; + +import java.io.File; +import java.util.ArrayList; +import java.util.HashMap; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; +import java.util.Optional; + +import org.bukkit.Location; +import org.bukkit.Material; +import org.bukkit.NamespacedKey; +import org.bukkit.World; +import org.bukkit.configuration.MemoryConfiguration; +import org.bukkit.entity.Player; +import org.bukkit.inventory.ItemStack; +import org.bukkit.persistence.PersistentDataType; +import org.bukkit.util.Vector; +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.mockito.MockedStatic; + +import net.tfminecraft.cooking.Cooking; +import net.tfminecraft.cooking.cache.ItemCache; +import net.tfminecraft.cooking.cup.BucketItems; +import net.tfminecraft.cooking.cup.CupItems; +import net.tfminecraft.cooking.enums.Method; +import net.tfminecraft.cooking.heat.HeatSources; +import net.tfminecraft.cooking.item.FoodItem; +import net.tfminecraft.cooking.item.data.CookParameter; +import net.tfminecraft.cooking.item.model.FoodModel; +import net.tfminecraft.cooking.item.tag.TagTrack; +import net.tfminecraft.cooking.loader.TrackLoader; +import net.tfminecraft.cooking.quality.CompositionContext; +import net.tfminecraft.cooking.quality.CompositionQualityResolver; +import net.tfminecraft.cooking.quality.CompositionResult; +import net.tfminecraft.cooking.utils.Encoder; +import net.tfminecraft.cooking.utils.FoodParser; +import net.tfminecraft.cooking.utils.IngredientConverter; +import net.tfminecraft.cooking.utils.InventoryAdder; +import net.tfminecraft.cooking.utils.ItemBuilder; +import net.tfminecraft.cooking.utils.ItemUpdater; +import net.tfminecraft.cooking.utils.Keys; +import net.tfminecraft.interactiblefurniture.InteractibleFurniture; +import net.tfminecraft.interactiblefurniture.events.FurnitureInteractEvent; +import net.tfminecraft.interactiblefurniture.events.FurnitureSlotItemAddEvent; +import net.tfminecraft.interactiblefurniture.events.FurnitureSlotItemTakeEvent; +import net.tfminecraft.interactiblefurniture.furniture.Furniture; +import net.tfminecraft.interactiblefurniture.furniture.FurnitureType; +import net.tfminecraft.interactiblefurniture.furniture.PlacedSlot; +import net.tfminecraft.interactiblefurniture.furniture.SlotDefinition; +import net.tfminecraft.interactiblefurniture.furniture.data.DisplayData; +import net.tfminecraft.tlibs.TLibs; +import net.tfminecraft.tlibs.objects.api.ItemAPI; + +class CookingReferencesTest { + Environment env; + + @BeforeEach void setUp() { env = new Environment(); } + @AfterEach void tearDown() { env.close(); } + + @Test + void referenceStoresItsStationAndExpiresOnlyTimedSecondaries() { + CookingReference ref = new CookingReference(env.furniture, Method.POT); + assertSame(env.furniture, ref.getFurniture()); + assertSame(ref.slots, ref.getSlots()); + assertEquals(Method.POT, ref.getMethod()); + assertTrue(ref.isEmpty()); + PlacedSlot butter = env.place("butter", new ItemStack(Material.GOLD_NUGGET)); + ref.secondaries.put("butter", 2); + ref.secondaries.put("liquid", -1); + ref.secondaries.put("missing", 1); + assertFalse(ref.isEmpty()); + ref.tick(); + assertEquals(1, ref.getSecondary("butter")); + assertEquals(-1, ref.getSecondary("liquid")); + assertFalse(ref.secondaries.containsKey("missing")); + ref.tick(); + verify(butter).clearModel(); + assertFalse(ref.secondaries.containsKey("butter")); + ref.remove(); + assertTrue(ref.getSecondaries().isEmpty()); + ref.interact(env.interaction()); + } + + @Test + void addingAValidIngredientConsumesOneAndUpdatesTheFurnitureModel() { + CookingReference ref = new CookingReference(env.furniture, Method.POT); + FoodItem carrot = env.food("carrot", "vegetable", "Carrot", Method.POT); + ItemStack stack = env.stack(carrot, Material.CARROT, 4); + assertFalse(ref.add("decoration", stack)); + assertFalse(ref.add("input_1", new ItemStack(Material.STONE))); + assertTrue(ref.add("input_1", stack)); + assertEquals(3, stack.getAmount()); + assertSame(carrot, ref.getSlot("input_1")); + assertTrue(ref.hasSlot("vegetable")); + assertFalse(ref.hasSlot("meat")); + assertEquals(1, env.active.get("input_1").getCurrentItem().getAmount()); + assertEquals(List.of("aa5500"), ref.getColours()); + assertFalse(ref.add("input_1", stack)); + } + + @Test + void rejectedFurnitureSlotDoesNotCreateAnInvisibleIngredient() { + CookingReference ref = new CookingReference(env.furniture, Method.POT); + ItemStack stack = env.stack(env.food("carrot", "vegetable", "Carrot", Method.POT), Material.CARROT, 2); + assertFalse(ref.add("input_missing", stack)); + assertTrue(ref.isEmpty(), "A rejected add must not leave a ghost ingredient"); + assertEquals(2, stack.getAmount()); + when(env.furniture.getType()).thenReturn(null); + assertFalse(ref.add("input_1", stack)); + assertTrue(ref.isEmpty()); + } + + @Test + void failedIngredientRenderingDoesNotConsumeFoodOrCreateAnEmptyDisplay() { + CookingReference ref = new CookingReference(env.furniture, Method.POT); + ItemStack stack = env.stack(env.food("carrot", "vegetable", "Carrot", Method.POT), Material.CARROT, 2); + env.updater.when(() -> ItemUpdater.applyItemUpdate(any(), any(), any())).thenReturn(null); + assertFalse(ref.add("input_1", stack)); + assertTrue(ref.isEmpty()); + assertFalse(env.active.containsKey("input_1")); + assertEquals(2, stack.getAmount()); + } + + @Test + void coloursAndIngredientNamesRemainUniqueAndPutMinorIngredientsLast() { + CookingReference ref = new CookingReference(env.furniture, Method.POT); + ref.slots = new LinkedHashMap<>(); + ref.slots.put("null", null); + ref.slots.put("no-origin", env.food("unknown", "vegetable", null)); + ref.slots.put("none", env.food("none", "vegetable", "none")); + ref.slots.put("mixed", env.food("mixed", "vegetable", "Mixed")); + ref.slots.put("flour", env.food("flour", "flour", "Wheat")); + ref.slots.put("more-flour", env.food("flour2", "flour", "Wheat")); + ref.slots.put("salt", env.food("salt", "salt", "Salt")); + ref.slots.put("carrot", env.food("carrot", "vegetable", " Carrot ")); + ref.slots.put("carrot2", env.food("carrot2", "vegetable", "Carrot")); + assertEquals(List.of("Carrot", "Wheat"), ref.getIngredients()); + assertEquals("Carrot and Wheat Soup", ref.getName("Soup")); + ref.addColour("aa5500"); + ref.addColour("aa5500"); + ref.addColour("ffffff"); + ref.removeColour("ffffff"); + assertEquals(List.of("aa5500"), ref.getColours()); + assertNotNull(ref.getLiquidItemPath()); + FoodItem template = env.foodNamed("soup", "soup", "Mixed", "{fillers}{ingredients}{prefixes}{colour}"); + assertTrue(ref.applyNameTemplate(template, "aa5500", "Soup").contains("Carrot and Wheat")); + ref.slots.clear(); + assertTrue(ref.applyNameTemplate(template, "ffffff", "Soup").contains("Mixed Soup")); + } + + @Test + void rebuildingKeepsOnlySupportedRawFoodAndRestoresFryingButter() { + CookingReference ref = new CookingReference(env.furniture, Method.FRYING_PAN); + FoodItem raw = env.food("raw", "meat", "Beef", Method.FRYING_PAN); + FoodItem running = env.food("running", "meat", "Chicken", Method.FRYING_PAN); + running.getCookData().restore(Method.FRYING_PAN, 4); + FoodItem cooked = env.food("cooked", "meat", "Pork", Method.FRYING_PAN); + cooked.getTagTrack("cooked").forceSetValue(1); + env.place("input_1", env.stack(raw, Material.BEEF, 1)); + env.place("input_2", env.stack(running, Material.CHICKEN, 1)); + env.place("input_3", env.stack(cooked, Material.PORKCHOP, 1)); + env.place("input_4", env.stack(env.food("wrong", "meat", "Fish", Method.POT), Material.COD, 1)); + env.place("input_5", env.stack(env.food("inert", "flour", "Wheat"), Material.WHEAT, 1)); + env.place("butter", new ItemStack(Material.GOLD_NUGGET)); + env.place("liquid", null); + env.define("air"); env.place("air", new ItemStack(Material.AIR)); + env.define("stone"); env.place("stone", new ItemStack(Material.STONE)); + ref.danger = 8; ref.colours.add("old"); + ref.rebuildFromFurniture(); + assertEquals(2, ref.slots.size()); + assertSame(raw, ref.getSlot("input_1")); + assertEquals(Method.FRYING_PAN, raw.getCookData().getCurrentMethod()); + assertEquals(4, running.getCookData().getCurrentTime()); + assertEquals(30, ref.getSecondary("butter")); + assertEquals(0, ref.danger); + assertTrue(ref.colours.isEmpty()); + new CookingReference(null, Method.POT).rebuildFromFurniture(); + when(env.furniture.getType()).thenReturn(null); + ref.rebuildFromFurniture(); + assertTrue(ref.isEmpty()); + } + + @Test + void slotAdditionAllowsWhitelistedToolsButRejectsUncookableOrWrongStateFood() { + CookingReference ref = new CookingReference(env.furniture, Method.POT); + var tool = env.addEvent(new ItemStack(Material.STICK)); + ref.slotAdd(tool); + assertFalse(tool.isCancelled()); + FoodItem inert = env.food("salt", "salt", "Salt"); + var uncookable = env.addEvent(env.stack(inert, Material.SUGAR, 1)); + ref.slotAdd(uncookable); assertTrue(uncookable.isCancelled()); + var wrong = env.addEvent(env.stack(env.food("steak", "meat", "Beef", Method.FRYING_PAN), Material.BEEF, 1)); + ref.slotAdd(wrong); assertTrue(wrong.isCancelled()); + FoodItem cooked = env.food("cooked", "meat", "Beef", Method.POT); + cooked.getTagTrack("cooked").forceSetValue(1); + var done = env.addEvent(env.stack(cooked, Material.BEEF, 1)); + ref.slotAdd(done); assertTrue(done.isCancelled()); + FoodItem raw = env.food("raw", "meat", "Beef", Method.POT); + var accepted = env.addEvent(env.stack(raw, Material.BEEF, 1)); + ref.slotAdd(accepted); + assertFalse(accepted.isCancelled()); + assertSame(raw, ref.getSlot("input_1")); + assertEquals(Method.POT, raw.getCookData().getCurrentMethod()); + assertNotNull(accepted.getDisplayData()); + } + + @Test + void modelUpdatesHandleMissingSlotsMissingOutputAndUnrecognisedModels() { + CookingReference ref = new CookingReference(env.furniture, Method.POT); + FoodItem food = env.food("carrot", "vegetable", "Carrot", Method.POT); + ref.applySlotUpdate("input_1", food); + PlacedSlot placed = env.place("input_1", env.stack(food, Material.CARROT, 1)); + ref.applySlotUpdate("input_1", food); + verify(placed).applyDisplayData(any()); + env.updater.when(() -> ItemUpdater.applyItemUpdate(any(), any(), any())).thenReturn(null); + ref.applySlotUpdate("input_1", food); + env.updater.when(() -> ItemUpdater.applyItemUpdate(any(), any(), any())).thenReturn(new ItemStack(Material.STONE)); + ref.applySlotUpdate("input_1", food); + assertEquals(Material.STONE, placed.getCurrentItem().getType()); + verify(placed, times(1)).applyDisplayData(any()); + } + + @Test + void removingAHeatedSlotReturnsHotFoodAndClearingResetsState() { + CookingReference ref = new CookingReference(env.furniture, Method.POT); + ref.slotRemove(env.takeEvent(new ItemStack(Material.STONE))); + FoodItem food = env.food("carrot", "vegetable", "Carrot", Method.POT); + food.getCookData().restore(Method.POT, 6); + ItemStack stack = env.stack(food, Material.CARROT, 1); + ref.slots.put("input_1", food); + var take = env.takeEvent(stack); + ref.slotRemove(take); + assertFalse(take.isCancelled()); + assertTrue(food.hasTagTrack("warmth")); + assertNotNull(take.getItem()); + assertFalse(ref.slots.containsKey("input_1")); + PlacedSlot placed = env.place("input_1", stack); + PlacedSlot liquid = env.place("liquid", new ItemStack(Material.GLASS)); + ref.slots.put("input_1", food); ref.slots.put("missing", food); + ref.secondaries.put("liquid", -1); ref.colours.add("fff"); ref.danger = 3; + ref.clear(); + assertTrue(ref.isEmpty()); assertTrue(ref.colours.isEmpty()); assertEquals(0, ref.danger); + verify(placed).clearModel(); verify(liquid).clearModel(); + } + + @Test + void slotRemovalValidationHandlesUnsupportedFoodAndEarlyOrUnavailableUpdates() { + CookingReference ref = new CookingReference(env.furniture, Method.POT); + FoodItem inert = env.food("salt", "salt", "Salt"); + ref.slots.put("input_1", inert); + var rejected = env.takeEvent(env.stack(inert, Material.SUGAR, 1)); + ref.slotRemove(rejected); assertTrue(rejected.isCancelled()); + assertSame(inert, ref.getSlot("input_1")); + FoodItem pan = env.food("pan", "meat", "Beef", Method.FRYING_PAN); + ref.slots.put("input_1", pan); + var wrong = env.takeEvent(env.stack(pan, Material.BEEF, 1)); + ref.slotRemove(wrong); assertTrue(wrong.isCancelled()); + assertSame(pan, ref.getSlot("input_1")); + FoodItem food = env.food("carrot", "vegetable", "Carrot", Method.POT); + ItemStack stack = env.stack(food, Material.CARROT, 1); + ref.slots.put("input_1", food); + ref.slotRemove(env.takeEvent(stack)); + assertFalse(food.hasTagTrack("warmth")); + food.getCookData().restore(Method.POT, 6); + TrackLoader.oList.removeIf(track -> track.getId().equals("warmth")); + ref.slots.put("input_1", food); + var missingWarmth = env.takeEvent(stack); + ref.slotRemove(missingWarmth); + assertTrue(missingWarmth.isCancelled()); + assertSame(food, ref.getSlot("input_1")); + assertFalse(food.hasTagTrack("warmth")); + } + + @Test + void failedCookedItemRenderingCancelsTheTakeAndPreservesTheStationIngredient() { + CookingReference ref = new CookingReference(env.furniture, Method.POT); + FoodItem food = env.food("carrot", "vegetable", "Carrot", Method.POT); + food.getCookData().restore(Method.POT, 6); + ref.slots.put("input_1", food); + var event = env.takeEvent(env.stack(food, Material.CARROT, 1)); + env.updater.when(() -> ItemUpdater.applyItemUpdate(any(), any(), any())).thenReturn(null); + ref.slotRemove(event); + assertTrue(event.isCancelled()); + assertSame(food, ref.getSlot("input_1")); + } + + @Test + void sauceAcceptsOnlyDistinctApprovedAdditionsAfterLiquid() { + SauceReference ref = new SauceReference(env.furniture, Method.SAUCEPAN); + ItemStack salt = env.stack(env.food("salt", "salt", "Salt"), Material.SUGAR, 1); + assertFalse(ref.canAdd(env.player, salt)); + ref.secondaries.put("liquid", -1); + assertFalse(ref.canAdd(env.player, null)); + assertFalse(ref.canAdd(env.player, new ItemStack(Material.AIR))); + assertFalse(ref.canAdd(env.player, new ItemStack(Material.STONE))); + assertTrue(ref.canAdd(env.player, salt)); + ref.slots.put("salt", env.resolve(salt)); + assertFalse(ref.canAdd(env.player, salt)); + for (String category : List.of("sweetener", "garnish", "alcohol")) { + assertTrue(ref.canAdd(env.player, env.stack(env.food(category, category, category), Material.CARROT, 1))); + } + assertFalse(ref.canAdd(env.player, env.stack(env.food("beef", "meat", "Beef"), Material.BEEF, 1))); + } + + @Test + void sauceTicksOnlyWithHeatAndUpdatesAtCookingTransitions() { + SauceReference ref = new SauceReference(env.furniture, Method.SAUCEPAN); + FoodItem food = env.food("sauce", "sauce", "Tomato", Method.SAUCEPAN); + food.getCookData().start(Method.SAUCEPAN); + ref.slots.put("input_1", food); + ref.slots.put("inert", env.food("salt", "salt", "Salt")); + PlacedSlot slot = env.place("input_1", env.stack(food, Material.CARROT, 1)); + ref.tick(); assertEquals(0, food.getCookData().getCurrentTime()); + env.heated = true; + ref.tick(); ref.tick(); + assertEquals(2, food.getCookData().getCurrentTime()); + assertEquals(1, food.getTagTrack("cooked").getValue()); + verify(slot).forceModel(any()); + ref.clear(); ref.tick(); + SauceReference other = new SauceReference(env.furniture, Method.NONE); + other.secondaries.put("liquid", -1); other.tick(); + } + + @ParameterizedTest + @ValueSource(strings = {"HONEY_BOTTLE", "WHITE_DYE", "MILK_BUCKET"}) + void saucePoursSupportedContainersAndReturnsTheirEmptyVessel(String material) { + SauceReference ref = new SauceReference(env.furniture, Method.SAUCEPAN); + FoodItem milk = env.food("milk", "milk", "Milk"); + env.player.getInventory().setItemInMainHand(env.stack(milk, Material.valueOf(material), 1)); + ref.interact(env.interaction()); + assertEquals(-1, ref.getSecondary("liquid")); + assertEquals(material.equals("MILK_BUCKET") ? Material.BUCKET : Material.GLASS_BOTTLE, + env.player.getInventory().getItemInMainHand().getType()); + assertTrue(env.active.containsKey("liquid")); + assertFalse(ref.getColours().isEmpty()); + ref.clear(); assertTrue(ref.isEmpty()); + } + + @Test + void sauceRejectsWrongWaterAndUnconfiguredFurnitureWithoutConsumingAnything() { + SauceReference ref = new SauceReference(env.furniture, Method.SAUCEPAN); + ref.interact(env.interaction()); + for (Material material : List.of(Material.WATER_BUCKET, Material.POTION, Material.LAVA_BUCKET)) { + env.player.getInventory().setItemInMainHand(new ItemStack(material)); + ref.interact(env.interaction()); + assertTrue(ref.isEmpty()); + assertEquals(material, env.player.getInventory().getItemInMainHand().getType()); + } + env.definitions.remove("liquid"); + env.player.getInventory().setItemInMainHand(new ItemStack(Material.HONEY_BOTTLE)); + ref.interact(env.interaction()); + assertTrue(ref.isEmpty()); + ref.updateModel(); + when(env.furniture.getType()).thenReturn(null); + ref.interact(env.interaction()); ref.updateModel(); + assertTrue(ref.isEmpty()); + } + + @Test + void sauceUsesItsConfiguredLiquidModelAndMilkFallback() { + SauceReference ref = new SauceReference(env.furniture, Method.SAUCEPAN); + env.cache.when(() -> ItemCache.getLiquidModel(any(ItemStack.class))).thenReturn("v.white_stained_glass"); + env.player.getInventory().setItemInMainHand(new ItemStack(Material.WHITE_DYE)); + ref.interact(env.interaction()); + assertEquals(-1, ref.getSecondary("liquid")); + ref.clear(); + env.cache.when(() -> ItemCache.getLiquidModel(any(ItemStack.class))).thenReturn(" "); + env.cache.when(() -> ItemCache.getLiquidModel("v.milk_bucket")).thenReturn("v.milk_glass"); + env.player.getInventory().setItemInMainHand(new ItemStack(Material.MILK_BUCKET)); + ref.interact(env.interaction()); + assertEquals(-1, ref.getSecondary("liquid")); + ref.clear(); + ref.slots.put("input_1", env.food("salt", "salt", "Salt")); + env.player.getInventory().setItemInMainHand(new ItemStack(Material.AIR)); + ref.interact(env.interaction()); + assertEquals(1, ref.slots.size()); + } + + @ParameterizedTest + @ValueSource(strings = {"HONEY_BOTTLE", "MILK_BUCKET"}) + void stackedSauceContainersReturnExtrasOrDropThemWhenTheInventoryIsFull(String material) { + try (MockedStatic adder = mockStatic(InventoryAdder.class)) { + ItemStack leftover = new ItemStack(Material.BUCKET); + adder.when(() -> InventoryAdder.addItem(eq(env.player), any())).thenReturn(leftover); + SauceReference ref = new SauceReference(env.furniture, Method.SAUCEPAN); + env.player.getInventory().setItemInMainHand(new ItemStack(Material.valueOf(material), 2)); + ref.interact(env.interaction()); + assertEquals(1, env.player.getInventory().getItemInMainHand().getAmount()); + verify(env.world).dropItemNaturally(any(), same(leftover)); + } + } + + @Test + void sauceScoopCombinesQualityAndFlourMilkTagsThenClearsTheStation() { + SauceReference ref = new SauceReference(env.furniture, Method.SAUCEPAN); + ref.scoop(env.player, new ItemStack(Material.PAPER)); + env.player.getInventory().setItemInMainHand(env.stack(env.food("milk", "milk", "Milk"), Material.WHITE_DYE, 1)); + ref.interact(env.interaction()); + ref.slots.put("input_1", env.food("flour", "flour", "Wheat")); + ref.slots.put("input_2", env.food("sugar", "sweetener", "Sugar")); + ref.colours.add("ffffff"); + ref.scoop(env.player, new ItemStack(Material.PAPER)); + FoodItem output = env.lastBuilt; + assertEquals(1, output.getTagTrack("sauce_thickness").getValue()); + assertTrue(output.hasTagTrack("sauce_creamy")); + assertEquals(1, output.getTagTrack("sauce_cooked").getValue()); + assertTrue(output.hasTagTrack("sweet")); + assertEquals(72, env.lastQuality); + assertTrue(ref.isEmpty()); + assertNotNull(env.player.getInventory().getItemInMainHand().getItemMeta() + .getPersistentDataContainer().get(Keys.SAUCE_COLOUR, PersistentDataType.STRING)); + ref.secondaries.put("liquid", -1); + ref.colours.add("aa5500"); + ref.scoop(env.player, new ItemStack(Material.PAPER)); + assertEquals(0, env.lastBuilt.getTagTrack("sauce_thickness").getValue()); + assertFalse(env.lastBuilt.hasTagTrack("sauce_creamy")); + } + + @Test + void sauceInteractionAddsAnIngredientAndALadleScoopsTheResult() { + SauceReference ref = new SauceReference(env.furniture, Method.SAUCEPAN); + ref.secondaries.put("liquid", -1); + env.player.getInventory().setItemInMainHand(env.stack(env.food("salt", "salt", "Salt"), Material.SUGAR, 2)); + ref.interact(env.interaction()); + assertEquals(1, ref.slots.size()); + assertEquals(1, env.player.getInventory().getItemInMainHand().getAmount()); + env.player.getInventory().setItemInMainHand(new ItemStack(Material.PAPER)); + ref.interact(env.interaction()); + assertTrue(ref.isEmpty()); + assertTrue(env.lastBuilt.hasTagTrack("seasoning")); + } + + @Test + void fryingRejectsColdOrUnusableButterAndConsumesOneWhenWarm() { + FryingReference ref = new FryingReference(env.furniture, Method.FRYING_PAN); + ref.interact(env.interaction()); + env.player.getInventory().setItemInMainHand(new ItemStack(Material.STONE)); + ref.interact(env.interaction()); + env.player.getInventory().setItemInMainHand(new ItemStack(Material.GOLD_NUGGET)); + ref.interact(env.interaction()); assertTrue(ref.isEmpty()); + env.heated = true; + ref.interact(env.interaction()); assertTrue(ref.isEmpty()); + FoodItem butter = env.food("butter", "butter", "Butter"); + env.player.getInventory().setItemInMainHand(env.stack(butter, Material.GOLD_NUGGET, 3)); + var event = env.interaction(); + ref.interact(event); + assertTrue(event.isCancelled()); assertEquals(30, ref.getSecondary("butter")); + assertEquals(2, env.player.getInventory().getItemInMainHand().getAmount()); + ref.interact(env.interaction()); + assertEquals(2, env.player.getInventory().getItemInMainHand().getAmount()); + env.heated = false; ref.tick(); assertTrue(ref.isEmpty()); + } + + @Test + void fryingConvertsVanillaButterBeforeConsumingTheActualHandStack() { + FryingReference ref = new FryingReference(env.furniture, Method.FRYING_PAN); + env.heated = true; + ItemStack converted = env.stack(env.food("butter", "butter", "Butter"), Material.GOLD_NUGGET, 4); + env.converter.when(() -> IngredientConverter.convertIfNeeded(eq(env.player), any())).thenReturn(converted); + env.player.getInventory().setItemInMainHand(new ItemStack(Material.GOLD_NUGGET, 4)); + ref.interact(env.interaction()); + assertEquals(3, env.player.getInventory().getItemInMainHand().getAmount()); + assertEquals(30, ref.getSecondary("butter")); + } + + @Test + void fryingRejectsButterWhenTheFurnitureHasNoButterSlot() { + FryingReference ref = new FryingReference(env.furniture, Method.FRYING_PAN); + env.heated = true; + env.player.getInventory().setItemInMainHand(env.stack(env.food("butter", "butter", "Butter"), Material.GOLD_NUGGET, 1)); + env.definitions.remove("butter"); + ref.interact(env.interaction()); assertTrue(ref.isEmpty()); + when(env.furniture.getType()).thenReturn(null); + ref.interact(env.interaction()); assertTrue(ref.isEmpty()); + assertEquals(1, env.player.getInventory().getItemInMainHand().getAmount()); + } + + @Test + void aButterDisplayDisappearingDuringRebuildDoesNotCreateAQualityExtra() { + FryingReference ref = new FryingReference(env.furniture, Method.FRYING_PAN); + ItemStack butter = env.stack(env.food("butter", "butter", "Butter"), Material.GOLD_NUGGET, 1); + PlacedSlot slot = env.place("butter", butter); + when(slot.getCurrentItem()).thenReturn(butter, null); + assertDoesNotThrow(ref::rebuildFromFurniture); + assertTrue(ref.slots.isEmpty()); + } + + @Test + void takingRawFoodOutOfThePanDoesNotLeaveAnInvisibleCookingIngredient() { + FryingReference ref = new FryingReference(env.furniture, Method.FRYING_PAN); + FoodItem steak = env.food("steak", "meat", "Beef", Method.FRYING_PAN); + steak.getCookData().restore(Method.FRYING_PAN, 3); + ref.slots.put("input_1", steak); + var event = env.takeEvent(env.stack(steak, Material.BEEF, 1)); + ref.slotRemove(event); + assertFalse(event.isCancelled()); + assertFalse(ref.slots.containsKey("input_1"), "An accepted take removes the furniture slot too"); + assertNotNull(event.getItem()); + } + + @Test + void aRawPanIngredientStaysInItsSlotWhenRenderingFails() { + FryingReference ref = new FryingReference(env.furniture, Method.FRYING_PAN); + FoodItem steak = env.food("steak", "meat", "Beef", Method.FRYING_PAN); + steak.getCookData().restore(Method.FRYING_PAN, 3); + ref.slots.put("input_1", steak); + var event = env.takeEvent(env.stack(steak, Material.BEEF, 1)); + env.updater.when(() -> ItemUpdater.applyItemUpdate(any(), any(), any())).thenReturn(null); + ref.slotRemove(event); + assertTrue(event.isCancelled()); assertSame(steak, ref.getSlot("input_1")); + } + + @Test + void panRemovalFailureCancelsTheFurnitureTakeAndPreservesTheIngredient() { + FryingReference ref = new FryingReference(env.furniture, Method.FRYING_PAN); + FoodItem steak = env.food("steak", "meat", "Beef", Method.FRYING_PAN); + steak.getCookData().restore(Method.FRYING_PAN, 6); + ref.slots.put("input_1", steak); + TrackLoader.oList.removeIf(track -> track.getId().equals("warmth")); + var event = env.takeEvent(env.stack(steak, Material.BEEF, 1)); + ref.slotRemove(event); + assertTrue(event.isCancelled(), "Furniture must keep its display when the reference keeps its ingredient"); + assertSame(steak, ref.getSlot("input_1")); + } + + @Test + void rejectedPanRemovalKeepsTheExistingIngredientForBothValidationFailures() { + FryingReference ref = new FryingReference(env.furniture, Method.FRYING_PAN); + ref.slotRemove(env.takeEvent(new ItemStack(Material.STONE))); + for (FoodItem food : List.of(env.food("salt", "salt", "Salt"), + env.food("potato", "vegetable", "Potato", Method.POT))) { + ref.slots.put("input_1", food); + var event = env.takeEvent(env.stack(food, Material.CARROT, 1)); + ref.slotRemove(event); + assertTrue(event.isCancelled()); + assertSame(food, ref.getSlot("input_1")); + } + } + + @Test + void fryingWithoutButterBurnsFoodButAnUnheatedPanDoesNotAdvanceCooking() { + FryingReference ref = new FryingReference(env.furniture, Method.FRYING_PAN); + FoodItem steak = env.food("steak", "meat", "Beef", Method.FRYING_PAN); + steak.getCookData().start(Method.FRYING_PAN); + ref.slots.put("input_1", steak); + ref.slots.put("inert", env.food("salt", "salt", "Salt")); + env.place("input_1", env.stack(steak, Material.BEEF, 1)); + ref.tick(); assertEquals(0, steak.getCookData().getCurrentTime()); + env.heated = true; + for (int i = 0; i < 10; i++) ref.tick(); + assertEquals(20, steak.getCookData().getCurrentTime()); + assertEquals(2, steak.getTagTrack("cooked").getValue()); + ref.clear(); assertTrue(ref.isEmpty()); ref.tick(); + FryingReference nonPan = new FryingReference(env.furniture, Method.NONE); + nonPan.secondaries.put("liquid", -1); nonPan.tick(); + } + + @Test + void fryingRebuildsButterAndComposesItIntoAHeatedDishOnRemoval() { + FryingReference ref = new FryingReference(env.furniture, Method.FRYING_PAN); + FoodItem butter = env.food("butter", "butter", "Butter"); + FoodItem steak = env.food("steak", "meat", "Beef", Method.FRYING_PAN); + env.place("butter", env.stack(butter, Material.GOLD_NUGGET, 1)); + env.place("input_1", env.stack(steak, Material.BEEF, 1)); + ref.rebuildFromFurniture(); + assertEquals(30, ref.getSecondary("butter")); + assertTrue(ref.slots.containsKey("input_1")); + steak.getCookData().restore(Method.FRYING_PAN, 6); + var take = env.takeEvent(env.stack(steak, Material.BEEF, 1)); + ref.slotRemove(take); + assertFalse(take.isCancelled()); assertTrue(ref.isEmpty()); + env.composer.verify(() -> CompositionQualityResolver.compose(eq(env.player), + argThat(items -> items.size() == 2), eq(CompositionContext.FRYING_PAN))); + assertTrue(steak.hasTagTrack("warmth")); + ref.removeSecondary(Map.entry("other", 1)); + ref.rebuildFromFurniture(); + } + + @Test + void fryingSlotAdditionAndFailedUpdatesKeepTheDisplayedItemsConsistent() { + FryingReference ref = new FryingReference(env.furniture, Method.FRYING_PAN); + ref.slotAdd(env.addEvent(new ItemStack(Material.STONE))); + var wrong = env.addEvent(env.stack(env.food("salt", "salt", "Salt"), Material.SUGAR, 1)); + ref.slotAdd(wrong); assertTrue(wrong.isCancelled()); + FoodItem steak = env.food("steak", "meat", "Beef", Method.FRYING_PAN); + var add = env.addEvent(env.stack(steak, Material.BEEF, 2)); + ref.slotAdd(add); + assertEquals(1, add.getItem().getAmount()); assertNotNull(add.getDisplayData()); + env.updater.when(() -> ItemUpdater.applyItemUpdate(any(), any(), any())).thenReturn(null); + var failed = env.addEvent(env.stack(steak, Material.BEEF, 2)); + ref.slotAdd(failed); assertEquals(2, failed.getItem().getAmount()); + steak.getCookData().restore(Method.FRYING_PAN, 6); + var take = env.takeEvent(failed.getItem()); + ref.slotRemove(take); + assertTrue(take.isCancelled()); + assertSame(steak, ref.getSlot("input_1")); + } + + /** Furniture and item metadata codecs are boundaries; food, tracks and cooking clocks remain real. */ + static final class Environment implements AutoCloseable { + final ServerMock server; + final Cooking previousPlugin; + final List previousTracks; + final Furniture furniture = mock(Furniture.class); + final FurnitureType type = mock(FurnitureType.class); + final World world = mock(World.class); + final Player player = mock(Player.class); + final Map definitions = new LinkedHashMap<>(); + final Map active = new LinkedHashMap<>(); + final Map variables = new HashMap<>(); + final Map foods = new HashMap<>(); + final List> mocks = new ArrayList<>(); + final MockedStatic foodCodec; + final MockedStatic updater; + final MockedStatic cache; + final MockedStatic converter; + final MockedStatic composer; + final NamespacedKey foodKey = NamespacedKey.fromString("test:reference_food"); + final String oldFallback = ItemCache.liquidFallback; + final String oldPotDisplay = ItemCache.potLiquidDisplay; + final int oldScoops = ItemCache.potSoupScoops; + final int oldHeight = ItemCache.potSoupHeightDivisor; + boolean heated; + int nextFood; + FoodItem lastBuilt; + int lastQuality; + + Environment() { + 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.isEnabled()).thenReturn(true); + previousTracks = TrackLoader.oList; + TrackLoader.oList = new ArrayList<>(); + new TrackLoader().load(new File("src/main/resources/tags.yml")); + ItemCache.liquidFallback = "v.glass"; + ItemCache.potLiquidDisplay = "v.glass"; + ItemCache.potSoupScoops = 3; + ItemCache.potSoupHeightDivisor = 4; + when(player.getInventory()).thenReturn(server.addPlayer().getInventory()); + when(player.getWorld()).thenReturn(world); + when(player.getLocation()).thenReturn(new Location(world, 1, 65, 1)); + when(world.getName()).thenReturn("kitchen"); + when(furniture.getId()).thenReturn("test_station"); + when(furniture.getLoc()).thenReturn(new Location(world, 1, 64, 1)); + when(furniture.getType()).thenReturn(type); + when(furniture.getVariables()).thenReturn(variables); + when(type.getSlots()).thenReturn(definitions); + when(type.getSlot(anyString())).thenAnswer(inv -> definitions.get(inv.getArgument(0))); + when(furniture.getActiveSlots()).thenReturn(active); + when(furniture.getActiveSlot(anyString())).thenAnswer(inv -> Optional.ofNullable(active.get(inv.getArgument(0)))); + when(furniture.hasActiveSlot(anyString())).thenAnswer(inv -> active.containsKey(inv.getArgument(0))); + when(furniture.getOrCreatePlacedSlot(anyString())).thenAnswer(inv -> { + String key = inv.getArgument(0); + return active.containsKey(key) ? active.get(key) : place(key, null); + }); + for (String id : List.of("liquid", "butter", "input_1", "input_2", "input_3", "input_4", "input_5")) define(id); + scoped(mockStatic(HeatSources.class)).when(() -> HeatSources.stationHasHeat(furniture)).thenAnswer(inv -> heated); + foodCodec = scoped(mockStatic(FoodItem.class)); + foodCodec.when(() -> FoodItem.fromItem(any())).thenAnswer(inv -> resolve(inv.getArgument(0))); + updater = scoped(mockStatic(ItemUpdater.class)); + updater.when(() -> ItemUpdater.applyItemUpdate(any(), any(), any())).thenAnswer(inv -> { + ItemStack source = inv.getArgument(0); + return source == null ? null : stack(inv.getArgument(1), source.getType(), source.getAmount()); + }); + cache = scoped(mockStatic(ItemCache.class)); + cache.when(() -> ItemCache.getColour(any())).thenReturn("aa5500"); + cache.when(() -> ItemCache.isLadle(any())).thenAnswer(inv -> material(inv.getArgument(0), Material.PAPER)); + cache.when(() -> ItemCache.isMasher(any())).thenAnswer(inv -> material(inv.getArgument(0), Material.STICK)); + cache.when(() -> ItemCache.isButter(any())).thenAnswer(inv -> material(inv.getArgument(0), Material.GOLD_NUGGET)); + cache.when(() -> ItemCache.isPotWaterInput(any())).thenAnswer(inv -> material(inv.getArgument(0), Material.WATER_BUCKET)); + cache.when(() -> ItemCache.isCupOfWater(any())).thenAnswer(inv -> material(inv.getArgument(0), Material.HONEY_BOTTLE)); + cache.when(() -> ItemCache.isCupOfMilk(any())).thenAnswer(inv -> material(inv.getArgument(0), Material.WHITE_DYE)); + cache.when(() -> ItemCache.isMilkBucket(any())).thenAnswer(inv -> material(inv.getArgument(0), Material.MILK_BUCKET)); + cache.when(() -> ItemCache.isWater(any())).thenAnswer(inv -> material(inv.getArgument(0), Material.POTION)); + cache.when(() -> ItemCache.isLiquid(any())).thenAnswer(inv -> material(inv.getArgument(0), Material.LAVA_BUCKET)); + ItemAPI api = mock(ItemAPI.class, RETURNS_DEEP_STUBS); + scoped(mockStatic(TLibs.class)).when(TLibs::getItemAPI).thenReturn(api); + when(api.getCreator().getItemFromPath(any())).thenAnswer(inv -> new ItemStack(Material.GLASS)); + InteractibleFurniture plugin = mock(InteractibleFurniture.class, RETURNS_DEEP_STUBS); + scoped(mockStatic(InteractibleFurniture.class)).when(InteractibleFurniture::getInstance).thenReturn(plugin); + converter = scoped(mockStatic(IngredientConverter.class)); + converter.when(() -> IngredientConverter.convertIfNeeded(any(), any())).thenAnswer(inv -> inv.getArgument(1)); + scoped(mockStatic(CupItems.class)).when(CupItems::emptyCup).thenAnswer(inv -> new ItemStack(Material.GLASS_BOTTLE)); + scoped(mockStatic(BucketItems.class)).when(BucketItems::empty).thenAnswer(inv -> new ItemStack(Material.BUCKET)); + scoped(mockStatic(Encoder.class)).when(() -> Encoder.getEncodedSlots(furniture)).thenReturn("saved-inputs"); + composer = scoped(mockStatic(CompositionQualityResolver.class)); + composer.when(() -> CompositionQualityResolver.compose(any(), any(), any())).thenReturn( + new CompositionResult(60, 72, Map.of(), List.of(), List.of(), List.of(), null)); + scoped(mockStatic(FoodParser.class)).when(() -> FoodParser.parse(anyString())).thenAnswer(inv -> { + String text = inv.getArgument(0); + String kind = text.startsWith("soup") ? "soup" : "sauce"; + FoodParser.Result result = new FoodParser.Result(); + result.template = foodNamed(kind, kind, "Mixed", "{colour}{prefixes}{ingredients}"); + result.template.setBaseFood(8); + return result; + }); + scoped(mockStatic(ItemBuilder.class)).when(() -> ItemBuilder.buildSingleWithQuality(any(), any(), anyInt())).thenAnswer(inv -> { + lastBuilt = inv.getArgument(0); lastQuality = inv.getArgument(2); + return stack(lastBuilt, Material.PAPER, 1); + }); + } + + MockedStatic scoped(MockedStatic value) { mocks.add(value); return value; } + static boolean material(ItemStack item, Material material) { return item != null && item.getType() == material; } + FoodItem foodNamed(String id, String category, String origin, String name) { + MemoryConfiguration config = new MemoryConfiguration(); + config.set("name", name); config.set("update", false); config.set("mashable", true); + FoodItem result = new FoodItem(id, config); + result.setCategory(category); result.setOrigin(origin); + result.setModel(new FoodModel(new ItemStack(Material.CARROT))); + return result; + } + FoodItem food(String id, String category, String origin, Method... methods) { + FoodItem result = foodNamed(id, category, origin, id); + if (methods.length > 0) { + result.addOrModifyTrack(TrackLoader.getByString("cooked")); + for (Method method : methods) result.getCookData().getParameters().put(method, new CookParameter(1, 2, 20)); + } + return new FoodItem(result); + } + ItemStack stack(FoodItem food, Material material, int amount) { + ItemStack stack = new ItemStack(material, amount); + String id = "food-" + nextFood++; + foods.put(id, food); + var meta = stack.getItemMeta(); + meta.getPersistentDataContainer().set(foodKey, PersistentDataType.STRING, id); + stack.setItemMeta(meta); + return stack; + } + FoodItem resolve(ItemStack item) { + if (item == null || item.getType().isAir() || !item.hasItemMeta()) return null; + return foods.get(item.getItemMeta().getPersistentDataContainer().get(foodKey, PersistentDataType.STRING)); + } + SlotDefinition define(String id) { + SlotDefinition definition = mock(SlotDefinition.class); + when(definition.getId()).thenReturn(id); + when(definition.getDisplayScale()).thenReturn(new Vector(1, 2, 1)); + definitions.put(id, definition); + return definition; + } + PlacedSlot place(String id, ItemStack initial) { + PlacedSlot slot = mock(PlacedSlot.class); + ItemStack[] current = {initial}; + when(slot.getCurrentItem()).thenAnswer(inv -> current[0]); + doAnswer(inv -> { current[0] = inv.getArgument(0); return null; }).when(slot).forceModel(any()); + doAnswer(inv -> { current[0] = null; active.remove(id); return null; }).when(slot).clearModel(); + active.put(id, slot); + return slot; + } + FurnitureInteractEvent interaction() { return new FurnitureInteractEvent(player, furniture); } + FurnitureSlotItemAddEvent addEvent(ItemStack stack) { return new FurnitureSlotItemAddEvent(player, furniture, definitions.get("input_1"), stack); } + FurnitureSlotItemTakeEvent takeEvent(ItemStack stack) { return new FurnitureSlotItemTakeEvent(player, furniture, definitions.get("input_1"), stack); } + @Override public void close() { + for (int i = mocks.size() - 1; i >= 0; i--) mocks.get(i).close(); + TrackLoader.oList = previousTracks; + Cooking.plugin = previousPlugin; + ItemCache.liquidFallback = oldFallback; ItemCache.potLiquidDisplay = oldPotDisplay; + ItemCache.potSoupScoops = oldScoops; ItemCache.potSoupHeightDivisor = oldHeight; + MockBukkit.unmock(); + } + } +} diff --git a/src/test/java/net/tfminecraft/cooking/cooking/PotReferenceCoverageTest.java b/src/test/java/net/tfminecraft/cooking/cooking/PotReferenceCoverageTest.java new file mode 100644 index 0000000..bb53b03 --- /dev/null +++ b/src/test/java/net/tfminecraft/cooking/cooking/PotReferenceCoverageTest.java @@ -0,0 +1,480 @@ +package net.tfminecraft.cooking.cooking; + +import static org.junit.jupiter.api.Assertions.*; +import static org.mockito.ArgumentMatchers.*; +import static org.mockito.Mockito.*; + +import java.io.ByteArrayOutputStream; +import java.io.ObjectOutputStream; +import java.util.Base64; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; +import java.util.UUID; + +import org.bukkit.Bukkit; +import org.bukkit.Material; +import org.bukkit.Particle; +import org.bukkit.entity.Entity; +import org.bukkit.inventory.ItemStack; +import org.bukkit.persistence.PersistentDataType; +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.mockito.ArgumentCaptor; +import org.mockito.MockedStatic; + +import net.tfminecraft.cooking.cache.ItemCache; +import net.tfminecraft.cooking.enums.Method; +import net.tfminecraft.cooking.enums.Tag; +import net.tfminecraft.cooking.item.FoodItem; +import net.tfminecraft.cooking.loader.TrackLoader; +import net.tfminecraft.cooking.utils.ItemUpdater; +import net.tfminecraft.cooking.utils.Keys; +import net.tfminecraft.interactiblefurniture.furniture.PlacedSlot; +import net.tfminecraft.interactiblefurniture.furniture.data.DisplayData; + +class PotReferenceCoverageTest { + CookingReferencesTest.Environment env; + + @BeforeEach void setUp() { env = new CookingReferencesTest.Environment(); } + @AfterEach void tearDown() { env.close(); } + + @Test + void waterRequiresTwentyHeatedTicksAndCoolsWhenTheFireGoesOut() { + PotReference pot = pot(); + env.heated = true; + for (int i = 0; i < 25; i++) pot.tick(); + assertFalse(pot.isBoiling(), "An empty pot cannot heat water it does not contain"); + pot.secondaries.put("liquid", -1); + for (int i = 0; i < 19; i++) pot.tick(); + assertFalse(pot.isBoiling()); + pot.tick(); assertTrue(pot.isBoiling()); + pot.tick(); assertTrue(pot.isBoiling()); + env.server.getScheduler().performTicks(25); + env.heated = false; pot.tick(); assertFalse(pot.isBoiling()); + for (int i = 0; i < 25; i++) pot.tick(); + assertFalse(pot.isBoiling()); + } + + @Test + void boilingSchedulesFiniteBubbleEffectsAtTheLiquidDisplay() { + PotReference pot = pot(); + PlacedSlot liquid = env.place("liquid", new ItemStack(Material.GLASS)); + Entity display = mock(Entity.class); + UUID id = UUID.randomUUID(); + when(liquid.getDisplayStandId()).thenReturn(id); + when(display.getLocation()).thenAnswer(invocation -> env.furniture.getLoc()); + try (MockedStatic bukkit = mockStatic(Bukkit.class, CALLS_REAL_METHODS)) { + bukkit.when(() -> Bukkit.getEntity(id)).thenReturn(display); + boil(pot); + for (int i = 0; i < 100; i++) pot.tick(); + env.server.getScheduler().performTicks(25); + verify(env.world, atLeast(1)).spawnParticle(eq(Particle.BUBBLE_POP), any(), eq(1), eq(0d), eq(0.05d), eq(0d), eq(0.05d)); + clearInvocations(env.world); + env.server.getScheduler().performTicks(20); + verifyNoInteractions(env.world); + } + } + + @Test + void firstFoodSelectionSkipsEmptyAndNonFoodDisplaysAndUpdatesOnlyTheMain() { + PotReference pot = pot(); + assertNull(pot.getMain()); + pot.setMain(null); + FoodItem carrot = env.food("carrot", "vegetable", "Carrot", Method.POT); + pot.setMain(carrot); + env.place("empty", null); + env.place("liquid", new ItemStack(Material.GLASS)); + PlacedSlot first = env.place("input_1", env.stack(carrot, Material.CARROT, 1)); + FoodItem potato = env.food("potato", "vegetable", "Potato", Method.POT); + PlacedSlot next = env.place("input_2", env.stack(potato, Material.POTATO, 1)); + assertSame(carrot, pot.getMain()); + pot.setMain(carrot); + verify(first).forceModel(any()); verify(next, never()).forceModel(any()); + env.updater.when(() -> ItemUpdater.applyItemUpdate(any(), any(), any())).thenReturn(null); + pot.setMain(carrot); + verify(first, times(1)).forceModel(any()); + } + + @Test + void potStartsRawCookingAndUpdatesTheDisplayWhenItsCookedStateChanges() { + PotReference pot = pot(); + FoodItem carrot = env.food("carrot", "vegetable", "Carrot", Method.POT); + PlacedSlot placed = env.place("input_1", env.stack(carrot, Material.CARROT, 1)); + pot.slots.put("input_1", carrot); + pot.slots.put("extra_salt", env.food("salt", "salt", "Salt")); + pot.slots.put("uncookable", env.food("inert", "flour", "Wheat")); + env.heated = true; + pot.tick(); assertEquals(1, carrot.getCookData().getCurrentTime()); + pot.tick(); assertEquals(2, carrot.getCookData().getCurrentTime()); + assertEquals(1, carrot.getTagTrack("cooked").getValue()); + verify(placed).forceModel(any()); + } + + @Test + void mashedSoupThickensWithoutRestartingTheIngredientCookingClock() { + PotReference pot = pot(); + FoodItem carrot = mashed("carrot"); + carrot.getCookData().restore(Method.POT, 4); + env.place("input_1", env.stack(carrot, Material.CARROT, 1)); + pot.slots.put("input_1", carrot); + env.heated = true; + pot.tick(); + assertTrue(carrot.hasTagTrack("soup_thickness")); + assertFalse(carrot.getCookData().isBeingCooked()); + pot.tick(); + assertEquals(1, carrot.getTagTrack("soup_thickness").getValue()); + assertFalse(carrot.getCookData().isBeingCooked(), "Mashed soup must stay off the raw-ingredient cooking clock"); + } + + @Test + void additionsRequireBoilingWaterAndAppropriateFoodAndKeepTheFiveMainLimit() { + PotReference pot = pot(); + ItemStack carrot = env.stack(env.food("carrot", "vegetable", "Carrot", Method.POT), Material.CARROT, 1); + assertFalse(pot.canAdd(env.player, carrot)); + boil(pot); + pot.secondaries.clear(); assertFalse(pot.canAdd(env.player, carrot)); + pot.secondaries.put("liquid", -1); + assertFalse(pot.canAdd(env.player, null)); + assertFalse(pot.canAdd(env.player, new ItemStack(Material.AIR))); + assertFalse(pot.canAdd(env.player, new ItemStack(Material.STONE))); + assertFalse(pot.canAdd(env.player, env.stack(env.food("raw", "vegetable", "Carrot"), Material.CARROT, 1))); + assertFalse(pot.canAdd(env.player, env.stack(env.food("pan", "meat", "Beef", Method.FRYING_PAN), Material.BEEF, 1))); + assertTrue(pot.canAdd(env.player, carrot)); + ItemStack salt = env.stack(env.food("salt", "salt", "Salt"), Material.SUGAR, 1); + assertFalse(pot.canAdd(env.player, salt)); + pot.slots.put("input_1", env.resolve(carrot)); + assertFalse(pot.canAdd(env.player, carrot)); + pot.slots.put("input_1", mashed("carrot")); + assertTrue(pot.canAdd(env.player, salt)); + pot.slots.put("extra_salt", env.resolve(salt)); + assertFalse(pot.canAdd(env.player, salt)); + for (int i = 2; i <= 5; i++) pot.slots.put("input_" + i, mashed("carrot" + i)); + assertFalse(pot.canAdd(env.player, carrot)); + assertFalse(pot.canAdd(null, carrot)); + } + + @Test + void takingBoiledFoodSkipsUnavailableSlotsAndAppliesWarmthToTheReturnedItem() { + PotReference pot = pot(); + FoodItem food = env.food("carrot", "vegetable", "Carrot", Method.POT); + food.getTagTrack("cooked").forceSetValue(3); + pot.slots = new LinkedHashMap<>(); + pot.slots.put("missing", food); pot.slots.put("empty", food); pot.slots.put("input_1", food); + env.place("empty", null); + PlacedSlot placed = env.place("input_1", env.stack(food, Material.CARROT, 1)); + pot.take(env.player); + assertTrue(food.hasTagTrack("warmth")); + assertTrue(pot.slots.isEmpty()); verify(placed).clearModel(); + assertSame(food, env.resolve(env.player.getInventory().getItemInMainHand())); + pot.slots.put("input_1", mashed("carrot")); + pot.take(env.player); assertEquals(1, pot.slots.size()); + } + + @Test + void mashingMarksOnlyMashableMainsBoiledAndHidesTheirDisplays() { + PotReference pot = pot(); + FoodItem carrot = env.food("carrot", "vegetable", "Carrot", Method.POT); + carrot.getCookData().start(Method.POT); + FoodItem hard = env.food("hard", "meat", "Bone", Method.POT); hard.setMashable(false); + pot.slots.put("input_1", carrot); pot.slots.put("input_2", hard); + pot.slots.put("extra_salt", env.food("salt", "salt", "Salt")); + PlacedSlot first = env.place("input_1", env.stack(carrot, Material.CARROT, 1)); + env.place("input_2", env.stack(hard, Material.BONE, 1)); + pot.mash(env.player); + assertTrue(pot.isSoup()); assertTrue(carrot.hasTag(Tag.MASHED)); + assertEquals(3, carrot.getTagTrack("cooked").getValue()); + assertFalse(carrot.getCookData().isBeingCooked()); + assertFalse(hard.hasTag(Tag.MASHED)); + assertEquals(3, env.variables.get("pot.soupServings")); + ArgumentCaptor poses = ArgumentCaptor.forClass(DisplayData.class); + verify(first, atLeastOnce()).applyDisplayData(poses.capture()); + assertTrue(poses.getAllValues().stream().anyMatch(pose -> Float.valueOf(0).equals(pose.getxScale()))); + pot.mash(env.player); assertEquals(3, env.variables.get("pot.soupServings")); + } + + @Test + void mashingWithoutADisplayedEligibleMainDoesNotCreateServings() { + PotReference pot = pot(); + pot.mash(env.player); + FoodItem hard = env.food("bone", "meat", "Bone"); hard.setMashable(false); + pot.slots.put("input_1", hard); pot.mash(env.player); + assertFalse(env.variables.containsKey("pot.soupServings")); + FoodItem missing = env.food("carrot", "vegetable", "Carrot", Method.POT); + pot.slots.put("input_2", missing); pot.mash(env.player); + assertFalse(env.variables.containsKey("pot.soupServings")); + } + + @Test + void boilingTrackIsCreatedWhenMissingAndMissingTrackDefinitionsAreTolerated() { + FoodItem food = env.food("carrot", "vegetable", "Carrot"); + PotReference.markBoiled(food); + assertEquals(3, food.getTagTrack("cooked").getValue()); + FoodItem other = env.food("potato", "vegetable", "Potato"); + TrackLoader.oList.removeIf(track -> track.getId().equals("cooked")); + PotReference.markBoiled(other); + assertFalse(other.hasTagTrack("cooked")); + } + + @Test + void soupScoopKeepsTemplateFoodAndPersistsEachRemainingServingAndHeight() { + PotReference pot = preparedSoup(); + FoodItem main = pot.getMain(); + main.addOrModifyTrack(TrackLoader.getByString("soup_thickness")); + main.getTagTrack("soup_thickness").setValue(400); + PlacedSlot liquid = env.place("liquid", new ItemStack(Material.GLASS)); + ItemStack ladle = new ItemStack(Material.PAPER); + pot.scoop(env.player, ladle); + assertEquals(2, env.variables.get("pot.soupServings")); + assertEquals(8, env.lastBuilt.getBaseFood()); + assertEquals(400, env.lastBuilt.getTagTrack("soup_thickness").getValue()); + assertEquals(72, env.lastQuality); + assertEquals("saved-inputs", env.player.getInventory().getItemInMainHand().getItemMeta() + .getPersistentDataContainer().get(Keys.SLOT_DATA, PersistentDataType.STRING)); + ArgumentCaptor pose = ArgumentCaptor.forClass(DisplayData.class); + verify(liquid).applyDisplayData(pose.capture()); + assertEquals(-0.5f, pose.getValue().getyPos()); + pot.scoop(env.player, ladle); assertEquals(1, env.variables.get("pot.soupServings")); + pot.scoop(env.player, ladle); assertTrue(pot.isEmpty()); + assertFalse(env.variables.containsKey("pot.soupServings")); + assertFalse(env.variables.containsKey("pot.extras")); + pot.scoop(env.player, ladle); + } + + @ParameterizedTest + @ValueSource(strings = {"2", "invalid", "-4"}) + void restoredServingValuesAreParsedOrFallBackToConfiguredPortions(String count) { + PotReference pot = preparedSoup(); + env.variables.put("pot.soupServings", count); + pot.scoop(env.player, new ItemStack(Material.PAPER)); + if (count.equals("-4")) assertFalse(env.variables.containsKey("pot.soupServings")); + else assertEquals(count.equals("2") ? 1 : 2, env.variables.get("pot.soupServings")); + } + + @Test + void soupLevelUsesSafeConfigurationMinimumsAndMissingDefinitionsAreHarmless() { + PotReference pot = preparedSoup(); + ItemCache.potSoupScoops = 0; ItemCache.potSoupHeightDivisor = 0; + env.variables.put("pot.soupServings", 0); + env.place("liquid", new ItemStack(Material.GLASS)); + pot.updateModel(); + env.definitions.remove("liquid"); pot.updateModel(); + pot.rebuildFromFurniture(); + when(env.furniture.getType()).thenReturn(null); pot.updateModel(); + new PotReference(null, Method.POT).rebuildFromFurniture(); + assertEquals(26, PotReference.scoopFood(26)); + } + + @Test + void waterInteractionConsumesOnlyTheCorrectBucketAndRejectsWrongContainers() { + PotReference pot = pot(); + pot.interact(env.interaction()); + for (Material wrong : List.of(Material.HONEY_BOTTLE, Material.POTION, Material.LAVA_BUCKET)) { + env.player.getInventory().setItemInMainHand(new ItemStack(wrong)); + pot.interact(env.interaction()); + assertTrue(pot.isEmpty()); + } + env.player.getInventory().setItemInMainHand(new ItemStack(Material.WATER_BUCKET)); + var event = env.interaction(); pot.interact(event); + assertTrue(event.isCancelled()); + assertEquals(Material.BUCKET, env.player.getInventory().getItemInMainHand().getType()); + assertEquals(-1, pot.getSecondary("liquid")); + assertEquals(List.of("3d85c6"), pot.getColours()); + assertFalse(pot.isBoiling()); + } + + @Test + void unconfiguredPotRejectsWaterWithoutConsumingIt() { + PotReference pot = pot(); + env.player.getInventory().setItemInMainHand(new ItemStack(Material.WATER_BUCKET)); + env.definitions.remove("liquid"); pot.interact(env.interaction()); + assertTrue(pot.isEmpty()); + when(env.furniture.getType()).thenReturn(null); pot.interact(env.interaction()); + assertTrue(pot.isEmpty()); + assertEquals(Material.WATER_BUCKET, env.player.getInventory().getItemInMainHand().getType()); + } + + @Test + void toolsAndHeldSoupCannotAccidentallyAddOrConsumeAnotherServing() { + PotReference pot = pot(); + env.player.getInventory().setItemInMainHand(new ItemStack(Material.PAPER)); + var emptyLadle = env.interaction(); pot.interact(emptyLadle); assertFalse(emptyLadle.isCancelled()); + env.player.getInventory().setItemInMainHand(new ItemStack(Material.STICK)); + var mash = env.interaction(); pot.interact(mash); assertTrue(mash.isCancelled()); + env.player.getInventory().setItemInMainHand(env.stack(env.food("soup", "soup", "Mixed"), Material.PAPER, 1)); + var heldSoup = env.interaction(); pot.interact(heldSoup); assertTrue(heldSoup.isCancelled()); + assertTrue(pot.isEmpty()); + PotReference soup = preparedSoup(); + env.player.getInventory().setItemInMainHand(new ItemStack(Material.PAPER)); + var scoop = env.interaction(); soup.interact(scoop); assertTrue(scoop.isCancelled()); + assertEquals(2, env.variables.get("pot.soupServings")); + } + + @Test + void ingredientInteractionFillsTheFirstFreeInputAndEmptyHandTakesItBack() { + PotReference pot = pot(); boil(pot); + FoodItem carrot = env.food("carrot", "vegetable", "Carrot", Method.POT); + env.player.getInventory().setItemInMainHand(env.stack(carrot, Material.CARROT, 3)); + var add = env.interaction(); pot.interact(add); + assertTrue(add.isCancelled()); assertEquals(1, pot.slots.size()); + assertEquals(2, env.player.getInventory().getItemInMainHand().getAmount()); + env.player.getInventory().setItemInMainHand(new ItemStack(Material.AIR)); + pot.interact(env.interaction()); + assertSame(carrot, env.resolve(env.player.getInventory().getItemInMainHand())); + assertTrue(pot.slots.isEmpty()); + } + + @Test + void soupExtrasConsumeOnePersistAcrossRebuildAndThickenTheMain() { + PotReference pot = preparedSoup(); boil(pot); + pot.getMain().addOrModifyTrack(TrackLoader.getByString("soup_thickness")); + env.player.getInventory().setItemInMainHand(env.stack(env.food("salt", "salt", "Salt"), Material.SUGAR, 2)); + var add = env.interaction(); pot.interact(add); + assertTrue(add.isCancelled()); + assertEquals(1, env.player.getInventory().getItemInMainHand().getAmount()); + assertNotNull(env.variables.get("pot.extras")); + assertEquals(600, pot.getMain().getTagTrack("soup_thickness").getValue()); + env.player.getInventory().setItemInMainHand(env.stack(env.food("pepper", "pepper", "Pepper"), Material.BLACK_DYE, 2)); + pot.interact(env.interaction()); + assertTrue(env.variables.get("pot.extras").toString().contains("|")); + assertEquals(1200, pot.getMain().getTagTrack("soup_thickness").getValue()); + PotReference restored = pot(); restored.rebuildFromFurniture(); + assertTrue(restored.slots.containsKey("extra_salt")); + assertTrue(restored.slots.containsKey("extra_pepper")); + assertEquals("salt", restored.getSlot("extra_salt").getCategory()); + assertTrue(restored.isSoup()); + assertEquals("3d85c6", restored.getColours().get(0)); + restored.clear(); assertFalse(env.variables.containsKey("pot.extras")); + } + + @Test + void anExtraThatCannotBeSerializedIsRejectedWithoutConsumingTheIngredient() { + PotReference pot = preparedSoup(); boil(pot); + env.player.getInventory().setItemInMainHand(env.stack(env.food("salt", "salt", "Salt"), Material.SUGAR, 2)); + // The codec already returns null on an I/O failure; the transaction must handle that result. + try (MockedStatic codec = mockStatic(PotReference.class, CALLS_REAL_METHODS)) { + codec.when(() -> PotReference.encodeStack(any())).thenReturn(null); + pot.interact(env.interaction()); + } + assertEquals(2, env.player.getInventory().getItemInMainHand().getAmount()); + assertFalse(pot.slots.containsKey("extra_salt")); + assertFalse(env.variables.containsKey("pot.extras")); + } + + @Test + void aFailedSecondExtraDoesNotReplacePreviouslySavedIngredients() { + PotReference pot = preparedSoup(); boil(pot); + FoodItem salt = env.food("salt", "salt", "Salt"); + env.player.getInventory().setItemInMainHand(env.stack(salt, Material.SUGAR, 2)); + pot.interact(env.interaction()); + Object saved = env.variables.get("pot.extras"); + env.player.getInventory().setItemInMainHand(env.stack(env.food("pepper", "pepper", "Pepper"), Material.BLACK_DYE, 2)); + try (MockedStatic codec = mockStatic(PotReference.class, CALLS_REAL_METHODS)) { + codec.when(() -> PotReference.encodeStack(argThat(stack -> stack != null + && env.resolve(stack) != null && env.resolve(stack).getCategory().equals("pepper")))) + .thenReturn(null); + pot.interact(env.interaction()); + } + assertEquals(2, env.player.getInventory().getItemInMainHand().getAmount()); + assertEquals(saved, env.variables.get("pot.extras")); + assertSame(salt, pot.getSlot("extra_salt")); + assertFalse(pot.slots.containsKey("extra_pepper")); + } + + @Test + void extraSlotNamesRecognizeOnlyTheSupportedCategories() { + for (String category : List.of("salt", "pepper", "garnish", "spice")) { + assertEquals("extra_" + category, PotReference.extraSlotKey(category.toUpperCase())); + } + assertNull(PotReference.extraSlotKey(null)); assertNull(PotReference.extraSlotKey("vegetable")); + assertTrue(PotReference.isExtraSlot("extra_salt")); + assertFalse(PotReference.isExtraSlot(null)); assertFalse(PotReference.isExtraSlot("input_1")); + assertFalse(PotReference.canMash(null)); + } + + @Test + void rebuildRecoversBoiledAndMashedMainsButDiscardsUnsupportedOrMissingFood() { + PotReference pot = pot(); + FoodItem boiled = env.food("boiled", "vegetable", "Carrot", Method.POT); + boiled.getTagTrack("cooked").forceSetValue(3); + env.place("input_1", env.stack(boiled, Material.CARROT, 1)); + env.place("input_2", env.stack(mashed("potato"), Material.POTATO, 1)); + env.place("input_3", new ItemStack(Material.STONE)); + env.place("input_4", env.stack(env.food("pan", "meat", "Beef", Method.FRYING_PAN), Material.BEEF, 1)); + env.place("liquid", new ItemStack(Material.GLASS)); + pot.rebuildFromFurniture(); + assertEquals(2, pot.slots.size()); assertTrue(pot.isSoup()); + assertSame(boiled, pot.getSlot("input_1")); + assertEquals(-1, pot.getSecondary("liquid")); + assertEquals(List.of("3d85c6", "aa5500"), pot.getColours()); + } + + @Test + void malformedSavedExtrasAreIgnoredInsteadOfCrashingFurnitureReload() { + for (Object saved : List.of(5, " ", "missing-dot", ".bad", "extra_salt.", "input_1.bad", "extra_salt.%%%")) { + env.variables.put("pot.extras", saved); + PotReference pot = pot(); + assertDoesNotThrow(pot::rebuildFromFurniture); + assertTrue(pot.slots.isEmpty()); + } + String stone = PotReference.encodeStack(new ItemStack(Material.STONE)); + env.variables.put("pot.extras", "extra_salt." + stone); + PotReference pot = pot(); pot.rebuildFromFurniture(); assertTrue(pot.slots.isEmpty()); + } + + @Test + void savedStackCodecRoundTripsItemsAndRejectsCorruptOrNonItemData() throws Exception { + ItemStack original = new ItemStack(Material.CARROT, 5); + assertEquals(original, PotReference.decodeStack(PotReference.encodeStack(original))); + assertNull(PotReference.encodeStack(null)); + assertNull(PotReference.decodeStack(null)); assertNull(PotReference.decodeStack(" ")); + assertNull(PotReference.decodeStack("%%%")); + assertNull(PotReference.decodeStack(Base64.getEncoder().encodeToString(new byte[] {1, 2, 3}))); + ByteArrayOutputStream bytes = new ByteArrayOutputStream(); + try (ObjectOutputStream stream = new ObjectOutputStream(bytes)) { stream.writeObject("not an item"); } + assertNull(PotReference.decodeStack(Base64.getEncoder().encodeToString(bytes.toByteArray()))); + ItemStack broken = mock(ItemStack.class); + when(broken.serialize()).thenReturn(Map.of("nonserializable", new Object())); + assertNull(PotReference.encodeStack(broken)); + } + + @Test + void clearingPlainWaterPreservesTheFoodUntilThePlayerTakesIt() { + PotReference pot = pot(); + FoodItem carrot = env.food("carrot", "vegetable", "Carrot", Method.POT); + PlacedSlot input = env.place("input_1", env.stack(carrot, Material.CARROT, 1)); + PlacedSlot liquid = env.place("liquid", new ItemStack(Material.GLASS)); + pot.slots.put("input_1", carrot); pot.secondaries.put("liquid", -1); + env.variables.put("pot.soupServings", 2); env.variables.put("pot.extras", "old"); + pot.clear(); + assertSame(carrot, pot.getSlot("input_1")); + assertTrue(pot.secondaries.isEmpty()); + assertFalse(env.variables.containsKey("pot.soupServings")); + verify(input, never()).clearModel(); verify(liquid).clearModel(); + } + + private PotReference pot() { return new PotReference(env.furniture, Method.POT); } + private void boil(PotReference pot) { + env.heated = true; pot.secondaries.put("liquid", -1); + for (int i = 0; i < 20; i++) pot.tick(); + assertTrue(pot.isBoiling()); + } + private FoodItem mashed(String id) { + FoodItem food = env.food(id, "vegetable", id, Method.POT); + food.addOrModifyTrack(TrackLoader.getByString("mashed")); + food.getTagTrack("cooked").forceSetValue(3); + return food; + } + private PotReference preparedSoup() { + PotReference pot = pot(); + FoodItem carrot = mashed("carrot"); + pot.slots.put("input_1", carrot); + env.place("input_1", env.stack(carrot, Material.CARROT, 1)); + env.place("liquid", new ItemStack(Material.GLASS)); + pot.secondaries.put("liquid", -1); + return pot; + } +} diff --git a/src/test/java/net/tfminecraft/cooking/crafting/CraftingStationCoverageTest.java b/src/test/java/net/tfminecraft/cooking/crafting/CraftingStationCoverageTest.java new file mode 100644 index 0000000..05f3a4b --- /dev/null +++ b/src/test/java/net/tfminecraft/cooking/crafting/CraftingStationCoverageTest.java @@ -0,0 +1,708 @@ +package net.tfminecraft.cooking.crafting; + +import static org.junit.jupiter.api.Assertions.*; +import static org.mockito.ArgumentMatchers.*; +import static org.mockito.Mockito.*; + +import java.io.ByteArrayOutputStream; +import java.io.File; +import java.io.PrintStream; +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.ArrayList; +import java.util.HashMap; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; +import java.util.Optional; +import java.util.UUID; + +import org.bukkit.Bukkit; +import org.bukkit.Chunk; +import org.bukkit.Location; +import org.bukkit.Material; +import org.bukkit.NamespacedKey; +import org.bukkit.World; +import org.bukkit.configuration.MemoryConfiguration; +import org.bukkit.entity.Entity; +import org.bukkit.entity.Item; +import org.bukkit.entity.ItemDisplay; +import org.bukkit.entity.Player; +import org.bukkit.inventory.ItemStack; +import org.bukkit.persistence.PersistentDataType; +import org.bukkit.util.Transformation; +import org.bukkit.util.Vector; +import org.joml.Quaternionf; +import org.joml.Vector3f; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.io.TempDir; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.CsvSource; +import org.junit.jupiter.params.provider.ValueSource; +import org.mockbukkit.mockbukkit.MockBukkit; +import org.mockbukkit.mockbukkit.ServerMock; +import org.mockito.MockedStatic; + +import net.tfminecraft.cooking.Cooking; +import net.tfminecraft.cooking.cache.FurnitureCache; +import net.tfminecraft.cooking.cache.ItemCache; +import net.tfminecraft.cooking.carve.CarvableRoastUtils; +import net.tfminecraft.cooking.enums.Method; +import net.tfminecraft.cooking.events.DishCookedEvent; +import net.tfminecraft.cooking.fishing.CustomFishingCatalog; +import net.tfminecraft.cooking.fishing.CutRule; +import net.tfminecraft.cooking.fishing.SeafoodCutting; +import net.tfminecraft.cooking.fishing.SeafoodPortions; +import net.tfminecraft.cooking.fishing.SeafoodYield; +import net.tfminecraft.cooking.heat.HeatSources; +import net.tfminecraft.cooking.item.FoodItem; +import net.tfminecraft.cooking.item.IngredientLineage; +import net.tfminecraft.cooking.item.data.CookParameter; +import net.tfminecraft.cooking.item.data.OverrideData; +import net.tfminecraft.cooking.item.model.FoodModel; +import net.tfminecraft.cooking.item.tag.TagTrack; +import net.tfminecraft.cooking.loader.CraftingStationLoader; +import net.tfminecraft.cooking.loader.FoodLoader; +import net.tfminecraft.cooking.loader.TrackLoader; +import net.tfminecraft.cooking.quality.CompositionContext; +import net.tfminecraft.cooking.quality.CompositionQualityResolver; +import net.tfminecraft.cooking.quality.CompositionResult; +import net.tfminecraft.cooking.utils.FoodParser; +import net.tfminecraft.cooking.utils.ItemBuilder; +import net.tfminecraft.cooking.utils.ItemUpdater; +import net.tfminecraft.interactiblefurniture.InteractibleFurniture; +import net.tfminecraft.interactiblefurniture.events.FurnitureBreakEvent; +import net.tfminecraft.interactiblefurniture.events.FurnitureInteractEvent; +import net.tfminecraft.interactiblefurniture.events.FurnitureSlotItemAddEvent; +import net.tfminecraft.interactiblefurniture.events.FurnitureSlotItemTakeEvent; +import net.tfminecraft.interactiblefurniture.furniture.Furniture; +import net.tfminecraft.interactiblefurniture.furniture.FurnitureType; +import net.tfminecraft.interactiblefurniture.furniture.PlacedSlot; +import net.tfminecraft.interactiblefurniture.furniture.SlotDefinition; +import net.tfminecraft.tlibs.TLibs; +import net.tfminecraft.tlibs.objects.api.ItemAPI; + +public class CraftingStationCoverageTest { + Environment env; + @BeforeEach void open() { env = new Environment(); } + @AfterEach void close() { env.close(); } + + @Test + void recipeAndStationConfigurationUseDeclaredValuesAndDefaults() { + CraftingRecipe defaults = new CraftingRecipe("empty", new MemoryConfiguration()); + assertEquals("empty", defaults.getId()); + assertTrue(defaults.getInputs().isEmpty()); + assertEquals("none", defaults.getOutput()); + assertEquals(1, defaults.getRatio()); + assertFalse(defaults.isProcessed()); + assertFalse(defaults.canAdd(new ItemStack(Material.STONE))); + MemoryConfiguration config = env.config(2, true, "tool:knife"); + CraftingStation template = new CraftingStation("board", config); + CraftingStation station = new CraftingStation(env.furniture, template); + assertEquals("board", station.getId()); + assertEquals("board", station.getBlockId()); + assertTrue(station.isSingleOrigin()); + assertEquals(5, station.getMaxSlots()); + assertSame(env.furniture, station.getFurniture()); + assertTrue(station.getSlots().isEmpty()); + assertFalse(station.hasRecipe()); + CraftingStation empty = new CraftingStation("empty", new MemoryConfiguration()); + assertEquals("none", empty.getBlockId()); + assertEquals(0, empty.getMaxSlots()); + } + + @Test + void loaderReplacesTemplatesAndLooksUpNamesIgnoringCase(@TempDir Path directory) throws Exception { + Path config = directory.resolve("stations.yml"); + Files.writeString(config, "board:\n furniture: oak_board\n max-slots: 2\nsecond:\n furniture: stone_board\n"); + new CraftingStationLoader().load(config.toFile()); + assertEquals(2, CraftingStationLoader.get().size()); + assertEquals("oak_board", CraftingStationLoader.getById("BOARD").getBlockId()); + assertEquals("stone_board", CraftingStationLoader.getById("second").getBlockId()); + assertNull(CraftingStationLoader.getById("missing")); + PrintStream previous = System.err; + ByteArrayOutputStream diagnostics = new ByteArrayOutputStream(); + try (PrintStream capture = new PrintStream(diagnostics)) { + System.setErr(capture); + new CraftingStationLoader().load(directory.resolve("missing.yml").toFile()); + } finally { System.setErr(previous); } + assertTrue(CraftingStationLoader.get().isEmpty()); + assertTrue(diagnostics.toString().contains("missing.yml")); + } + + @Test + void rebuildingReadsOnlyNonemptyInputDisplaysAndCopiesTheirStacks() { + CraftingStation station = env.station(1, false); + ItemStack raw = env.raw(1); + env.place("input_2", raw); + env.place("input_1", new ItemStack(Material.AIR)); + env.place("input_3", null); + env.place("decoration", env.raw(1)); + station.add(env.raw(1), "stale"); + station.rebuildFromFurniture(); + assertEquals(List.of("input_2"), new ArrayList<>(station.getSlots().keySet())); + assertEquals(raw, station.getSlots().get("input_2")); + assertNotSame(raw, station.getSlots().get("input_2")); + assertTrue(station.hasRecipe()); + when(env.furniture.getType()).thenReturn(null); + station.rebuildFromFurniture(); + assertTrue(station.getSlots().isEmpty()); + assertFalse(station.hasRecipe()); + CraftingStation template = new CraftingStation("board", env.config(1, false, "none")); + template.rebuildFromFurniture(); + assertTrue(template.getSlots().isEmpty()); + } + + @Test + void eligibilityEnforcesCapacityRecipeOriginAndCarvableRestrictions() { + CraftingStation station = env.station(1, false); + ItemStack raw = env.raw(1); + assertTrue(station.canAddItem(raw)); + env.carve.when(() -> CarvableRoastUtils.isCarvable(raw)).thenReturn(true); + assertFalse(station.canAddItem(raw)); + env.carve.when(() -> CarvableRoastUtils.isCarvable(raw)).thenReturn(false); + station.add(raw, "input_1"); + assertFalse(station.canAddItem(new ItemStack(Material.STONE))); + assertTrue(station.canAddItem(env.raw(1))); + for (int n = 2; n <= 5; n++) station.add(env.raw(1), "input_" + n); + assertFalse(station.hasFreeSlots()); + assertFalse(station.canAddItem(raw)); + + MemoryConfiguration limited = env.config(1, false, "none"); + limited.set("max-slots", 1); + CraftingStation one = new CraftingStation(env.furniture, new CraftingStation("one", limited)); + one.add(raw, "input_1"); + assertTrue(one.hasFreeSlots()); + assertFalse(one.canAddItem(raw)); + + CraftingStation origin = env.station(1, true); + assertTrue(origin.canAddItem(new ItemStack(Material.STONE))); + origin.add(new ItemStack(Material.STONE), "input_1"); + assertTrue(origin.canAddItem(raw)); + origin.add(raw, "input_2"); + assertTrue(origin.canAddItem(env.stack(env.food("raw", "beef"), Material.BEEF, 1))); + assertFalse(origin.canAddItem(env.stack(env.food("raw", "Pork"), Material.PORKCHOP, 1))); + } + + @Test + void additionAndRemovalEventsKeepRecipeAndDisplayState() { + CraftingStation station = env.station(1, false); + var add = env.addEvent("input_1", env.raw(1)); + station.addItem(add); + assertFalse(add.isCancelled()); + assertNotNull(add.getDisplayData()); + assertTrue(station.hasRecipe()); + var denied = env.addEvent("input_2", new ItemStack(Material.STONE)); + station.addItem(denied); + assertTrue(denied.isCancelled()); + assertEquals(1, station.getSlots().size()); + FoodItem carved = env.food("raw", "Beef"); + carved.getOverrides().put("BEEF", new OverrideData(null, null, "roast")); + carved.setCarveState("roast", 1, 3); + var carve = env.addEvent("input_2", env.stack(carved, Material.BEEF, 1)); + station.addItem(carve); + assertNotNull(carve.getDisplayData()); + env.carve.verify(() -> CarvableRoastUtils.readCarveState(carved, carve.getItem())); + station.removeItem(env.takeEvent("input_1", add.getItem())); + assertTrue(station.hasRecipe()); + station.removeItem(env.takeEvent("input_2", carve.getItem())); + assertFalse(station.hasRecipe()); + var plain = env.addEvent("input_1", new ItemStack(Material.STONE)); + station.addItem(plain); + assertNull(plain.getDisplayData()); + station.interact(new FurnitureInteractEvent(env.player, env.furniture)); + assertEquals(1, station.getSlots().size()); + } + + @Test + void craftingWaitsForARecipeAndEnoughInputs() { + CraftingStation station = env.station(2, false); + station.craft(); + station.add(env.raw(1), "input_1"); + station.craft(); + assertEquals(1, station.getSlots().size()); + station.getSlots().clear(); + station.craft(); + env.builder.verifyNoInteractions(); + } + + @ParameterizedTest + @ValueSource(ints = {0, -1}) + void invalidRecipeRatioLeavesIngredientsAndDisplaysUntouched(int ratio) { + CraftingStation station = env.station(ratio, false); + ItemStack input = env.raw(2); + PlacedSlot display = env.add(station, "input_1", input); + assertDoesNotThrow(() -> { station.craft(); }); + assertSame(input, station.getSlots().get("input_1")); + assertSame(input, display.getCurrentItem()); + verify(display, never()).clearModel(); + assertTrue(env.dropped.isEmpty()); + env.builder.verifyNoInteractions(); + } + + @ParameterizedTest + @CsvSource(value = {"NULL,NULL,true", "NULL,Beef,false", "Beef,NULL,false", "Beef,bEeF,true"}, nullValues = "NULL") + void sameOriginMatchingHandlesFoodsWithoutAnOrigin(String existing, String incoming, boolean accepted) { + CraftingStation station = env.station(1, true); + env.add(station, "input_1", env.stack(env.food("raw", existing), Material.BEEF, 1)); + ItemStack candidate = env.stack(env.food("raw", incoming), Material.BEEF, 1); + assertEquals(accepted, assertDoesNotThrow(() -> station.canAddItem(candidate))); + assertEquals(1, station.getSlots().size()); + if (accepted) { + env.add(station, "input_2", candidate); + assertDoesNotThrow(() -> { station.craft(); }); + assertEquals(2, station.getSlots().values().iterator().next().getAmount()); + } + } + + @Test + void craftingPreservesOriginProcessesFoodAndReturnsUnusedInput() { + MemoryConfiguration config = env.config(2, true, "tool:knife"); + config.set("recipes.cut.output", "cut:{origin}"); + config.set("recipes.cut.processed", true); + CraftingStation station = new CraftingStation(env.furniture, new CraftingStation("board", config)); + for (int n = 1; n <= 3; n++) env.add(station, "input_" + n, env.raw(1)); + station.craft(env.player); + assertEquals("cut:Beef", env.lastParsed); + assertEquals(72, env.lastQuality); + assertNotNull(env.lastBuilt.getTagTrack("processed")); + assertEquals(1, env.dropped.size()); + assertEquals("raw", env.resolve(env.dropped.get(0)).getId()); + assertEquals(1, station.getSlots().size()); + assertEquals("cut:Beef", env.resolve(station.getSlots().values().iterator().next()).getId()); + assertFalse(station.hasRecipe()); + env.composer.verify(() -> CompositionQualityResolver.compose(eq(env.player), argThat(inputs -> inputs.size() == 2), eq(CompositionContext.CUTTING_BOARD))); + assertTrue(env.server.getPluginManager().getFiredEvents().anyMatch(event -> event instanceof DishCookedEvent cooked + && cooked.getPlayer() == env.player && cooked.getMethod().equals("craft") && cooked.getResult().getAmount() == 1)); + } + + @Test + void craftingCanProcessNonFoodAndDropsOutputWhenDisplaysAreOccupied() { + MemoryConfiguration config = env.config(1, true, "none"); + config.set("recipes.cut.inputs", List.of("v.stone")); + CraftingStation station = new CraftingStation(env.furniture, new CraftingStation("board", config)); + env.definitions.keySet().removeIf(id -> !id.equals("input_1") && !id.equals("decoration")); + env.place("input_1", new ItemStack(Material.STICK)); + env.add(station, "incoming", new ItemStack(Material.STONE)); + station.craft(); + assertEquals(1, env.dropped.size()); + assertEquals("cut", env.resolve(env.dropped.get(0)).getId()); + assertTrue(station.getSlots().isEmpty()); + assertFalse(station.hasRecipe()); + env.composer.verify(() -> CompositionQualityResolver.compose(isNull(), argThat(inputs -> inputs.isEmpty()), eq(CompositionContext.CUTTING_BOARD))); + } + + @Test + void missingRemainderDisplayDoesNotLoseIngredientsOrAbortCrafting() { + CraftingStation station = env.station(2, false); + for (int n = 1; n <= 3; n++) station.add(env.raw(1), "input_" + n); + assertDoesNotThrow(() -> { station.craft(); }); + assertEquals(1, env.dropped.size(), "The unconsumed ingredient must still be returned"); + assertEquals("raw", env.resolve(env.dropped.get(0)).getId()); + assertEquals(1, station.getSlots().values().iterator().next().getAmount()); + } + + @ParameterizedTest + @ValueSource(strings = {"null-result", "null-template", "null-stack", "air-stack"}) + void invalidOutputDoesNotConsumeInputsOrDisplays(String failure) { + CraftingStation station = env.station(1, false); + ItemStack raw = env.raw(1); + ItemStack airOutput = new ItemStack(Material.AIR); + PlacedSlot display = env.add(station, "input_1", raw); + switch (failure) { + case "null-result" -> env.parser.when(() -> FoodParser.parse("cut")).thenReturn(null); + case "null-template" -> env.parser.when(() -> FoodParser.parse("cut")).thenReturn(new FoodParser.Result()); + case "null-stack" -> env.builder.when(() -> ItemBuilder.buildSingleWithQuality(any(), any(), anyInt())).thenReturn(null); + case "air-stack" -> env.builder.when(() -> ItemBuilder.buildSingleWithQuality(any(), any(), anyInt())).thenReturn(airOutput); + default -> fail("Unknown output failure"); + } + assertDoesNotThrow(() -> { station.craft(); }); + assertSame(raw, station.getSlots().get("input_1")); + assertSame(raw, display.getCurrentItem()); + assertTrue(station.hasRecipe()); + verify(display, never()).clearModel(); + assertTrue(env.dropped.isEmpty()); + } + + @Test + void craftingConsumesRecipeUnitsFromAStackAndReturnsItsRemainder() { + CraftingStation station = env.station(2, false); + env.add(station, "input_1", env.raw(3)); + station.craft(); + assertEquals(1, station.getSlots().size()); + ItemStack result = station.getSlots().values().iterator().next(); + assertEquals("cut", env.resolve(result).getId()); + assertEquals(1, result.getAmount()); + assertEquals(1, env.dropped.size()); + assertEquals("raw", env.resolve(env.dropped.get(0)).getId()); + assertEquals(1, env.dropped.get(0).getAmount()); + env.composer.verify(() -> CompositionQualityResolver.compose(isNull(), argThat(inputs -> inputs.size() == 2), eq(CompositionContext.CUTTING_BOARD))); + } + + @Test + void displayedRemainderWithoutAnItemStillReturnsTheStoredIngredient() { + CraftingStation station = env.station(2, false); + for (int n = 1; n <= 3; n++) { + station.add(env.raw(1), "input_" + n); + env.place("input_" + n, null); + } + station.craft(); + assertEquals(1, env.dropped.size()); + assertEquals("raw", env.resolve(env.dropped.get(0)).getId()); + assertEquals(1, env.dropped.get(0).getAmount()); + } + + @Test + void consecutiveRecipesPreserveEveryUnitInProducedStacks() { + MemoryConfiguration config = env.config(2, false, "none"); + env.recipe(config, "chop", List.of("food:cut"), "chopped", 1); + CraftingStation station = new CraftingStation(env.furniture, new CraftingStation("board", config)); + for (int n = 1; n <= 4; n++) env.add(station, "input_" + n, env.raw(1)); + station.craft(); + assertEquals(2, station.getSlots().values().iterator().next().getAmount()); + assertTrue(station.hasRecipe()); + station.craft(); + ItemStack finalOutput = station.getSlots().values().iterator().next(); + assertEquals("chopped", env.resolve(finalOutput).getId()); + assertEquals(2, finalOutput.getAmount(), "Each of the two intermediate portions must survive the second recipe"); + } + + @Test + void breakingWithTheConfiguredToolCraftsInsteadOfRemovingTheBoard() { + CraftingStation station = env.station(1, false); + var unattended = new FurnitureBreakEvent(env.furniture, null); + station.remove(unattended); + assertFalse(unattended.isCancelled()); + var empty = new FurnitureBreakEvent(env.furniture, env.player); + env.player.getInventory().setItemInMainHand(new ItemStack(Material.IRON_SWORD)); + station.remove(empty); + assertFalse(empty.isCancelled()); + env.add(station, "input_1", env.raw(1)); + env.player.getInventory().setItemInMainHand(new ItemStack(Material.STICK)); + station.remove(empty); + assertFalse(empty.isCancelled()); + env.player.getInventory().setItemInMainHand(new ItemStack(Material.IRON_SWORD)); + station.remove(empty); + assertTrue(empty.isCancelled()); + assertEquals("cut", env.resolve(station.getSlots().values().iterator().next()).getId()); + CraftingStation noTool = new CraftingStation(env.furniture, new CraftingStation("board", env.config(1, false, "none"))); + var allowed = new FurnitureBreakEvent(env.furniture, env.player); + noTool.remove(allowed); + assertFalse(allowed.isCancelled()); + } + + @Test + void seafoodSkipsUnusableSourcesAndPreservesCatchMetadata() { + CraftingStation station = env.seafoodStation(); + FoodItem invalid = env.whole(); invalid.setCatchSizeCm(null); + station.add(new ItemStack(Material.STONE), "input_1"); + station.add(env.raw(1), "input_2"); + station.add(env.stack(invalid, Material.COD, 1), "input_3"); + station.craft(); + assertEquals(3, station.getSlots().size()); + assertNull(env.lastBuilt); + FoodItem whole = env.whole(); + whole.setLineage(IngredientLineage.ofMain("Sea Bass")); + env.add(station, "input_4", env.stack(whole, Material.COD, 1)); + station.craft(env.player); + assertEquals("fillet", env.lastBuilt.getId()); + assertEquals("Sea Bass", env.lastBuilt.getOrigin()); + assertEquals(whole.getLineage(), env.lastBuilt.getLineage()); + assertEquals(40, env.lastBuilt.getCatchSizeCm()); + assertEquals("fish", env.lastBuilt.getSeafoodCutType()); + assertEquals("sea_bass", env.lastBuilt.getCustomFishingId()); + assertEquals(67, env.lastBuilt.getQualityMin()); + assertEquals(6.5, env.lastBuilt.getBaseNutrition()); + assertTrue(env.lastBuilt.isEdible()); + assertEquals(0, env.lastBuilt.getTagTrack("freshness").getValue()); + assertTrue(env.server.getPluginManager().getFiredEvents().anyMatch(event -> event instanceof DishCookedEvent)); + } + + @Test + void seafoodUsesOneWholeFishAtATimeAndReturnsOutputWhenBoardIsFull() { + CraftingStation station = env.seafoodStation(); + env.definitions.keySet().removeIf(id -> !id.equals("input_1") && !id.equals("decoration")); + ItemStack whole = env.stack(env.whole(), Material.COD, 2); + PlacedSlot source = env.add(station, "input_1", whole); + station.craft(); + assertEquals(1, source.getCurrentItem().getAmount()); + assertEquals(1, station.getSlots().get("input_1").getAmount()); + assertEquals(1, env.dropped.size()); + assertEquals("fillet", env.resolve(env.dropped.get(0)).getId()); + assertTrue(station.hasRecipe()); + station.craft(); + assertFalse(station.hasRecipe()); + assertEquals("fillet", env.resolve(station.getSlots().get("input_1")).getId()); + assertEquals(1, env.lastBuilt.getLineage().mains().size()); + } + + @Test + void unavailableSeafoodTemplatesLeaveWholeFishUntouched() { + CraftingStation station = env.seafoodStation(); + ItemStack fish = env.stack(env.whole(), Material.COD, 1); + PlacedSlot display = env.add(station, "input_1", fish); + FoodLoader.oList.clear(); + station.craft(); + assertSame(fish, station.getSlots().get("input_1")); + verify(display, never()).clearModel(); + assertNull(SeafoodPortions.build(null, new SeafoodYield("fillet", 1, 0))); + assertNull(SeafoodPortions.build(env.whole(), null)); + assertNull(SeafoodCutting.plan("unknown", 10)); + assertNull(SeafoodCutting.plan("fish", 0)); + assertNull(SeafoodCutting.plan(null, 10)); + assertNull(SeafoodCutting.plan(" ", 10)); + } + + /** Real food and inventory values, with external furniture, item codecs and plugin services scoped per test. */ + public static final class Environment implements AutoCloseable { + public final ServerMock server; + final Cooking previousPlugin; + final List previousTracks; + final List previousFoods; + final List previousStations; + final String oldTurner = ItemCache.firePitTurner; + final String oldAxis = ItemCache.firePitSpinAxis; + final float oldVisual = ItemCache.firePitVisualY; + final float oldPivot = ItemCache.firePitPivotY; + public final Furniture furniture = mock(Furniture.class); + public final FurnitureType type = mock(FurnitureType.class); + public final World world = mock(World.class); + public final Chunk chunk = mock(Chunk.class); + public final Player player = mock(Player.class); + public final Map definitions = new LinkedHashMap<>(); + public final Map active = new LinkedHashMap<>(); + public final Map placed = new LinkedHashMap<>(); + public final Map entities = new HashMap<>(); + public final List dropped = new ArrayList<>(); + final Map foods = new HashMap<>(); + final List> mocks = new ArrayList<>(); + public final MockedStatic foodCodec; + public final MockedStatic updater; + public final MockedStatic builder; + public final MockedStatic parser; + public final MockedStatic carve; + public final MockedStatic composer; + public final MockedStatic fishing; + public final ItemAPI api = mock(ItemAPI.class, RETURNS_DEEP_STUBS); + final NamespacedKey foodKey = NamespacedKey.fromString("test:craft_food"); + public boolean heated = true; + public boolean firePit; + int nextFood; + public FoodItem lastBuilt; + public int lastQuality; + public String lastParsed; + + public Environment() { + 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.isEnabled()).thenReturn(true); + previousTracks = TrackLoader.oList; + TrackLoader.oList = new ArrayList<>(); + new TrackLoader().load(new File("src/main/resources/tags.yml")); + previousFoods = FoodLoader.oList; + FoodLoader.oList = new ArrayList<>(); + previousStations = new ArrayList<>(CraftingStationLoader.get()); + CraftingStationLoader.get().clear(); + ItemCache.firePitTurner = "v.stick"; + ItemCache.firePitSpinAxis = "x"; + ItemCache.firePitVisualY = 0.25f; + ItemCache.firePitPivotY = 0.5f; + when(player.getInventory()).thenReturn(server.addPlayer().getInventory()); + when(player.getWorld()).thenReturn(world); + when(player.getLocation()).thenReturn(new Location(world, 1, 64, 1)); + when(world.getUID()).thenReturn(UUID.randomUUID()); + when(world.getName()).thenReturn("craft-test"); + when(world.getChunkAt(anyInt(), anyInt())).thenReturn(chunk); + when(world.getChunkAt(any(Location.class))).thenReturn(chunk); + when(chunk.getWorld()).thenReturn(world); + when(chunk.getX()).thenReturn(0); + when(chunk.getZ()).thenReturn(0); + when(world.dropItem(any(Location.class), any(ItemStack.class))).thenAnswer(inv -> { + dropped.add(((ItemStack) inv.getArgument(1)).clone()); + return mock(Item.class); + }); + when(furniture.getId()).thenReturn("board"); + when(furniture.getEntityId()).thenReturn(UUID.randomUUID()); + when(furniture.getLoc()).thenReturn(new Location(world, 1, 64, 1)); + when(furniture.getType()).thenReturn(type); + when(type.getId()).thenReturn("board"); + when(type.getSlots()).thenReturn(definitions); + when(type.getSlot(anyString())).thenAnswer(inv -> definitions.get(inv.getArgument(0))); + when(furniture.getActiveSlots()).thenReturn(active); + when(furniture.getActiveSlot(anyString())).thenAnswer(inv -> Optional.ofNullable(active.get(inv.getArgument(0)))); + when(furniture.hasActiveSlot(anyString())).thenAnswer(inv -> active.containsKey(inv.getArgument(0))); + when(furniture.getOrCreatePlacedSlot(anyString())).thenAnswer(inv -> { + String id = inv.getArgument(0); + return active.containsKey(id) ? active.get(id) : place(id, null); + }); + doAnswer(inv -> { active.remove(inv.getArgument(0)); return null; }).when(furniture).removeActiveSlot(anyString()); + for (String id : List.of("decoration", "input_1", "input_2", "input_3", "input_4", "input_5")) define(id); + placed.put(furniture.getEntityId(), furniture); + scoped(mockStatic(HeatSources.class)).when(() -> HeatSources.stationHasHeat(any())).thenAnswer(inv -> heated); + scoped(mockStatic(FurnitureCache.class)).when(() -> FurnitureCache.isFirePit(any())).thenAnswer(inv -> firePit); + foodCodec = scoped(mockStatic(FoodItem.class, CALLS_REAL_METHODS)); + foodCodec.when(() -> FoodItem.fromItem(any())).thenAnswer(inv -> resolve(inv.getArgument(0))); + updater = scoped(mockStatic(ItemUpdater.class)); + updater.when(() -> ItemUpdater.applyItemUpdate(any(), any(), any())).thenAnswer(inv -> { + ItemStack source = inv.getArgument(0); + return source == null ? null : stack(inv.getArgument(1), source.getType(), source.getAmount()); + }); + scoped(mockStatic(TLibs.class)).when(TLibs::getItemAPI).thenReturn(api); + when(api.getChecker().checkItemWithPath(any(), anyString())).thenAnswer(inv -> { + ItemStack stack = inv.getArgument(0); String path = inv.getArgument(1); + if (stack == null) return false; + FoodItem food = resolve(stack); + return path.equals("any") || (path.startsWith("food:") && food != null && food.getId().equals(path.substring(5))) + || (path.equals("tool:knife") && stack.getType() == Material.IRON_SWORD) + || (path.equals("v.stone") && stack.getType() == Material.STONE); + }); + when(api.getCreator().getItemFromPath(anyString())).thenAnswer(inv -> new ItemStack(Material.STICK)); + InteractibleFurniture plugin = mock(InteractibleFurniture.class, RETURNS_DEEP_STUBS); + scoped(mockStatic(InteractibleFurniture.class)).when(InteractibleFurniture::getInstance).thenReturn(plugin); + when(plugin.getFurnitureManager().getPlacedFurniture()).thenReturn(placed); + scoped(mockStatic(Bukkit.class, CALLS_REAL_METHODS)).when(() -> Bukkit.getEntity(any(UUID.class))) + .thenAnswer(inv -> entities.get(inv.getArgument(0))); + carve = scoped(mockStatic(CarvableRoastUtils.class)); + carve.when(() -> CarvableRoastUtils.getStageModelData(any())).thenAnswer(inv -> { + FoodItem food = inv.getArgument(0); + return food.getModel().getModel(food); + }); + composer = scoped(mockStatic(CompositionQualityResolver.class)); + composer.when(() -> CompositionQualityResolver.compose(any(), any(), any())).thenReturn( + new CompositionResult(60, 72, Map.of(), List.of(), List.of(), List.of(), null)); + parser = scoped(mockStatic(FoodParser.class)); + parser.when(() -> FoodParser.parse(anyString())).thenAnswer(inv -> { + lastParsed = inv.getArgument(0); + FoodParser.Result result = new FoodParser.Result(); result.template = food(lastParsed, "Beef"); + return result; + }); + builder = scoped(mockStatic(ItemBuilder.class)); + builder.when(() -> ItemBuilder.buildSingleWithQuality(any(), any(), anyInt())).thenAnswer(inv -> { + lastBuilt = inv.getArgument(0); lastQuality = inv.getArgument(2); + return stack(lastBuilt, Material.PAPER, 1); + }); + fishing = scoped(mockStatic(CustomFishingCatalog.class)); + fishing.when(() -> CustomFishingCatalog.cutRule("fish")).thenReturn(new CutRule("fish", "fillet", 1, 1, 100, 10, 10)); + } + + public MockedStatic scoped(MockedStatic value) { mocks.add(value); return value; } + public MemoryConfiguration config(int ratio, boolean singleOrigin, String tool) { + MemoryConfiguration config = new MemoryConfiguration(); + config.set("furniture", "board"); config.set("max-slots", 5); + config.set("single-origin", singleOrigin); config.set("tool", tool); + recipe(config, "cut", List.of("food:raw"), "cut", ratio); + return config; + } + public void recipe(MemoryConfiguration config, String id, List inputs, String output, int ratio) { + config.set("recipes." + id + ".inputs", inputs); + config.set("recipes." + id + ".output", output); + config.set("recipes." + id + ".ratio", ratio); + } + public CraftingStation station(int ratio, boolean singleOrigin) { + return new CraftingStation(furniture, new CraftingStation("board", config(ratio, singleOrigin, "tool:knife"))); + } + public CraftingStation seafoodStation() { + MemoryConfiguration config = config(1, false, "tool:knife"); + config.set("recipes", null); + recipe(config, "cut_seafood", List.of("food:seafood_whole", "v.stone"), "fillet", 1); + FoodItem fillet = food("fillet", "Fish"); fillet.setBaseNutrition(6.5); + FoodLoader.oList.add(fillet); + return new CraftingStation(furniture, new CraftingStation("board", config)); + } + public FoodItem food(String id, String origin, Method... methods) { + MemoryConfiguration config = new MemoryConfiguration(); + config.set("name", id); config.set("update", false); + FoodItem food = new FoodItem(id, config); + food.setCategory("meat"); food.setOrigin(origin); + food.setModel(new FoodModel(new ItemStack(Material.CARROT))); + if (methods.length > 0) { + food.addOrModifyTrack(TrackLoader.getByString("cooked")); + for (Method method : methods) food.getCookData().getParameters().put(method, new CookParameter(1, 2, 20)); + } + return new FoodItem(food); + } + public FoodItem whole() { + FoodItem food = food("seafood_whole", "Sea Bass"); + food.setCatchSizeCm(40); food.setSeafoodCutType("fish"); food.setCustomFishingId("sea_bass"); + food.setQualityRange(67, 67); + return food; + } + public ItemStack raw(int amount) { return stack(food("raw", "Beef"), Material.BEEF, amount); } + public ItemStack stack(FoodItem food, Material material, int amount) { + ItemStack item = new ItemStack(material, amount); + String key = "food-" + nextFood++; + foods.put(key, food); + var meta = item.getItemMeta(); + meta.getPersistentDataContainer().set(foodKey, PersistentDataType.STRING, key); + item.setItemMeta(meta); + return item; + } + public FoodItem resolve(ItemStack item) { + if (item == null || item.getType().isAir() || !item.hasItemMeta()) return null; + return foods.get(item.getItemMeta().getPersistentDataContainer().get(foodKey, PersistentDataType.STRING)); + } + public SlotDefinition define(String id) { + SlotDefinition definition = mock(SlotDefinition.class); + when(definition.getId()).thenReturn(id); + when(definition.getDisplayScale()).thenReturn(new Vector(1, 1, 1)); + definitions.put(id, definition); + return definition; + } + public PlacedSlot place(String id, ItemStack initial) { + PlacedSlot slot = mock(PlacedSlot.class); + ItemStack[] current = {initial}; + when(slot.getCurrentItem()).thenAnswer(inv -> current[0]); + doAnswer(inv -> { current[0] = inv.getArgument(0); return null; }).when(slot).forceModel(any()); + doAnswer(inv -> { current[0] = inv.getArgument(0); return null; }).when(slot).setCurrentItem(any()); + doAnswer(inv -> { current[0] = null; active.remove(id); return null; }).when(slot).clearModel(); + active.put(id, slot); + return slot; + } + public PlacedSlot add(CraftingStation station, String id, ItemStack item) { + station.add(item, id); + return place(id, item); + } + public FurnitureSlotItemAddEvent addEvent(String id, ItemStack item) { + return new FurnitureSlotItemAddEvent(player, furniture, definitions.get(id), item); + } + public FurnitureSlotItemTakeEvent takeEvent(String id, ItemStack item) { + return new FurnitureSlotItemTakeEvent(player, furniture, definitions.get(id), item); + } + public FurnitureInteractEvent interact(String id) { + return new FurnitureInteractEvent(player, furniture, definitions.get(id), new Vector()); + } + public ItemDisplay display(UUID entityId, Location initial, Transformation transform) { + ItemDisplay display = mock(ItemDisplay.class); + Location[] location = {initial.clone()}; + Transformation[] state = {transform}; + when(display.getUniqueId()).thenReturn(entityId); + when(display.getLocation()).thenAnswer(inv -> location[0].clone()); + when(display.getTransformation()).thenAnswer(inv -> state[0]); + doAnswer(inv -> { state[0] = inv.getArgument(0); return null; }).when(display).setTransformation(any()); + when(display.teleport(any(Location.class))).thenAnswer(inv -> { location[0] = ((Location) inv.getArgument(0)).clone(); return true; }); + entities.put(entityId, display); + return display; + } + public ItemDisplay display(String slotId, float x) { + UUID uuid = UUID.randomUUID(); + when(active.get(slotId).getDisplayStandId()).thenReturn(uuid); + return display(uuid, new Location(world, x, 64, 1), identity()); + } + public Transformation identity() { return new Transformation(new Vector3f(), new Quaternionf(), new Vector3f(1), new Quaternionf()); } + @Override public void close() { + for (int n = mocks.size() - 1; n >= 0; n--) mocks.get(n).close(); + TrackLoader.oList = previousTracks; + FoodLoader.oList = previousFoods; + CraftingStationLoader.get().clear(); CraftingStationLoader.get().addAll(previousStations); + ItemCache.firePitTurner = oldTurner; ItemCache.firePitSpinAxis = oldAxis; + ItemCache.firePitVisualY = oldVisual; ItemCache.firePitPivotY = oldPivot; + Cooking.plugin = previousPlugin; + MockBukkit.unmock(); + } + } +} diff --git a/src/test/java/net/tfminecraft/cooking/crops/CropsCoverageTest.java b/src/test/java/net/tfminecraft/cooking/crops/CropsCoverageTest.java new file mode 100644 index 0000000..892a66a --- /dev/null +++ b/src/test/java/net/tfminecraft/cooking/crops/CropsCoverageTest.java @@ -0,0 +1,211 @@ +package net.tfminecraft.cooking.crops; + +import static org.junit.jupiter.api.Assertions.*; +import static org.mockito.ArgumentMatchers.*; +import static org.mockito.Mockito.*; + +import java.nio.file.Path; +import java.util.*; +import java.util.concurrent.locks.LockSupport; +import net.tfminecraft.cooking.Cooking; +import net.tfminecraft.cooking.farming.FarmingCoverageTest.Plot; +import net.tfminecraft.cooking.item.FoodItem; +import net.tfminecraft.cooking.loader.ConversionLoader; +import net.tfminecraft.cooking.utils.*; +import net.tfminecraft.simplefactions.map.fertility.FertilityProvinceResolver; +import net.momirealms.customcrops.api.context.*; +import net.momirealms.customcrops.api.core.Registries; +import net.momirealms.customcrops.api.core.block.*; +import net.momirealms.customcrops.api.core.mechanic.crop.*; +import net.momirealms.customcrops.api.core.world.CustomCropsBlockState; +import net.momirealms.customcrops.api.event.*; +import net.momirealms.customcrops.api.requirement.Requirement; +import org.bukkit.*; +import org.bukkit.block.*; +import org.bukkit.configuration.file.YamlConfiguration; +import org.bukkit.entity.*; +import org.bukkit.event.block.BlockGrowEvent; +import org.bukkit.event.block.BlockPlaceEvent; +import org.bukkit.event.entity.ItemSpawnEvent; +import org.bukkit.event.server.PluginEnableEvent; +import org.bukkit.inventory.*; +import org.bukkit.plugin.Plugin; +import org.junit.jupiter.api.*; +import org.junit.jupiter.api.io.TempDir; +import org.mockbukkit.mockbukkit.MockBukkit; +import org.mockito.MockedStatic; + +class CropsCoverageTest { + @TempDir Path directory; + Plot f; + private final CropCustomCropsListener listener=new CropCustomCropsListener(); + @BeforeEach void setUp()throws Exception{f=new Plot(directory);} + @AfterEach void tearDown()throws Exception{f.close();} + + @Test void invalidNanAffectionIsRejectedByGrowthProbability(){assertThrows(IllegalArgumentException.class,()->CropGrowthChance.growChance(50,Double.NaN));} + @Test void invalidNanAffectionFallsBackWhenLoadingCropYaml()throws Exception{ + YamlConfiguration yaml=new YamlConfiguration();yaml.set("crops.wheat.block","WHEAT");yaml.set("crops.wheat.affection",Double.NaN);f.loadCrops(yaml);assertEquals(0.5,CropsConfig.crop("wheat").affection()); + } + @Test void invalidNanAffectionCannotPoisonHarvestQualityWeights(){assertArrayEquals(CropHarvestQuality.weights(50,0.5),CropHarvestQuality.weights(50,Double.NaN),1e-9);} + + @Test void cropLoaderParsesWeightsSourcesMaterialNamesAndDefaults()throws Exception{ + YamlConfiguration yaml=new YamlConfiguration();yaml.loadFromString(""" + growth-gate: {enabled: false} + harvest-quality: + rich: {'1': 10, '5': 40, '0': 99, '6': 99, bad: 100} + poor: {'1': -2, '5': 1} + crops: + CARROT: {source: ' CustomCrops ', seed: seed, affection: 0.8, block: ' carrots '} + wheat: {affection: 0, block: WHEAT} + high: {affection: 2, block: INVALID} + blank: {block: ' '} + empty: {} + ignored: scalar + """);f.loadCrops(yaml); + assertFalse(CropsConfig.growthGateEnabled());assertEquals(10,CropsConfig.harvestRich().get(1));assertEquals(40,CropsConfig.harvestRich().get(5));assertEquals(16,CropsConfig.harvestRich().get(2));assertEquals(0,CropsConfig.harvestPoor().get(1));assertEquals(5,CropsConfig.crops().size()); + CropDefinition carrot=CropsConfig.crop("CARROT");assertEquals("carrot",carrot.id());assertEquals("customcrops",carrot.source());assertEquals("seed",carrot.seed());assertEquals(0.8,carrot.affection());assertEquals(Material.CARROTS,carrot.block());assertSame(carrot,CropsConfig.byBlock(Material.CARROTS));assertEquals(0.5,CropsConfig.crop("wheat").affection());assertEquals(0.5,CropsConfig.crop("high").affection());assertNull(CropsConfig.crop("high").block());assertNull(CropsConfig.crop(null));assertNull(CropsConfig.byBlock(null)); + f.loadCrops(new YamlConfiguration());assertTrue(CropsConfig.growthGateEnabled());assertTrue(CropsConfig.crops().isEmpty());assertEquals(8,CropsConfig.harvestRich().get(1)); + new CropsLoader().load(directory.resolve("missing.yml").toFile());assertTrue(CropsConfig.growthGateEnabled());assertTrue(CropPlantingRule.requiresOpenSky(Material.WHEAT)); + } + + @Test void cropDefinitionsAndFoodClassificationAreImmutableAndLocaleIndependent(){ + Locale old=Locale.getDefault();try{Locale.setDefault(Locale.forLanguageTag("tr-TR"));CropDefinition rice=new CropDefinition("rice","vanilla","seed",0.5,Material.WHEAT);Map crops=new HashMap<>(Map.of("rice",rice));CropsConfig.apply(null,null,crops);crops.clear();assertSame(rice,CropsConfig.crop("RICE"));assertThrows(UnsupportedOperationException.class,()->CropsConfig.crops().clear());}finally{Locale.setDefault(old);} + assertEquals("vanilla",CropsConfig.normalizeSource(null));assertEquals("vanilla",CropsConfig.normalizeSource("unknown"));CropsConfig.apply(Map.of(),Map.of(),null);assertTrue(CropsConfig.crops().isEmpty()); + assertFalse(CropsConfig.isFarmFood(null));assertFalse(CropsConfig.isFarmFood(" "));assertFalse(CropsConfig.isFarmConversionInput(null));assertFalse(CropsConfig.isFarmConversionInput(" ")); + for(String seed:List.of("v.wheat","v.carrot","v.potato","v.beetroot","v.apple","ia.tfmc_cooking:rice","IA.TFMC_COOKING:TOMATO"))assertTrue(CropsConfig.isFarmConversionInput(seed),seed); + for(String other:List.of("v.beef","ia.other:carrot","ia.tfmc_cooking:butter","ia.tfmc_cooking:SALT","ia.tfmc_cooking:dough","ia.tfmc_cooking:cup_of_milk"))assertFalse(CropsConfig.isFarmConversionInput(other),other); + ConversionLoader.conversions.put("v.beef","grain(type=grain)");ConversionLoader.conversions.put("v.wheat",null);ConversionLoader.conversions.put("v.carrot","other(type=other)");assertFalse(CropsConfig.isFarmFood("grain(type=grain)"));ConversionLoader.conversions.put("v.wheat","c.grain(type=grain)");assertTrue(CropsConfig.isFarmFood("grain(type=grain)")); + } + + @Test void plantingRulesRecognizeGreenhousesExplicitCoversExemptionsAndDisabledGate()throws Exception{ + Location location=new Location(f.world,3,65,4);assertTrue(CropPlantingRule.hasOpenSky(location));assertFalse(CropPlantingRule.hasOpenSky(new Location(null,0,65,0))); + f.block(3,70,4,Material.STONE);assertFalse(CropPlantingRule.hasOpenSky(location));for(Material glass:List.of(Material.GLASS,Material.TINTED_GLASS,Material.BLUE_STAINED_GLASS_PANE)){f.block(3,70,4,glass);assertTrue(CropPlantingRule.hasOpenSky(location));} + YamlConfiguration yaml=new YamlConfiguration();yaml.loadFromString(""" + planting: + allow-glass-roofs: false + allowed-cover: [OAK_LEAVES, INVALID, DIAMOND] + exempt-vanilla: [WHEAT] + exempt-custom: [RICE] + """);f.loadCrops(yaml);assertFalse(CropPlantingRule.hasOpenSky(location));f.block(3,70,4,Material.OAK_LEAVES);assertTrue(CropPlantingRule.hasOpenSky(location));assertFalse(CropPlantingRule.requiresOpenSky(Material.WHEAT));assertTrue(CropPlantingRule.requiresOpenSky(Material.NETHER_WART));assertTrue(CropPlantingRule.requiresOpenSky(Material.BROWN_MUSHROOM));assertTrue(CropPlantingRule.requiresOpenSky(Material.RED_MUSHROOM));assertFalse(CropPlantingRule.requiresOpenSky(Material.STONE));assertFalse(CropPlantingRule.customRequiresOpenSky("rice"));assertTrue(CropPlantingRule.customRequiresOpenSky(null)); + yaml.set("planting.require-open-sky",false);f.loadCrops(yaml);assertFalse(CropPlantingRule.requiresOpenSky(Material.CARROTS));assertFalse(CropPlantingRule.customRequiresOpenSky("tomato")); + } + + @Test void plantingEventsDenyCoveredFoodCropsAndAllowOpenSkyOrExemptSpecies(){ + Block crop=f.crop(0,65,0,Material.WHEAT,false);f.block(0,68,0,Material.STONE);var event=plant(crop);new CropPlantingListener().onPlant(event);assertTrue(event.isCancelled());assertEquals(CropPlantingListener.DENIAL_MESSAGE,f.player.nextMessage()); + f.block(0,68,0,Material.AIR);var open=plant(crop);new CropPlantingListener().onPlant(open);assertFalse(open.isCancelled());crop.setType(Material.NETHER_WART);f.block(0,68,0,Material.STONE);var exempt=plant(crop);new CropPlantingListener().onPlant(exempt);assertFalse(exempt.isCancelled()); + CropConfig config=config("carrot");var custom=new CropPlantEvent(f.player,new ItemStack(Material.WHEAT_SEEDS),EquipmentSlot.HAND,crop.getLocation(),config,null,0);listener.onPlant(custom);assertTrue(custom.isCancelled());assertEquals(CropPlantingListener.DENIAL_MESSAGE,f.player.nextMessage()); + var yeast=new CropPlantEvent(f.player,new ItemStack(Material.WHEAT_SEEDS),EquipmentSlot.HAND,crop.getLocation(),config("yeast"),null,0);listener.onPlant(yeast);assertFalse(yeast.isCancelled()); + } + + @Test void fertilityUsesOptionalPluginAndSafelyHandlesMissingOrIncompatibleMapApi(){ + Location location=new Location(f.world,0,65,0);assertFalse(CropFertility.mapActive());assertEquals(0,CropFertility.at(location));Plugin map=MockBukkit.createMockPlugin("SimpleFactions"); + try(MockedStatic resolver=mockStatic(FertilityProvinceResolver.class)){ + assertFalse(CropFertility.mapActive());assertEquals(0,CropFertility.at(location));resolver.when(FertilityProvinceResolver::isActive).thenReturn(true);resolver.when(()->FertilityProvinceResolver.fertilityAt(location)).thenReturn(120,-2,52);assertTrue(CropFertility.mapActive());assertEquals(100,CropFertility.at(location));assertEquals(0,CropFertility.at(location));assertEquals(52,CropFertility.at(location)); + resolver.when(FertilityProvinceResolver::isActive).thenThrow(new IllegalStateException("not ready"));assertFalse(CropFertility.mapActive());assertEquals(0,CropFertility.at(location));f.server.getPluginManager().disablePlugin(map);assertFalse(CropFertility.mapActive());assertEquals(0,CropFertility.at(location)); + } + } + + @Test void growthProbabilitiesAndGatesAreDeterministicAtBoundaries(){ + assertEquals(0,CropGrowthChance.growChance(-1,0.5));assertEquals(1,CropGrowthChance.growChance(101,0.5));assertEquals(0.5,CropGrowthChance.growChance(25,0.5));assertThrows(IllegalArgumentException.class,()->CropGrowthChance.growChance(50,0));assertThrows(IllegalArgumentException.class,()->CropGrowthChance.growChance(50,2)); + Random random=mock(Random.class);when(random.nextDouble()).thenReturn(0.2,0.8);assertTrue(CropGrowthChance.rollGrows(25,0.5,random));assertFalse(CropGrowthChance.rollGrows(25,0.5,random));assertTrue(CropGrowthChance.rollGrows(100,0.5,random));assertFalse(CropGrowthChance.rollGrows(0,0.5,random)); + assertTrue(CropGrowthGate.allowsGrowth(0,OptionalDouble.of(0.5),false,true,random));assertTrue(CropGrowthGate.allowsGrowth(0,OptionalDouble.of(0.5),true,false,random));assertTrue(CropGrowthGate.allowsGrowth(0,null,true,true,random));assertTrue(CropGrowthGate.allowsGrowth(0,OptionalDouble.empty(),true,true,random));assertTrue(CropGrowthGate.allowsGrowth(0,OptionalDouble.of(0.5),true,true,null));assertFalse(CropGrowthGate.allowsGrowth(0,OptionalDouble.of(0.5),true,true,random));assertTrue(CropGrowthGate.allowsGrowth(100,OptionalDouble.of(0.5),true,true,random));when(random.nextDouble()).thenReturn(0.2);assertTrue(CropGrowthGate.allowsGrowth(25,OptionalDouble.of(0.5),true,true,random)); + configureCustom();assertEquals(0.5,CropGrowthGate.resolveCustomAffection("carrot",null).orElseThrow());assertEquals(0.8,CropGrowthGate.resolveCustomAffection("carrot",OptionalDouble.of(0.8)).orElseThrow());assertTrue(CropGrowthGate.resolveCustomAffection("missing",OptionalDouble.empty()).isEmpty()); + } + + @Test void growthEventsCancelOnlyWhenAnActiveFertilityMapRejectsTheCrop(){ + configureCustom();Location location=new Location(f.world,0,65,0);Block crop=f.crop(0,65,0,Material.CARROTS,false); + try(MockedStatic fertility=mockStatic(CropFertility.class)){ + fertility.when(CropFertility::mapActive).thenReturn(true);fertility.when(()->CropFertility.at(any())).thenReturn(0);var denied=new BlockGrowEvent(crop,crop.getState());new CropGrowthListener().onBlockGrow(denied);assertTrue(denied.isCancelled());assertFalse(CropGrowthGate.allowsCustom("carrot",location,OptionalDouble.empty())); + fertility.when(()->CropFertility.at(any())).thenReturn(100);var allowed=new BlockGrowEvent(crop,crop.getState());new CropGrowthListener().onBlockGrow(allowed);assertFalse(allowed.isCancelled());assertTrue(CropGrowthGate.allowsCustom("carrot",location,OptionalDouble.empty()));assertFalse(CropGrowthGate.shouldCancelVanilla(Material.STONE,location)); + } + } + + @Test void qualityWeightsRemainNormalizedAndRollEveryBoundary(){ + double[] weights=CropHarvestQuality.weights(-20,0.5);double total=0;for(int i=1;i<=5;i++){assertTrue(weights[i]>0);total+=weights[i];}assertEquals(1,total,1e-9);assertArrayEquals(CropHarvestQuality.weights(100,0.5),CropHarvestQuality.weights(200,0.5));assertArrayEquals(CropHarvestQuality.weights(50,0.5),CropHarvestQuality.weights(50,0));assertArrayEquals(CropHarvestQuality.weights(50,0.5),CropHarvestQuality.weights(50,2)); + Random random=mock(Random.class);when(random.nextDouble()).thenReturn(0.0,0.99);assertEquals(1,CropHarvestQuality.roll(0,0.5,random));assertEquals(5,CropHarvestQuality.roll(0,0.5,random));int fallback=CropHarvestQuality.roll(50,0.5,null);assertTrue(fallback>=1&&fallback<=5);assertTrue(CropHarvestQuality.roll("missing",new Location(f.world,0,65,0),null)>=1); + } + + @Test void itemConversionPreservesSeedsAndAlreadyCookedFoodAndConvertsAmounts(){ + configureCustom();CropDefinition crop=CropsConfig.crop("carrot");FoodItem template=f.food("carrot",Material.BREAD);ConversionLoader.conversions.put("v.carrot","vegetable(type=carrot;origin=Carrot)"); + assertNull(CropHarvestItems.convertDrop(null,crop,2));assertNull(CropHarvestItems.rewriteCustomDrop(null,crop,2));ItemStack air=new ItemStack(Material.AIR);assertSame(air,CropHarvestItems.convertDrop(air,crop,2));assertSame(air,CropHarvestItems.rewriteCustomDrop(air,crop,2)); + for(Material seed:List.of(Material.WHEAT_SEEDS,Material.BEETROOT_SEEDS,Material.MELON_SEEDS,Material.PUMPKIN_SEEDS)){ItemStack stack=new ItemStack(seed);assertSame(stack,CropHarvestItems.convertDrop(stack,crop,5));} + ItemStack original=new ItemStack(Material.CARROT,4);ItemStack converted=CropHarvestItems.convertDrop(original,crop,9);assertEquals(4,converted.getAmount());assertEquals(5,FoodItem.fromItem(converted).getQualityMin());assertEquals(4,original.getAmount());assertSame(converted,CropHarvestItems.rewriteCustomDrop(converted,crop,1)); + ItemStack seed=new ItemStack(Material.WHEAT_SEEDS);assertSame(seed,CropHarvestItems.rewriteCustomDrop(seed,crop,5));assertFalse(CropHarvestItems.isConfiguredSeed(null,crop));assertFalse(CropHarvestItems.isConfiguredSeed(seed,null));assertFalse(CropHarvestItems.isConfiguredSeed(seed,new CropDefinition("none","customcrops",null,0.5,null)));assertFalse(CropHarvestItems.isConfiguredSeed(seed,new CropDefinition("none","customcrops"," ",0.5,null))); + when(f.items.getChecker().checkItemWithPath(seed,"broken")).thenThrow(new IllegalStateException("missing provider"));assertFalse(CropHarvestItems.isConfiguredSeed(seed,new CropDefinition("none","customcrops","broken",0.5,null))); + ItemStack stone=new ItemStack(Material.STONE);assertSame(stone,CropHarvestItems.convertDrop(stone,crop,3));ConversionLoader.conversions.put("v.carrot","invalid");assertSame(original,CropHarvestItems.convertDrop(original,crop,3));ConversionLoader.conversions.put("v.carrot","vegetable(type=missing)");assertSame(original,CropHarvestItems.convertDrop(original,crop,3)); + ConversionLoader.conversions.put("v.carrot","vegetable(type=carrot)");template.setModel(null);try(MockedStatic builder=mockStatic(ItemBuilder.class)){assertSame(original,CropHarvestItems.convertDrop(original,crop,3));} + } + + @Test void fertilityWrapperPreservesDelegatePointsAndRequiresLocationForRealCrops(){ + GrowCondition delegate=mock(GrowCondition.class);when(delegate.pointToAdd()).thenReturn(3);CookingFertilityGrowCondition wrapped=new CookingFertilityGrowCondition(delegate);assertEquals(3,wrapped.pointToAdd());Context context=mock(Context.class);assertFalse(wrapped.isMet(context));when(delegate.isMet(context)).thenReturn(true);assertTrue(wrapped.isMet(context));assertTrue(CookingFertilityGrowCondition.fertilityAllows(null)); + CustomCropsBlockState state=mock(CustomCropsBlockState.class);when(context.holder()).thenReturn(state);when(state.type()).thenReturn(mock(CustomCropsBlock.class));assertTrue(wrapped.isMet(context));CropBlock cropBlock=mock(CropBlock.class);when(state.type()).thenReturn(cropBlock);assertTrue(wrapped.isMet(context));CropConfig config=config(null);when(cropBlock.config(state)).thenReturn(config);assertTrue(wrapped.isMet(context));when(config.id()).thenReturn("carrot");assertFalse(wrapped.isMet(context)); + when(context.arg(ContextKeys.LOCATION)).thenReturn(new Location(f.world,0,65,0));try(MockedStatic gate=mockStatic(CropGrowthGate.class)){gate.when(()->CropGrowthGate.allowsCustom(eq("carrot"),any(),eq(OptionalDouble.empty()))).thenReturn(true);assertTrue(wrapped.isMet(context));assertTrue(new CookingFertilityGrowCondition(new CookingFertilityGrowCondition(wrapped)).isMet(context));} + } + + @Test void bridgeWrapsRegisteredConditionsOnceAndSuppliesAnUnconditionalDelegate(){ + List saved=new ArrayList<>();Registries.CROP.forEach(saved::add);Registries.CROP.clear();try{ + GrowCondition original=new GrowCondition(new Requirement[0],4);CropConfig configured=CropConfig.builder().id("carrot").seed(List.of("seed")).stages(List.of()).growConditions(new GrowCondition[]{original,null}).build();CropConfig empty=CropConfig.builder().id("empty").seed(List.of()).stages(List.of()).growConditions(new GrowCondition[0]).build();CropConfig absent=CropConfig.builder().id("absent").seed(List.of()).stages(List.of()).growConditions(null).build(); + CropConfig foreign=config("foreign");when(foreign.growConditions()).thenReturn(new GrowCondition[0]);Registries.CROP.register("foreign",foreign); + Registries.CROP.register("carrot",configured);Registries.CROP.register("empty",empty);Registries.CROP.register("absent",absent);Registries.CROP.register("ignored",null);CropCustomCropsBridge.injectFertility(); + assertInstanceOf(CookingFertilityGrowCondition.class,configured.growConditions()[0]);assertEquals(4,configured.growConditions()[0].pointToAdd());assertNull(configured.growConditions()[1]);assertEquals(1,empty.growConditions().length);assertEquals(1,empty.growConditions()[0].pointToAdd());assertEquals(1,absent.growConditions().length); + GrowCondition first=configured.growConditions()[0];CropCustomCropsBridge.injectFertility();assertSame(first,configured.growConditions()[0]); + }finally{Registries.CROP.clear();for(CropConfig crop:saved)Registries.CROP.register(crop.id(),crop);} + } + + @Test void bridgeHandlesPluginAvailabilityReloadSchedulingAndUnreadyExternalRegistry(){ + CropCustomCropsBridge bridge=new CropCustomCropsBridge();CropCustomCropsBridge.tryRegister(f.plugin);Plugin other=MockBukkit.createMockPlugin("Other");bridge.onPluginEnable(new PluginEnableEvent(other));Plugin custom=MockBukkit.createMockPlugin("CustomCrops");f.server.getPluginManager().disablePlugin(custom);CropCustomCropsBridge.tryRegister(f.plugin);f.server.getPluginManager().enablePlugin(custom);CropCustomCropsBridge.tryRegister(null);CropCustomCropsBridge.tryRegister(f.plugin);CropCustomCropsBridge.tryRegister(f.plugin);bridge.onPluginEnable(new PluginEnableEvent(custom));bridge.onCustomCropsReload(new CustomCropsReloadEvent(null));f.server.getScheduler().performOneTick(); + List saved=new ArrayList<>();Registries.CROP.forEach(saved::add);Registries.CROP.clear();try{CropConfig broken=config("unready");when(broken.growConditions()).thenThrow(new IllegalStateException("reloading"));Registries.CROP.register("unready",broken);assertDoesNotThrow(CropCustomCropsBridge::injectFertility);Cooking.plugin=null;assertDoesNotThrow(()->bridge.onCustomCropsReload(new CustomCropsReloadEvent(null)));Cooking.plugin=f.plugin;}finally{Registries.CROP.clear();for(CropConfig crop:saved)Registries.CROP.register(crop.id(),crop);} + } + + @Test void inventoryRewriteConvertsOnlyNewHarvestAndPreservesPreexistingProduce(){ + 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)); + try(MockedStatic quality=mockStatic(CropHarvestQuality.class)){quality.when(()->CropHarvestQuality.roll(anyString(),any(),any())).thenReturn(4);listener.onBreak(breakEvent(config("carrot"),new Location(f.world,0,65,0),f.player));f.player.getInventory().setItem(0,new ItemStack(Material.CARROT,5));f.server.getScheduler().performOneTick();} + assertEquals(3,f.inventoryCount(Material.CARROT),"Harvesting two carrots must not convert three carrots already held");assertEquals(2,f.inventoryCount(Material.BREAD)); + } + + @Test void inventoryRewriteDoesNotConvertAStackThatShrankDuringHarvest(){ + 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,5));listener.onBreak(breakEvent(config("carrot"),new Location(f.world,0,65,0),f.player));f.player.getInventory().setItem(0,new ItemStack(Material.CARROT,3));f.server.getScheduler().performOneTick();assertEquals(3,f.inventoryCount(Material.CARROT));assertEquals(0,f.inventoryCount(Material.BREAD)); + } + + @Test void customDropsUseNearbySameWorldHarvestQualityAndPreserveSeedsAndFood(){ + configureCustom();f.food("carrot",Material.BREAD);ConversionLoader.conversions.put("v.carrot","vegetable(type=carrot;origin=Carrot)");Location location=new Location(f.world,0,65,0);Item item=f.world.dropItem(location.clone().add(0.5,0.5,0.5),new ItemStack(Material.CARROT,2));Item seed=f.world.dropItem(location.clone().add(0.5,0.5,0.5),new ItemStack(Material.WHEAT_SEEDS));Item far=f.world.dropItem(new Location(f.world,10,65,0),new ItemStack(Material.CARROT)); + try(MockedStatic quality=mockStatic(CropHarvestQuality.class)){quality.when(()->CropHarvestQuality.roll(anyString(),any(),any())).thenReturn(4);listener.onBreak(breakEvent(config("carrot"),location,null));listener.onItemSpawn(new ItemSpawnEvent(item));listener.onItemSpawn(new ItemSpawnEvent(seed));listener.onItemSpawn(new ItemSpawnEvent(far));} + assertEquals(Material.BREAD,item.getItemStack().getType());assertEquals(2,item.getItemStack().getAmount());assertEquals(4,FoodItem.fromItem(item.getItemStack()).getQualityMin());assertEquals(Material.WHEAT_SEEDS,seed.getItemStack().getType());assertEquals(Material.CARROT,far.getItemStack().getType()); + World other=f.server.addSimpleWorld("other");Item elsewhere=other.dropItem(new Location(other,0.5,65.5,0.5),new ItemStack(Material.CARROT));listener.onItemSpawn(new ItemSpawnEvent(elsewhere));assertEquals(Material.CARROT,elsewhere.getItemStack().getType()); + } + + @Test void customHarvestIgnoresUnconfiguredCropsBoneMealAndImmatureStates(){ + configureCustom();Location location=new Location(f.world,0,65,0);listener.onBreak(breakEvent(null,location,null));listener.onBreak(breakEvent(config(null),location,null));listener.onBreak(breakEvent(config("unknown"),location,null));listener.onBreak(breakEvent(config("wheat"),location,null));CropConfig config=config("carrot");CustomCropsBlockState state=mock(CustomCropsBlockState.class);CropBlock type=mock(CropBlock.class);when(state.type()).thenReturn(type);when(type.point(state)).thenReturn(1);when(config.maxPoints()).thenReturn(2); + listener.onInteract(interact(config,state,new ItemStack(Material.BONE_MEAL)));listener.onInteract(interact(config,null,null));when(state.type()).thenReturn(mock(CustomCropsBlock.class));listener.onInteract(interact(config,state,null));when(state.type()).thenReturn(type);listener.onInteract(interact(config,state,null));listener.onInteract(interact(config("unknown"),state,null)); + when(type.point(state)).thenReturn(2);listener.onInteract(interact(config,state,new ItemStack(Material.STICK)));listener.onInteract(interact(config,state,null));listener.onBreak(breakEvent(config,null,null));listener.onBreak(breakEvent(config,new Location(null,0,0,0),null));Cooking.plugin=null;listener.onBreak(breakEvent(config,location,null));Cooking.plugin=f.plugin;f.server.getScheduler().performOneTick(); + } + + @Test void pendingHarvestExpiresAndScheduledInventoryRewriteSkipsOfflinePlayers(){ + configureCustom();Location location=new Location(f.world,0,65,0);listener.onBreak(breakEvent(config("carrot"),location,f.player));f.player.disconnect();f.server.getScheduler().performOneTick();f.player.reconnect(); + Item item=f.world.dropItem(location.clone().add(0.5,0.5,0.5),new ItemStack(Material.CARROT));LockSupport.parkNanos(250_000_000);listener.onItemSpawn(new ItemSpawnEvent(item));assertEquals(Material.CARROT,item.getItemStack().getType());f.server.getScheduler().performTicks(2); + Item empty=mock(Item.class);when(empty.getItemStack()).thenReturn(null);listener.onItemSpawn(new ItemSpawnEvent(empty));when(empty.getItemStack()).thenReturn(new ItemStack(Material.AIR));listener.onItemSpawn(new ItemSpawnEvent(empty));when(empty.getItemStack()).thenReturn(new ItemStack(Material.CARROT));when(empty.getLocation()).thenReturn(null);listener.onItemSpawn(new ItemSpawnEvent(empty));when(empty.getLocation()).thenReturn(new Location(null,0,0,0));listener.onItemSpawn(new ItemSpawnEvent(empty)); + } + + @Test void qualityRollHandlesFloatingPointRoundingAtTheLargestValidRandomDraw(){ + Map weights=Map.of(1,33.0,2,17.0,3,33.0,4,32.0,5,19.0);CropsConfig.apply(weights,weights,Map.of());Random random=mock(Random.class);when(random.nextDouble()).thenReturn(Math.nextDown(1.0));assertEquals(5,CropHarvestQuality.roll(100,0.5,random)); + } + + @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();} + assertEquals(3,f.inventoryCount(Material.CARROT));assertEquals(2,f.droppedCount(Material.BREAD));assertEquals(0,f.inventoryCount(Material.BREAD)); + } + + @Test void changedSlotsOnlyConvertRecognizedNewProduce(){ + 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));f.player.getInventory().setItem(1,new ItemStack(Material.STONE));f.player.getInventory().setItem(2,new ItemStack(Material.AIR)); + listener.onBreak(breakEvent(config("carrot"),new Location(f.world,0,65,0),f.player));f.player.getInventory().setItem(1,new ItemStack(Material.CARROT,2));f.player.getInventory().setItem(3,new ItemStack(Material.STONE));f.player.getInventory().setItem(4,new ItemStack(Material.WHEAT_SEEDS));f.server.getScheduler().performOneTick(); + 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)); + } + + 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);} + private CropInteractEvent interact(CropConfig config,CustomCropsBlockState state,ItemStack item){return new CropInteractEvent(f.player,item,new Location(f.world,0,65,0),state,EquipmentSlot.HAND,config,"stage");} + private BlockPlaceEvent plant(Block block){return new BlockPlaceEvent(block,block.getState(),block.getRelative(BlockFace.DOWN),new ItemStack(Material.WHEAT_SEEDS),f.player,true,EquipmentSlot.HAND);} +} diff --git a/src/test/java/net/tfminecraft/cooking/cup/CupItemsCoverageTest.java b/src/test/java/net/tfminecraft/cooking/cup/CupItemsCoverageTest.java new file mode 100644 index 0000000..dc07638 --- /dev/null +++ b/src/test/java/net/tfminecraft/cooking/cup/CupItemsCoverageTest.java @@ -0,0 +1,163 @@ +package net.tfminecraft.cooking.cup; + +import static org.junit.jupiter.api.Assertions.*; +import static org.mockito.Mockito.*; + +import java.util.ArrayList; +import java.util.HashMap; +import java.util.List; +import java.util.Map; +import net.tfminecraft.cooking.Cooking; +import net.tfminecraft.cooking.cache.ItemCache; +import net.tfminecraft.cooking.item.FoodItem; +import net.tfminecraft.cooking.item.model.FoodModel; +import net.tfminecraft.cooking.item.tag.TagStep; +import net.tfminecraft.cooking.item.tag.TagTrack; +import net.tfminecraft.cooking.loader.ConversionLoader; +import net.tfminecraft.cooking.loader.FoodLoader; +import net.tfminecraft.cooking.loader.TrackLoader; +import net.tfminecraft.cooking.quality.QualityConfig; +import net.tfminecraft.cooking.utils.ItemBuilder; +import net.tfminecraft.tlibs.TLibs; +import net.tfminecraft.tlibs.objects.api.ItemAPI; +import org.bukkit.Material; +import org.bukkit.configuration.MemoryConfiguration; +import org.bukkit.event.player.PlayerBucketFillEvent; +import org.bukkit.inventory.ItemStack; +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; +import org.mockito.MockedStatic; + +class CupItemsCoverageTest { + ServerMock server; + PlayerMock player; + Cooking previousPlugin; + List foods; + List tracks; + Map conversions; + String empty, water; + ItemAPI api; + MockedStatic tlibs; + + @BeforeEach void setup() { + server=MockBukkit.mock(); player=server.addPlayer(); previousPlugin=Cooking.plugin; + Cooking.plugin=mock(Cooking.class); when(Cooking.plugin.getName()).thenReturn("Cooking"); + when(Cooking.plugin.namespace()).thenReturn("cooking"); + foods=new ArrayList<>(FoodLoader.oList); FoodLoader.oList.clear(); + tracks=new ArrayList<>(TrackLoader.oList); TrackLoader.oList.clear(); + conversions=new HashMap<>(ConversionLoader.conversions); ConversionLoader.conversions.clear(); + empty=ItemCache.emptyCup; water=ItemCache.cupOfWater; + ItemCache.emptyCup="cup.empty"; ItemCache.cupOfWater="cup.water"; + api=mock(ItemAPI.class,RETURNS_DEEP_STUBS); tlibs=mockStatic(TLibs.class); + tlibs.when(TLibs::getItemAPI).thenReturn(api); + TrackLoader.oList.add(new TagTrack("freshness",false,List.of(new TagStep("fresh","Fresh",0,1,1,0,0)))); + food("milk_bucket",Material.MILK_BUCKET); food("cup_of_milk",Material.POTION); + QualityConfig.apply(3,3,null,null); + } + + @AfterEach void cleanup() { + FoodLoader.oList.clear(); FoodLoader.oList.addAll(foods); + TrackLoader.oList.clear(); TrackLoader.oList.addAll(tracks); + ConversionLoader.conversions.clear(); ConversionLoader.conversions.putAll(conversions); + ItemCache.emptyCup=empty; ItemCache.cupOfWater=water; + QualityConfig.apply(1,5,null,null); tlibs.close(); MockBukkit.unmock(); Cooking.plugin=previousPlugin; + } + + @Test void cupsCloneConfiguredItemsAndPreserveMilkOriginFreshnessAndQuality() { + ItemStack base=new ItemStack(Material.BOWL); + when(api.getCreator().getItemFromPath("cup.empty")).thenReturn(base); + when(api.getCreator().getItemFromPath("cup.water")).thenReturn(base); + assertEquals(base,CupItems.emptyCup()); assertNotSame(base,CupItems.emptyCup()); + assertEquals(base,CupItems.cupOfWater()); assertNotSame(base,CupItems.cupOfWater()); + assertMilk(CupItems.cupOfMilk(player,4,12,"Goat"),4,12,"Goat"); + assertMilk(CupItems.cupOfMilk(player,2,8),2,8,"Cow"); + assertMilk(CupItems.cupOfMilk(player,6),3,6,"Cow"); + assertMilk(CupItems.cupOfMilk(player,7,"Goat"),3,7,"Goat"); + assertEquals("Cow",DairyOrigin.orCow(null)); assertEquals("Cow",DairyOrigin.orCow(" ")); + } + + @Test void unavailableCupTemplatesReturnNoOutput() { + when(api.getCreator().getItemFromPath("cup.empty")).thenReturn(null); + when(api.getCreator().getItemFromPath("cup.water")).thenReturn(null); + FoodLoader.oList.removeIf(f->f.getId().equals("cup_of_milk")); + assertNull(CupItems.cupOfMilk(player,2,0)); assertNull(CupItems.cupOfMilk(player,0)); + assertNull(CupItems.emptyCup()); assertNull(CupItems.cupOfWater()); + } + + @Test void snapshotsReadMetadataAndUseSafeDefaultsForOtherItems() { + for(ItemStack item:new ItemStack[]{null,new ItemStack(Material.STONE),new ItemStack(Material.MILK_BUCKET)}) { + assertEquals(1,MilkBucketSnapshot.readQuality(item)); + assertEquals(0,MilkBucketSnapshot.readDairyFreshness(item)); + assertEquals("Cow",MilkBucketSnapshot.readOrigin(item)); + } + FoodItem milk=FoodLoader.getByString("milk_bucket"); milk.setOrigin("Goat"); + TagTrack freshness=new TagTrack(TrackLoader.getByString("freshness")); freshness.setValue(22); milk.addOrModifyTrack(freshness); + ItemStack stack=ItemBuilder.buildSingleWithQuality(milk,4); + assertEquals(4,MilkBucketSnapshot.readQuality(stack)); assertEquals(22,MilkBucketSnapshot.readDairyFreshness(stack)); + assertEquals("Goat",MilkBucketSnapshot.readOrigin(stack)); + freshness.forceSetValue(-5); milk.addOrModifyTrack(freshness); milk.setOrigin(null); + assertEquals(0,MilkBucketSnapshot.readDairyFreshness(ItemBuilder.buildSingleWithQuality(milk,1))); + assertEquals("Cow",MilkBucketSnapshot.readOrigin(ItemBuilder.buildSingleWithQuality(milk,1))); + milk.getTagTracks().clear(); assertEquals(0,MilkBucketSnapshot.readDairyFreshness(ItemBuilder.buildSingleWithQuality(milk,1))); + } + + @Test void conversionPreservesStackSizeAndUsesOverridesOrPickupQuality() { + ConversionLoader.conversions.put("v.milk_bucket","ingredient(type=milk_bucket;origin=Goat;tags=freshness.4)"); + ItemStack plain=new ItemStack(Material.MILK_BUCKET,3); + ItemStack converted=MilkBucketConverter.convert(player,plain,5); + assertEquals(3,converted.getAmount()); assertEquals(5,MilkBucketSnapshot.readQuality(converted)); + assertEquals("Goat",MilkBucketSnapshot.readOrigin(converted)); assertEquals(4,MilkBucketSnapshot.readDairyFreshness(converted)); + assertNull(FoodItem.fromItem(plain)); assertSame(converted,MilkBucketConverter.convert(player,converted)); + assertSame(converted,MilkBucketConverter.convertIfNeeded(player,converted)); + assertEquals(3,MilkBucketSnapshot.readQuality(player,plain)); + assertEquals(4,MilkBucketSnapshot.readDairyFreshness(player,plain)); + assertEquals("Goat",MilkBucketSnapshot.readOrigin(player,plain)); + when(api.getChecker().checkItemWithPath(plain,"v.milk_bucket")).thenReturn(true); + assertEquals(3,MilkBucketSnapshot.readQuality(MilkBucketConverter.convertIfNeeded(player,plain))); + } + + @Test void unrelatedOrUnconfiguredItemsAreLeftIntact() { + ItemStack stone=new ItemStack(Material.STONE), milk=new ItemStack(Material.MILK_BUCKET); + assertNull(MilkBucketConverter.convertIfNeeded(player,null)); assertSame(stone,MilkBucketConverter.convertIfNeeded(player,stone)); + assertNull(MilkBucketConverter.convert(player,null)); assertSame(stone,MilkBucketConverter.convert(player,stone)); + assertSame(milk,MilkBucketConverter.convert(player,milk)); + ConversionLoader.conversions.put("v.milk_bucket","invalid"); assertSame(milk,MilkBucketConverter.convert(player,milk)); + ConversionLoader.conversions.put("v.milk_bucket","ingredient(type=missing)"); assertSame(milk,MilkBucketConverter.convert(player,milk)); + } + + @Test void bucketFillAppliesDeferredConversionOnlyToUnconvertedMilkStillHeld() { + MilkBucketConverter listener=new MilkBucketConverter(); + PlayerBucketFillEvent event=mock(PlayerBucketFillEvent.class); when(event.getPlayer()).thenReturn(player); + when(event.getBucket()).thenReturn(Material.WATER_BUCKET); listener.onBucketFill(event); + server.getScheduler().performOneTick(); + when(event.getBucket()).thenReturn(Material.MILK_BUCKET); listener.onBucketFill(event); + ItemStack plain=new ItemStack(Material.MILK_BUCKET); when(event.getItemStack()).thenReturn(plain); + listener.onBucketFill(event); server.getScheduler().performOneTick(); + ConversionLoader.conversions.put("v.milk_bucket","ingredient(type=milk_bucket;origin=Cow)"); + player.getInventory().setItemInMainHand(new ItemStack(Material.MILK_BUCKET,2)); + listener.onBucketFill(event); server.getScheduler().performOneTick(); + assertEquals(2,player.getInventory().getItemInMainHand().getAmount()); + assertEquals(3,MilkBucketSnapshot.readQuality(player.getInventory().getItemInMainHand())); + ItemStack already=player.getInventory().getItemInMainHand(); + listener.onBucketFill(event); server.getScheduler().performOneTick(); assertEquals(already,player.getInventory().getItemInMainHand()); + listener.onBucketFill(event); player.getInventory().setItemInMainHand(new ItemStack(Material.STONE)); + server.getScheduler().performOneTick(); assertEquals(Material.STONE,player.getInventory().getItemInMainHand().getType()); + listener.onBucketFill(event); player.getInventory().setItemInMainHand(null); server.getScheduler().performOneTick(); + assertTrue(player.getInventory().getItemInMainHand().getType().isAir()); + } + + 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))); + FoodLoader.oList.add(food); return food; + } + private void assertMilk(ItemStack stack,int quality,int freshness,String origin) { + FoodItem food=FoodItem.fromItem(stack); assertNotNull(food); assertEquals("cup_of_milk",food.getId()); + assertEquals(quality,food.getQualityMin()); assertEquals(freshness,food.getTagTrack("freshness").getValue()); + assertEquals(origin,food.getOrigin()); + } +} diff --git a/src/test/java/net/tfminecraft/cooking/farming/FarmingCoverageTest.java b/src/test/java/net/tfminecraft/cooking/farming/FarmingCoverageTest.java new file mode 100644 index 0000000..e99bfe1 --- /dev/null +++ b/src/test/java/net/tfminecraft/cooking/farming/FarmingCoverageTest.java @@ -0,0 +1,274 @@ +package net.tfminecraft.cooking.farming; + +import static org.junit.jupiter.api.Assertions.*; +import static org.mockito.ArgumentMatchers.*; +import static org.mockito.Mockito.*; + +import java.io.File; +import java.lang.reflect.Field; +import java.lang.reflect.Modifier; +import java.nio.file.Path; +import java.util.*; +import java.util.concurrent.ThreadLocalRandom; +import java.util.function.Consumer; +import java.util.logging.Logger; +import net.tfminecraft.cooking.Cooking; +import net.tfminecraft.cooking.crops.*; +import net.tfminecraft.cooking.item.FoodItem; +import net.tfminecraft.cooking.item.model.FoodModel; +import net.tfminecraft.cooking.loader.*; +import net.tfminecraft.cooking.quality.QualityConfig; +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.configuration.MemoryConfiguration; +import org.bukkit.configuration.file.YamlConfiguration; +import org.bukkit.enchantments.Enchantment; +import org.bukkit.entity.*; +import org.bukkit.event.*; +import org.bukkit.event.block.*; +import org.bukkit.event.player.*; +import org.bukkit.inventory.*; +import org.bukkit.inventory.meta.Damageable; +import org.bukkit.plugin.Plugin; +import org.junit.jupiter.api.*; +import org.junit.jupiter.api.io.TempDir; +import org.mockbukkit.mockbukkit.MockBukkit; +import org.mockbukkit.mockbukkit.ServerMock; +import org.mockbukkit.mockbukkit.block.BlockMock; +import org.mockbukkit.mockbukkit.entity.PlayerMock; +import org.mockbukkit.mockbukkit.world.WorldMock; +import org.mockito.MockedStatic; + +public class FarmingCoverageTest { + @TempDir Path directory; + Plot f; + @BeforeEach void setUp() throws Exception { f = new Plot(directory); } + @AfterEach void tearDown() throws Exception { f.close(); } + + @Test void loaderDefaultsAndInvalidFilesResetEarlierConfiguration() throws Exception { + f.config.set("farming.enabled", false); f.loadFarming(); assertFalse(FarmingConfig.enabled()); assertFalse(FarmingConfig.antiTrampleEnabled()); + new FarmingLoader().load(directory.resolve("missing.yml").toFile()); assertDefaults(); + File invalid = directory.resolve("invalid.yml").toFile(); java.nio.file.Files.writeString(invalid.toPath(), "farming: [broken"); new FarmingLoader().load(invalid); assertDefaults(); + f.config = new YamlConfiguration(); f.loadFarming(); assertDefaults(); + } + + @Test void loaderParsesToolsCropsEffectsAndTrampleConfiguration() throws Exception { + f.config.loadFromString(""" + farming: + enabled: true + effects: {harvest-particle-multiplier: 2, replant-particle-multiplier: 3, tool-swing-particle: false} + replant-delay-min: 4 + replant-delay-max: 2 + only-harvest-mature: false + tool-damage-per-harvest: 3 + apply-unbreaking: false + tools: + - {path: ' hoe ', radius: 2, quality-bonus-percent: 15} + - {path: other, radius: '3', quality-bonus-percent: '25.5'} + - {path: bad, radius: nope, quality-bonus-percent: nope} + - {path: defaults} + - {radius: 4} + - {path: ' '} + crops: + - {crop: WHEAT, seed: WHEAT_SEEDS} + - {crop: CARROTS, seed: CARROT} + - {crop: INVALID, seed: WHEAT_SEEDS} + - {crop: POTATOES, seed: INVALID} + - {crop: BEETROOTS} + anti-trample: {enabled: false, trample-by-walking: true, dry-empty-farmland: false, trample-particle-multiplier: 4, crops: [WHEAT, INVALID]} + """); f.loadFarming(); + assertEquals(4, FarmingConfig.tools().size()); assertEquals("hoe", FarmingConfig.tools().getFirst().path()); assertEquals(2, FarmingConfig.tools().getFirst().radius()); assertEquals(15, FarmingConfig.tools().getFirst().qualityBonusPercent()); + assertEquals(3, FarmingConfig.tools().get(1).radius()); assertEquals(25.5, FarmingConfig.tools().get(1).qualityBonusPercent()); assertEquals(0, FarmingConfig.tools().get(2).radius()); assertEquals(0, FarmingConfig.tools().get(2).qualityBonusPercent()); + assertEquals(Set.of(Material.WHEAT, Material.CARROTS), FarmingConfig.cropMaterials()); assertEquals(Material.WHEAT_SEEDS, FarmingConfig.cropFor(Material.WHEAT).seed()); assertEquals(2, FarmingConfig.harvestParticleMultiplier()); assertEquals(3, FarmingConfig.replantParticleMultiplier()); assertFalse(FarmingConfig.toolSwingParticle()); assertEquals(4, FarmingConfig.replantDelayMax()); assertFalse(FarmingConfig.onlyHarvestMature()); assertEquals(3, FarmingConfig.toolDamagePerHarvest()); assertFalse(FarmingConfig.applyUnbreaking()); assertFalse(FarmingConfig.antiTrampleEnabled()); assertTrue(FarmingConfig.trampleByWalking()); assertFalse(FarmingConfig.dryEmptyFarmland()); assertEquals(4, FarmingConfig.trampleParticleMultiplier()); assertTrue(FarmingConfig.isTrampleableCrop(Material.WHEAT)); assertFalse(FarmingConfig.isTrampleableCrop(null)); assertFalse(FarmingConfig.isTrampleableCrop(Material.CARROTS)); + f.config.set("farming.anti-trample.crops", List.of("INVALID")); f.loadFarming(); assertTrue(FarmingConfig.isTrampleableCrop(Material.PUMPKIN_STEM)); f.config.set("farming.anti-trample.crops", List.of()); f.loadFarming(); assertTrue(FarmingConfig.isTrampleableCrop(Material.NETHER_WART)); + } + + @Test void matcherUsesFirstMatchingConfiguredToolAndHandlesMissingMaterials() { + assertNull(FarmingToolMatcher.matchTool(null)); assertNull(FarmingToolMatcher.matchTool(new ItemStack(Material.AIR))); assertNull(FarmingToolMatcher.matchTool(new ItemStack(Material.STICK))); assertEquals("hoe", FarmingToolMatcher.matchTool(new ItemStack(Material.IRON_HOE)).path()); assertNull(FarmingToolMatcher.cropFor(null)); assertNull(FarmingToolMatcher.cropFor(Material.STONE)); assertEquals(Material.WHEAT, FarmingToolMatcher.cropFor(Material.WHEAT).crop()); + } + + @Test void scannerFindsTheConnectedSquareAndAllFourSlopingDirections() { + BlockMock centre = f.crop(0, 65, 0, Material.WHEAT, true); + assertTrue(FarmAreaScanner.findCropBlocks(null, 2, Set.of(Material.WHEAT)).isEmpty()); assertTrue(FarmAreaScanner.findCropBlocks(centre, 2, null).isEmpty()); assertTrue(FarmAreaScanner.findCropBlocks(centre, 2, Set.of()).isEmpty()); assertTrue(FarmAreaScanner.findCropBlocks(centre, 2, Set.of(Material.CARROTS)).isEmpty()); assertEquals(List.of(centre), FarmAreaScanner.findCropBlocks(centre, 0, Set.of(Material.WHEAT))); + for (int x=-2;x<=2;x++) for (int z=-2;z<=2;z++) f.crop(x,65,z,Material.WHEAT,true); + List square = FarmAreaScanner.findCropBlocks(centre, 2, Set.of(Material.WHEAT)); assertEquals(25, square.size()); assertEquals(25, new HashSet<>(square).size()); + for (int x=-2;x<=2;x++) for (int z=-2;z<=2;z++) if (x!=0 || z!=0) f.block(x,65,z,Material.AIR); + f.crop(1,66,0,Material.WHEAT,true); f.crop(-1,64,0,Material.WHEAT,true); f.crop(0,66,1,Material.WHEAT,true); f.crop(0,64,-1,Material.WHEAT,true); + assertEquals(5, FarmAreaScanner.findCropBlocks(centre,2,Set.of(Material.WHEAT)).size()); + } + + @Test void scannerDoesNotJumpGapsOrHarvestBelowSolidObstructions() { + BlockMock centre = f.crop(0,65,0,Material.WHEAT,true); + for (int sign : List.of(-1,1)) { f.crop(2*sign,65,0,Material.WHEAT,true); f.crop(0,65,2*sign,Material.WHEAT,true); f.crop(sign,65,sign,Material.WHEAT,true); f.crop(sign,65,-sign,Material.WHEAT,true); } + assertEquals(List.of(centre), FarmAreaScanner.findCropBlocks(centre,2,Set.of(Material.WHEAT))); + f.block(1,65,0,Material.STONE); f.crop(1,64,0,Material.WHEAT,true); assertEquals(List.of(centre),FarmAreaScanner.findCropBlocks(centre,2,Set.of(Material.WHEAT))); + f.block(0,65,1,Material.WHEAT); f.block(1,65,1,Material.WHEAT); List connected=FarmAreaScanner.findCropBlocks(centre,2,Set.of(Material.WHEAT)); assertTrue(connected.contains(f.world.getBlockAt(1,65,1))); assertFalse(connected.contains(f.world.getBlockAt(1,64,0))); + } + + @Test void harvestReservesOneSeedTransfersProduceAndReplantsAtAgeZero() { + BlockMock crop = harvestable(); crop.setDrops(List.of(new ItemStack(Material.WHEAT_SEEDS,2), new ItemStack(Material.WHEAT,3))); f.holdHoe(); + assertTrue(FarmHarvestService.harvestBlock(f.player, f.tool(), hoe(), crop, wheat())); assertEquals(Material.AIR,crop.getType()); assertEquals(1,f.inventoryCount(Material.WHEAT_SEEDS)); assertEquals(3,f.inventoryCount(Material.WHEAT)); + f.server.getScheduler().performTicks(2); assertEquals(Material.WHEAT,crop.getType()); assertEquals(0,((Ageable)crop.getBlockData()).getAge()); assertEquals(1,f.inventoryCount(Material.WHEAT_SEEDS)); + } + + @Test void harvestCanConvertProduceWithOneQualityWhilePreservingSeeds() { + f.food("grain",Material.BREAD); ConversionLoader.conversions.put("v.wheat","grain(type=grain;origin=Wheat)"); + 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(); + try(MockedStatic quality=mockStatic(CropHarvestQuality.class)) { + quality.when(() -> CropHarvestQuality.roll("wheat",crop.getLocation(),f.player)).thenReturn(2); + assertTrue(FarmHarvestService.harvestBlock(f.player,f.tool(),new FarmingToolDefinition("hoe",0,100),crop,wheat())); + } + 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 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())); + f.mature(crop); f.on(BlockBreakEvent.class,event -> event.setCancelled(true)); assertFalse(FarmHarvestService.harvestBlock(f.player,f.tool(),hoe(),crop,wheat())); assertEquals(Material.WHEAT,crop.getType()); assertEquals(0,f.inventoryCount(Material.WHEAT)); + } + + @Test void replantRefundsSeedWhenPlayerLeavesBlockChangesOrPlacementIsDenied() { + for (int mode=0;mode<4;mode++) { + BlockMock crop=f.crop(mode*4,65,0,Material.WHEAT,true); crop.setDrops(List.of(new ItemStack(Material.WHEAT_SEEDS))); f.holdHoe(); + assertTrue(FarmHarvestService.harvestBlock(f.player,f.tool(),hoe(),crop,wheat())); + if(mode==0) f.player.disconnect(); + if(mode==1) crop.setType(Material.STONE); + if(mode==2) f.on(BlockPlaceEvent.class,event -> event.setCancelled(true)); + if(mode==3) f.on(BlockPlaceEvent.class,event -> event.setBuild(false)); + f.server.getScheduler().performTicks(2); assertEquals(mode==1?Material.STONE:Material.AIR,crop.getType()); assertEquals(mode+1,f.droppedCount(Material.WHEAT_SEEDS)); + if(mode==0) f.player.reconnect(); HandlerList.unregisterAll(f.plugin); + } + } + + @Test void fullInventoryDropsExcessAndMissingSeedsDoNotScheduleReplant() { + f.holdHoe(); for(int i=1;i<36;i++) f.player.getInventory().setItem(i,new ItemStack(Material.STONE,64)); + f.player.getInventory().setHelmet(new ItemStack(Material.DIAMOND_HELMET)); f.player.getInventory().setChestplate(new ItemStack(Material.DIAMOND_CHESTPLATE)); f.player.getInventory().setLeggings(new ItemStack(Material.DIAMOND_LEGGINGS)); f.player.getInventory().setBoots(new ItemStack(Material.DIAMOND_BOOTS)); f.player.getInventory().setItemInOffHand(new ItemStack(Material.SHIELD)); + BlockMock crop=harvestable(); crop.setDrops(Arrays.asList(null,new ItemStack(Material.AIR),new ItemStack(Material.WHEAT,4))); + assertEquals(3,crop.getDrops(f.tool()).size()); Player full=f.fullStoragePlayer(); + assertTrue(FarmHarvestService.harvestBlock(full,f.tool(),hoe(),crop,wheat())); assertEquals(4,f.droppedCount(Material.WHEAT)); f.server.getScheduler().performTicks(2); assertEquals(Material.AIR,crop.getType()); + } + + @Test void areaHarvestDamagesToolsAndStopsWhenTheyBreakOrDisappear() throws Exception { + f.config.set("farming.tools",List.of(Map.of("path","hoe","radius",1))); f.loadFarming(); f.holdHoe(); + BlockMock first=harvestable(); f.crop(1,65,0,Material.WHEAT,true); Damageable meta=(Damageable)f.tool().getItemMeta(); meta.setDamage(Material.IRON_HOE.getMaxDurability()-1); f.tool().setItemMeta(meta); + FarmHarvestService.harvestArea(f.player,f.tool(),new FarmingToolDefinition("hoe",1,0),first); assertEquals(0,f.tool().getAmount()); assertEquals(1,List.of(first,f.world.getBlockAt(1,65,0)).stream().filter(b -> b.getType()==Material.AIR).count()); + Block remaining=List.of(first,f.world.getBlockAt(1,65,0)).stream().filter(b -> b.getType()==Material.WHEAT).findFirst().orElseThrow(); + FarmHarvestService.harvestArea(f.player,f.tool(),hoe(),remaining); assertEquals(Material.WHEAT,remaining.getType(),"The exhausted tool cannot harvest another crop"); + f.holdHoe(); BlockMock second=harvestable(); f.on(BlockBreakEvent.class,event -> f.player.getInventory().setItemInMainHand(new ItemStack(Material.AIR))); FarmHarvestService.harvestArea(f.player,f.tool(),new FarmingToolDefinition("hoe",1,0),second); assertEquals(Material.AIR,second.getType()); + FarmHarvestService.harvestArea(null,f.tool(),hoe(),second); FarmHarvestService.harvestArea(f.player,null,hoe(),second); FarmHarvestService.harvestArea(f.player,f.tool(),null,second); FarmHarvestService.harvestArea(f.player,f.tool(),hoe(),null); + } + + @Test void toolDamageRespectsGameModesDurabilityAndDeterministicUnbreaking() throws Exception { + for(GameMode mode: List.of(GameMode.CREATIVE,GameMode.SPECTATOR,GameMode.ADVENTURE,GameMode.SURVIVAL)) { + f.player.setGameMode(mode); f.holdHoe(); FarmHarvestService.harvestArea(f.player,f.tool(),hoe(),harvestable()); assertEquals(mode==GameMode.ADVENTURE||mode==GameMode.SURVIVAL?1:0,((Damageable)f.tool().getItemMeta()).getDamage()); + } + f.player.setGameMode(GameMode.SURVIVAL); f.holdHoe(); var meta=f.tool().getItemMeta(); meta.setUnbreakable(true); f.tool().setItemMeta(meta); FarmHarvestService.harvestArea(f.player,f.tool(),hoe(),harvestable()); assertEquals(0,((Damageable)f.tool().getItemMeta()).getDamage()); + f.player.getInventory().setItemInMainHand(new ItemStack(Material.STICK)); FarmHarvestService.harvestArea(f.player,f.tool(),hoe(),harvestable()); assertEquals(Material.STICK,f.tool().getType()); + f.config.set("farming.tool-damage-per-harvest",0); f.loadFarming(); f.holdHoe(); FarmHarvestService.harvestArea(f.player,f.tool(),hoe(),harvestable()); assertEquals(0,((Damageable)f.tool().getItemMeta()).getDamage()); + f.config.set("farming.tool-damage-per-harvest",1); f.config.set("farming.apply-unbreaking",true); f.loadFarming(); f.tool().addUnsafeEnchantment(Enchantment.UNBREAKING,3); + ThreadLocalRandom random=mock(ThreadLocalRandom.class); when(random.nextDouble()).thenReturn(0.9,0.1); + try(MockedStatic rng=mockStatic(ThreadLocalRandom.class)) { rng.when(ThreadLocalRandom::current).thenReturn(random); FarmHarvestService.harvestArea(f.player,f.tool(),hoe(),harvestable()); assertEquals(0,((Damageable)f.tool().getItemMeta()).getDamage()); FarmHarvestService.harvestArea(f.player,f.tool(),hoe(),harvestable()); assertEquals(1,((Damageable)f.tool().getItemMeta()).getDamage()); } + } + + @Test void listenerHandlesOnlyConfiguredCropsAndNeverRecursesDuringItsSyntheticBreak() throws Exception { + FarmHarvestListener listener=new FarmHarvestListener(); f.server.getPluginManager().registerEvents(listener,f.plugin); f.holdHoe(); BlockMock crop=harvestable(); crop.setDrops(List.of(new ItemStack(Material.WHEAT,2))); var event=new BlockBreakEvent(crop,f.player); f.server.getPluginManager().callEvent(event); assertTrue(event.isCancelled()); assertEquals(Material.AIR,crop.getType()); assertEquals(2,f.inventoryCount(Material.WHEAT)); + var stone=new BlockBreakEvent(f.block(5,65,0,Material.STONE),f.player); listener.onCropBreak(stone); assertFalse(stone.isCancelled()); f.player.getInventory().setItemInMainHand(new ItemStack(Material.STICK)); var noHoe=new BlockBreakEvent(harvestable(),f.player); listener.onCropBreak(noHoe); assertFalse(noHoe.isCancelled()); + f.config.set("farming.enabled",false); f.loadFarming(); f.holdHoe(); var disabled=new BlockBreakEvent(harvestable(),f.player); listener.onCropBreak(disabled); assertFalse(disabled.isCancelled()); + } + + @Test void trampleProtectsFarmlandAndResetsCropAgeThroughCancellableEvents() { + FarmTrampleListener listener=new FarmTrampleListener(); f.server.getPluginManager().registerEvents(listener,f.plugin); BlockMock crop=harvestable(); Block farmland=crop.getRelative(BlockFace.DOWN); farmland.setType(Material.FARMLAND); + var event=new PlayerInteractEvent(f.player,Action.PHYSICAL,null,farmland,BlockFace.SELF); f.server.getPluginManager().callEvent(event); assertEquals(Event.Result.DENY,event.useInteractedBlock()); assertEquals(Material.FARMLAND,farmland.getType()); assertEquals(0,((Ageable)crop.getBlockData()).getAge()); + f.mature(crop); f.on(BlockFadeEvent.class,fade -> fade.setCancelled(true)); listener.onFarmlandTrample(new PlayerInteractEvent(f.player,Action.PHYSICAL,null,farmland,BlockFace.SELF)); assertEquals(((Ageable)crop.getBlockData()).getMaximumAge(),((Ageable)crop.getBlockData()).getAge()); + HandlerList.unregisterAll(f.plugin); f.on(PlayerInteractEvent.class,physical -> physical.setUseInteractedBlock(Event.Result.DENY)); listener.onFarmlandTrample(new PlayerInteractEvent(f.player,Action.PHYSICAL,null,farmland,BlockFace.SELF)); assertEquals(((Ageable)crop.getBlockData()).getMaximumAge(),((Ageable)crop.getBlockData()).getAge()); + } + + @Test void trampleAndWalkingIgnoreUnrelatedActionsAndRespectSettings() throws Exception { + FarmTrampleListener listener=new FarmTrampleListener(); BlockMock crop=harvestable(); Block farmland=crop.getRelative(BlockFace.DOWN); farmland.setType(Material.FARMLAND); + listener.onFarmlandTrample(new PlayerInteractEvent(f.player,Action.LEFT_CLICK_AIR,null,null,BlockFace.SELF)); listener.onFarmlandTrample(new PlayerInteractEvent(f.player,Action.LEFT_CLICK_BLOCK,null,farmland,BlockFace.SELF)); listener.onFarmlandTrample(new PlayerInteractEvent(f.player,Action.PHYSICAL,null,crop,BlockFace.SELF)); + crop.setType(Material.AIR); listener.onFarmlandTrample(new PlayerInteractEvent(f.player,Action.PHYSICAL,null,farmland,BlockFace.SELF)); f.config.set("farming.anti-trample.dry-empty-farmland",false); f.loadFarming(); listener.onFarmlandTrample(new PlayerInteractEvent(f.player,Action.PHYSICAL,null,farmland,BlockFace.SELF)); + Location from=new Location(f.world,2,64,0),to=farmland.getLocation(); var move=new PlayerMoveEvent(f.player,from,to); listener.onWalk(move); f.config.set("farming.anti-trample.trample-by-walking",true); f.loadFarming(); listener.onWalk(move); f.crop(0,65,0,Material.WHEAT,true); listener.onWalk(move); assertEquals(0,((Ageable)crop.getBlockData()).getAge()); + f.mature(crop); f.player.setSneaking(true); listener.onWalk(move); f.player.setSneaking(false); var cancelled=new PlayerMoveEvent(f.player,from,to); cancelled.setCancelled(true); listener.onWalk(cancelled); listener.onWalk(new PlayerMoveEvent(f.player,from,null)); listener.onWalk(new PlayerMoveEvent(f.player,to,to.clone())); listener.onWalk(new PlayerMoveEvent(f.player,to,from)); assertEquals(((Ageable)crop.getBlockData()).getMaximumAge(),((Ageable)crop.getBlockData()).getAge()); + f.config.set("farming.enabled",false); f.loadFarming(); listener.onWalk(move); listener.onFarmlandTrample(new PlayerInteractEvent(f.player,Action.PHYSICAL,null,farmland,BlockFace.SELF)); + } + + @Test void effectsUseConfiguredParticleCountsAndQualityBonusesClamp() throws Exception { + BlockMock crop=harvestable(); FarmingEffects.breakCrop(crop,crop.getState(),0,2); verify(f.world).spawnParticle(eq(Particle.BLOCK),any(Location.class),eq(20),eq(0.5),eq(0.5),eq(0.5),any()); FarmingEffects.breakCrop(crop,crop.getState(),2,1); FarmingEffects.trampleCrop(crop,crop.getState(),1); FarmingEffects.replant(crop,2); FarmingEffects.toolSwing(f.player); + FarmingEffects.breakCrop(null,crop.getState(),0,1); FarmingEffects.breakCrop(crop,null,0,1); FarmingEffects.replant(null,1); FarmingEffects.toolSwing(null); + Block nowhere=mock(Block.class); when(nowhere.getLocation()).thenReturn(new Location(null,0,0,0)); FarmingEffects.breakCrop(nowhere,crop.getState(),0,1); FarmingEffects.replant(nowhere,1); Player absent=mock(Player.class); when(absent.getLocation()).thenReturn(new Location(null,0,0,0)); when(absent.getEyeLocation()).thenReturn(new Location(null,0,0,0)); FarmingEffects.toolSwing(absent); + f.config.set("farming.effects.tool-swing-particle",false); f.loadFarming(); clearInvocations(f.world); FarmingEffects.toolSwing(f.player); verify(f.world,never()).spawnParticle(eq(Particle.SWEEP_ATTACK),any(Location.class),anyInt()); + assertEquals(3,HoeQualityBonus.apply(3,0)); assertEquals(5,HoeQualityBonus.apply(5,100)); ThreadLocalRandom random=mock(ThreadLocalRandom.class); when(random.nextDouble()).thenReturn(0.8,0.2); try(MockedStatic rng=mockStatic(ThreadLocalRandom.class)) { rng.when(ThreadLocalRandom::current).thenReturn(random); assertEquals(3,HoeQualityBonus.apply(3,50)); assertEquals(4,HoeQualityBonus.apply(3,50)); } + } + + @Test void harvestAreaSkipsChangedNeighboursAndStopsForAnEmptyHand() { + BlockMock first=harvestable(),next=f.crop(1,65,0,Material.WHEAT,true);f.player.getInventory().setItemInMainHand(new ItemStack(Material.AIR)); + FarmHarvestService.harvestArea(f.player,new ItemStack(Material.IRON_HOE),new FarmingToolDefinition("hoe",1,0),first);assertEquals(Material.WHEAT,first.getType()); + f.holdHoe();f.on(BlockBreakEvent.class,event->next.setType(Material.STONE));FarmHarvestService.harvestArea(f.player,f.tool(),new FarmingToolDefinition("hoe",1,0),first);assertEquals(Material.AIR,first.getType());assertEquals(Material.STONE,next.getType());assertEquals(1,((Damageable)f.tool().getItemMeta()).getDamage()); + } + + @Test void nonAgeableCropsReserveSeedsAndReplantWithinConfiguredDelayRange()throws Exception { + ItemStack exhaustedSeed=new ItemStack(Material.BROWN_MUSHROOM); exhaustedSeed.setAmount(0); + f.config.set("farming.replant-delay-min",1);f.config.set("farming.replant-delay-max",3);f.loadFarming();f.holdHoe();BlockMock mushroom=f.block(0,65,0,Material.BROWN_MUSHROOM);f.block(0,64,0,Material.MYCELIUM);mushroom.setDrops(List.of(new ItemStack(Material.STICK),exhaustedSeed,new ItemStack(Material.BROWN_MUSHROOM,2))); + assertFalse(mushroom.getBlockData() instanceof Ageable); + assertTrue(FarmHarvestService.harvestBlock(f.player,f.tool(),hoe(),mushroom,new FarmingCropDefinition(Material.BROWN_MUSHROOM,Material.BROWN_MUSHROOM)));assertEquals(1,f.inventoryCount(Material.STICK));assertEquals(1,f.inventoryCount(Material.BROWN_MUSHROOM));f.server.getScheduler().performTicks(4);assertEquals(Material.BROWN_MUSHROOM,mushroom.getType()); + } + + private BlockMock harvestable(){return f.crop(0,65,0,Material.WHEAT,true);} + private FarmingToolDefinition hoe(){return FarmingConfig.tools().getFirst();} + private FarmingCropDefinition wheat(){return FarmingConfig.cropFor(Material.WHEAT);} + private static void assertDefaults(){assertTrue(FarmingConfig.enabled());assertTrue(FarmingConfig.antiTrampleEnabled());assertEquals(10,FarmingConfig.replantDelayMin());assertEquals(20,FarmingConfig.replantDelayMax());assertTrue(FarmingConfig.tools().isEmpty());assertTrue(FarmingConfig.cropsByType().isEmpty());assertTrue(FarmingConfig.isTrampleableCrop(Material.WHEAT));} + + public static final class Plot implements AutoCloseable { + public final Path directory; public final ServerMock server; public final FieldWorld world; public final PlayerMock player; public final Cooking plugin; private final Cooking previous; + 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; + 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(""" + farming: + replant-delay-min: 1 + replant-delay-max: 1 + apply-unbreaking: false + tools: [{path: hoe, radius: 0}] + crops: [{crop: WHEAT, seed: WHEAT_SEEDS}, {crop: POTATOES, seed: POTATO}] + """); loadFarming(); items=mock(ItemAPI.class,RETURNS_DEEP_STUBS); tlibs=mockStatic(TLibs.class); tlibs.when(TLibs::getItemAPI).thenReturn(items); + when(items.getChecker().checkItemWithPath(any(),anyString())).thenAnswer(call->{ItemStack item=call.getArgument(0);String path=call.getArgument(1);if(item==null)return false;if(path.equals("hoe"))return item.getType()==Material.IRON_HOE;if(path.equals("seed"))return item.getType()==Material.WHEAT_SEEDS;if(path.startsWith("v."))return item.getType().name().equalsIgnoreCase(path.substring(2));return false;}); + } + public void loadFarming()throws Exception{File file=directory.resolve("farming.yml").toFile();config.save(file);new FarmingLoader().load(file);} + public void loadCrops(YamlConfiguration yaml)throws Exception{File file=directory.resolve("crops.yml").toFile();yaml.save(file);new CropsLoader().load(file);} + public BlockMock block(int x,int y,int z,Material type){BlockMock block=world.getBlockAt(x,y,z);block.setType(type);return block;} + public BlockMock crop(int x,int y,int z,Material type,boolean mature){BlockMock block=block(x,y,z,type);if(mature)mature(block);block.getRelative(BlockFace.DOWN).setType(Material.FARMLAND);return block;} + public void mature(Block block){if(block.getBlockData() instanceof Ageable age){age.setAge(age.getMaximumAge());block.setBlockData(age);}} + public void holdHoe(){player.getInventory().setItemInMainHand(new ItemStack(Material.IRON_HOE));} + public ItemStack tool(){return player.getInventory().getItemInMainHand();} + public int inventoryCount(Material material){return Arrays.stream(player.getInventory().getStorageContents()).filter(Objects::nonNull).filter(i->i.getType()==material).mapToInt(ItemStack::getAmount).sum();} + public int droppedCount(Material material){return world.getEntitiesByClass(Item.class).stream().map(Item::getItemStack).filter(i->i.getType()==material).mapToInt(ItemStack::getAmount).sum();} + /** 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 void on(Class type,Consumer consumer){server.getPluginManager().registerEvent(type,new Listener(){},EventPriority.NORMAL,(listener,event)->consumer.accept(type.cast(event)),plugin);} + @Override public void close()throws Exception{HandlerList.unregisterAll(plugin);MockBukkit.unmock();tlibs.close();FoodLoader.oList=previousFoods;ModelLoader.models=previousModels;ConversionLoader.conversions=previousConversions;Cooking.plugin=previous;settings.close();} + } + /** BlockMock supplies states and drops; this adapter implements its missing passability query. */ + public static class FieldWorld extends WorldMock { + private final Map fieldBlocks=new HashMap<>(); + @Override public BlockMock getBlockAt(int x,int y,int z){return fieldBlocks.computeIfAbsent(x+":"+y+":"+z,key->new BlockMock(Material.AIR,new Location(this,x,y,z)){@Override public boolean isPassable(){return !getType().isSolid();}});} + } + /** Restore actual static configuration; never inject private runtime states. */ + public static final class StaticConfigSnapshot implements AutoCloseable{ + private final Map saved=new LinkedHashMap<>(); + public StaticConfigSnapshot(Class... types)throws Exception{for(Class type:types)for(Field field:type.getDeclaredFields())if(Modifier.isStatic(field.getModifiers())&&!Modifier.isFinal(field.getModifiers())){field.setAccessible(true);saved.put(field,field.get(null));}} + @Override public void close()throws Exception{for(var entry:saved.entrySet())entry.getKey().set(null,entry.getValue());} + } +} diff --git a/src/test/java/net/tfminecraft/cooking/fishing/FishingCatalogCoverageTest.java b/src/test/java/net/tfminecraft/cooking/fishing/FishingCatalogCoverageTest.java new file mode 100644 index 0000000..14d6fe2 --- /dev/null +++ b/src/test/java/net/tfminecraft/cooking/fishing/FishingCatalogCoverageTest.java @@ -0,0 +1,85 @@ +package net.tfminecraft.cooking.fishing; + +import static org.junit.jupiter.api.Assertions.*; +import static org.mockito.Mockito.*; + +import java.io.File; +import java.nio.file.Files; +import java.nio.file.Path; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.io.TempDir; + +class FishingCatalogCoverageTest { + @TempDir Path directory; + @AfterEach void restore() { CustomFishingCatalog.load(new File("src/main/resources/custom-fishing.yml")); } + + @Test void replacingCatalogsClearsPreviousDefinitionsAndUsesUnknownBandsForEmptyIdentifiers() { + var catches=java.util.Map.of("tuna",new CatchMapping("tuna","Tuna","fish")); + CustomFishingCatalog.replace(catches,java.util.Map.of(),new RodBand(2,3)); + assertNotNull(CustomFishingCatalog.find("TUNA")); assertNull(CustomFishingCatalog.find(null)); assertNull(CustomFishingCatalog.find(" ")); + assertEquals(new RodBand(2,3),CustomFishingCatalog.band(null)); assertEquals(new RodBand(2,3),CustomFishingCatalog.band(" ")); + var fish=java.util.Map.of("COD",new VanillaFish("COD","Cod","fish",30)); + CustomFishingCatalog.replace(java.util.Map.of(),java.util.Map.of(),null,fish); + assertNull(CustomFishingCatalog.find("tuna")); assertEquals(30,CustomFishingCatalog.findVanilla("cod").sizeCm()); + assertNull(CustomFishingCatalog.findVanilla(null)); assertNull(CustomFishingCatalog.findVanilla(" ")); + assertNull(CustomFishingCatalog.cutRule(null)); assertNull(CustomFishingCatalog.cutRule(" ")); + } + + @Test void missingAndFailedConfigurationReadsResetTheCatalog() { + CustomFishingCatalog.load(null); assertTrue(CustomFishingCatalog.catches().isEmpty()); + CustomFishingCatalog.load(directory.resolve("absent").toFile()); assertTrue(CustomFishingCatalog.vanillaFish().isEmpty()); + File raced=mock(File.class); when(raced.isFile()).thenReturn(true); when(raced.toPath()).thenReturn(directory.resolve("deleted")); + CustomFishingCatalog.load(raced); assertEquals(new RodBand(1,5),CustomFishingCatalog.unknownRod()); + CustomFishingCatalog.replace(null,null,null,null,null,null,0); assertEquals(45,CustomFishingCatalog.legacySizeCm()); + } + + @Test void malformedEntriesAreSkippedAndDuplicateModelsHaveDeterministicOwnership() throws Exception { + Path path=directory.resolve("fishing.yml"); Files.writeString(path,""" + # malformed definitions should not poison valid ones + ignored line + : invalid key + legacy-size-cm: NaN + catches: + bad: + origin: Fish + cut: unknown + first: + origin: Tuna + cut: fish + model-data: 10, nope, 11 + second: + origin: Pike + cut: fish + model-data: 10 + third: + origin: Cod + cut: fish + rods: + bad: + min: 7 + max: 1 + unknown-rod: + min: invalid + max: 5 + vanilla: + COD: + origin: Cod + cut: fish + size-cm: wrong + cutting: + fish: + output: fillet + food-per-cm: NaN + min-total: 1 + max-total: 20 + cm-per-portion: 10 + max-portions: 8 + """); + CustomFishingCatalog.load(path.toFile()); + assertEquals(3,CustomFishingCatalog.catches().size()); assertEquals("second",CustomFishingCatalog.findModel(10).id()); + assertEquals("first",CustomFishingCatalog.findModel(11).id()); assertNull(CustomFishingCatalog.findModel(12)); + assertEquals(45,CustomFishingCatalog.legacySizeCm()); assertEquals(new RodBand(1,5),CustomFishingCatalog.band("bad")); + assertNull(CustomFishingCatalog.findVanilla("COD")); assertNull(CustomFishingCatalog.cutRule("fish")); + } +} diff --git a/src/test/java/net/tfminecraft/cooking/fishing/FishingRuntimeCoverageTest.java b/src/test/java/net/tfminecraft/cooking/fishing/FishingRuntimeCoverageTest.java new file mode 100644 index 0000000..cea37ad --- /dev/null +++ b/src/test/java/net/tfminecraft/cooking/fishing/FishingRuntimeCoverageTest.java @@ -0,0 +1,169 @@ +package net.tfminecraft.cooking.fishing; + +import static org.junit.jupiter.api.Assertions.*; +import static org.mockito.ArgumentMatchers.*; +import static org.mockito.Mockito.*; + +import java.io.File; +import java.util.List; +import java.util.Map; +import java.util.logging.Logger; +import net.momirealms.customfishing.api.BukkitCustomFishingPlugin; +import net.momirealms.customfishing.api.event.FishingLootSpawnEvent; +import net.momirealms.customfishing.api.mechanic.context.Context; +import net.momirealms.customfishing.api.mechanic.context.ContextKeys; +import net.momirealms.customfishing.api.mechanic.item.ItemManager; +import net.momirealms.customfishing.api.mechanic.loot.Loot; +import net.tfminecraft.cooking.Cooking; +import net.tfminecraft.cooking.item.FoodItem; +import net.tfminecraft.cooking.quality.PermissionEffectsConfig; +import net.tfminecraft.cooking.quality.QualityConfig; +import net.tfminecraft.cooking.sausagemaker.SausageMakerCoverageTest.Workshop; +import net.tfminecraft.cooking.util.LegacyModelData; +import net.tfminecraft.cooking.utils.ItemBuilder; +import org.bukkit.Bukkit; +import org.bukkit.Material; +import org.bukkit.Server; +import org.bukkit.entity.Item; +import org.bukkit.entity.Player; +import org.bukkit.event.Listener; +import org.bukkit.event.server.PluginEnableEvent; +import org.bukkit.inventory.ItemStack; +import org.bukkit.inventory.meta.ItemMeta; +import org.bukkit.plugin.Plugin; +import org.bukkit.plugin.PluginManager; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.mockito.ArgumentCaptor; +import org.mockito.MockedStatic; + +public class FishingRuntimeCoverageTest { + Workshop env; + PluginManager plugins; + Plugin customFishing; + MockedStatic bukkit; + boolean oldRegistered; + CatchMapping tuna = new CatchMapping("tuna", "Tuna", "fish"); + + @BeforeEach void setup() throws Exception { + var field=CustomFishingBridge.class.getDeclaredField("registered"); field.setAccessible(true); + oldRegistered=field.getBoolean(null); field.setBoolean(null,false); + env=new Workshop(); plugins=mock(PluginManager.class); customFishing=mock(Plugin.class); + when(customFishing.getName()).thenReturn("CustomFishing"); when(customFishing.isEnabled()).thenReturn(true); + when(plugins.getPlugin("CustomFishing")).thenReturn(customFishing); + Server server=mock(Server.class); when(server.getPluginManager()).thenReturn(plugins); + when(Cooking.plugin.getServer()).thenReturn(server); when(Cooking.plugin.getLogger()).thenReturn(Logger.getLogger("fishing-test")); + bukkit=mockStatic(Bukkit.class,CALLS_REAL_METHODS); bukkit.when(Bukkit::getPluginManager).thenReturn(plugins); + CustomFishingCatalog.replace(Map.of("tuna",tuna),Map.of("rod",new RodBand(3,3)),new RodBand(2,2),Map.of(),Map.of(),Map.of(17,"tuna"),45); + QualityConfig.apply(1,5,null,null); PermissionEffectsConfig.apply(List.of(),List.of()); + env.originQuality.when(()->net.tfminecraft.cooking.quality.OriginQualityResolver.applyPickupPermissions(any(),anyInt())) + .thenAnswer(inv->inv.getArgument(1)); + env.templates.put(CustomFishingCatalog.WHOLE_TYPE,env.food(CustomFishingCatalog.WHOLE_TYPE,null,1)); + env.builder.when(()->ItemBuilder.buildSingleWithQuality(any(FoodItem.class),any(ItemStack.class),anyInt())) + .thenAnswer(inv->env.stack(inv.getArgument(0),Material.COD,1)); + } + @AfterEach void cleanup() throws Exception { + bukkit.close(); env.close(); + var field=CustomFishingBridge.class.getDeclaredField("registered"); field.setAccessible(true); field.setBoolean(null,oldRegistered); + CustomFishingCatalog.load(new File("src/main/resources/custom-fishing.yml")); + } + + @Test void optionalIntegrationRegistersOnceAndCanRetryAfterRegistrationFailure() { + CustomFishingBridge bridge=new CustomFishingBridge(); + CustomFishingBridge.tryRegister(null); + when(plugins.getPlugin("CustomFishing")).thenReturn(null); CustomFishingBridge.tryRegister(Cooking.plugin); + when(plugins.getPlugin("CustomFishing")).thenReturn(customFishing); when(customFishing.isEnabled()).thenReturn(false); + CustomFishingBridge.tryRegister(Cooking.plugin); when(customFishing.isEnabled()).thenReturn(true); + bridge.onPluginEnable(new PluginEnableEvent(mock(Plugin.class))); + doThrow(new IllegalStateException("registration unavailable")).doNothing().when(plugins).registerEvents(any(),eq(Cooking.plugin)); + CustomFishingBridge.tryRegister(Cooking.plugin); + bridge.onPluginEnable(new PluginEnableEvent(customFishing)); + CustomFishingBridge.tryRegister(Cooking.plugin); + verify(plugins,times(2)).registerEvents(isA(CustomFishingCatchListener.class),eq(Cooking.plugin)); + } + + @Test void optionalItemFactsHandleUnavailableAndFailingProviders() { + ItemStack stack=new ItemStack(Material.COD); + assertNull(CustomFishingItemFacts.lootId(null)); assertNull(CustomFishingItemFacts.size(null)); + when(plugins.getPlugin("CustomFishing")).thenReturn(null); assertNull(CustomFishingItemFacts.lootId(stack)); + when(plugins.getPlugin("CustomFishing")).thenReturn(customFishing); when(customFishing.isEnabled()).thenReturn(false); + assertNull(CustomFishingItemFacts.size(stack)); when(customFishing.isEnabled()).thenReturn(true); + try(var api=mockStatic(BukkitCustomFishingPlugin.class)) { + assertNull(CustomFishingItemFacts.lootId(stack)); + BukkitCustomFishingPlugin instance=mock(BukkitCustomFishingPlugin.class); ItemManager items=mock(ItemManager.class); + api.when(BukkitCustomFishingPlugin::getInstance).thenReturn(instance); when(instance.getItemManager()).thenReturn(items); + when(items.getFishSize(stack)).thenReturn(null); + assertNull(CustomFishingItemFacts.lootId(stack)); assertNull(CustomFishingItemFacts.size(stack)); + when(items.getCustomFishingItemID(stack)).thenReturn(" ","tuna"); when(items.getFishSize(stack)).thenReturn(23.5f); + assertNull(CustomFishingItemFacts.lootId(stack)); assertEquals("tuna",CustomFishingItemFacts.lootId(stack)); + assertEquals(23.5,CustomFishingItemFacts.size(stack)); + when(items.getFishSize(stack)).thenThrow(new IllegalArgumentException("invalid metadata")); + assertNull(CustomFishingItemFacts.size(stack)); + api.when(BukkitCustomFishingPlugin::getInstance).thenThrow(new IllegalStateException("not initialized")); + assertNull(CustomFishingItemFacts.lootId(stack)); + } + } + + @Test void catchesKeepUnknownAndMalformedLootAndConvertValidFishWithTheirSize() { + CustomFishingCatchListener.register(Cooking.plugin); + ArgumentCaptor captor=ArgumentCaptor.forClass(Listener.class); verify(plugins).registerEvents(captor.capture(),eq(Cooking.plugin)); + CustomFishingCatchListener listener=(CustomFishingCatchListener)captor.getValue(); + listener.onLoot(null); listener.onLoot(mock(FishingLootSpawnEvent.class)); + FishingLootSpawnEvent event=mock(FishingLootSpawnEvent.class); Context context=mock(Context.class); + when(event.getContext()).thenReturn(context); when(event.getPlayer()).thenReturn(env.player); + listener.onLoot(event); + Loot loot=mock(Loot.class); when(loot.id()).thenReturn("tuna"); when(event.getLoot()).thenReturn(loot); + Item entity=mock(Item.class); ItemStack caught=new ItemStack(Material.COD); when(entity.getItemStack()).thenReturn(caught); when(event.getEntity()).thenReturn(entity); + listener.onLoot(event); verify(entity,never()).setItemStack(any()); + when(context.arg(ContextKeys.SIZE)).thenReturn(23.5f); when(context.arg(ContextKeys.ROD)).thenReturn("rod"); + env.templates.clear(); listener.onLoot(event); verify(entity,never()).setItemStack(any()); + env.templates.put(CustomFishingCatalog.WHOLE_TYPE,env.food(CustomFishingCatalog.WHOLE_TYPE,null,1)); + listener.onLoot(event); + ArgumentCaptor output=ArgumentCaptor.forClass(ItemStack.class); verify(entity).setItemStack(output.capture()); + FoodItem fish=env.resolve(output.getValue()); assertEquals("Tuna",fish.getOrigin()); assertEquals(24,fish.getCatchSizeCm()); + assertEquals("tuna",fish.getCustomFishingId()); assertEquals(3,fish.getQualityMin()); + assertEquals(1,output.getValue().getAmount()); + } + + @Test void legacyConversionPreservesStackCountAndOnlyRewritesRecognizedFish() { + when(plugins.getPlugin("CustomFishing")).thenReturn(null); + try(var models=mockStatic(LegacyModelData.class)) { + ItemStack noMeta=mock(ItemStack.class); when(noMeta.getType()).thenReturn(Material.COD); + assertNull(LegacyFishConversion.convert(null,noMeta)); + assertNull(LegacyFishConversion.convert(null,null)); + assertNull(LegacyFishConversion.convert(null,env.stack(env.food("already","Tuna",2),Material.COD,2))); + assertNull(LegacyFishConversion.convert(null,new ItemStack(Material.COD))); + ItemStack external=mock(ItemStack.class); when(external.getType()).thenReturn(Material.COD); when(external.hasItemMeta()).thenReturn(true); + assertNull(LegacyFishConversion.convert(null,external)); + ItemMeta meta=mock(ItemMeta.class); when(external.getItemMeta()).thenReturn(meta); + when(meta.getPersistentDataContainer()).thenReturn(new ItemStack(Material.COD).getItemMeta().getPersistentDataContainer()); + assertNull(LegacyFishConversion.convert(null,external)); + models.when(()->LegacyModelData.has(meta)).thenReturn(true); models.when(()->LegacyModelData.get(meta)).thenReturn(17); + when(external.getAmount()).thenReturn(7); + ItemStack converted=LegacyFishConversion.convert(env.player,external); + assertEquals(7,converted.getAmount()); assertEquals(45,env.resolve(converted).getCatchSizeCm()); + LegacyFishScan scan=new LegacyFishScan(); assertFalse(scan.matches(null)); assertFalse(scan.matches(new ItemStack(Material.SALMON))); + assertTrue(scan.matches(external)); assertFalse(scan.matches(converted)); + scan.update(env.player,null,0,external); scan.update(env.player,env.player.getInventory(),-1,external); + scan.update(env.player,env.player.getInventory(),4,external); assertEquals(7,env.player.getInventory().getItem(4).getAmount()); + scan.update(env.player,env.player.getInventory(),5,new ItemStack(Material.COD)); assertNull(env.player.getInventory().getItem(5)); + env.templates.clear(); assertNull(LegacyFishConversion.convert(env.player,external)); + } + assertFalse(LegacyFishAdapter.decide(false,"COD",null,null,null).replaces()); + } + + @Test void wholeFishBuildersHandleMissingTemplatesAndDescribeVanillaFish() { + ItemStack caught=new ItemStack(Material.COD); + FoodItem described=SeafoodWholeItems.describe(env.templates.get(CustomFishingCatalog.WHOLE_TYPE),tuna,25,4); + assertEquals("tuna",described.getCustomFishingId()); assertEquals(25,described.getCatchSizeCm()); + assertNull(SeafoodWholeItems.build(null,tuna,20,3)); assertNull(SeafoodWholeItems.build(caught,(CatchMapping)null,20,3)); + assertNull(SeafoodWholeItems.build(null,new VanillaFish("COD","Cod","fish",30),3)); + assertNull(SeafoodWholeItems.build(caught,(VanillaFish)null,3)); + FoodItem fish=env.resolve(SeafoodWholeItems.build(caught,new VanillaFish("COD","Cod","fish",30),4)); + assertEquals("Cod",fish.getOrigin()); assertEquals("seafood",fish.getCategory()); assertEquals(30,fish.getCatchSizeCm()); + assertNull(fish.getCustomFishingId()); assertEquals("fish",fish.getSeafoodCutType()); + env.templates.clear(); assertNull(SeafoodWholeItems.build(caught,tuna,20,3)); + assertNull(SeafoodWholeItems.build(caught,new VanillaFish("COD","Cod","fish",30),3)); + } +} diff --git a/src/test/java/net/tfminecraft/cooking/heat/HeatRuntimeCoverageTest.java b/src/test/java/net/tfminecraft/cooking/heat/HeatRuntimeCoverageTest.java new file mode 100644 index 0000000..6151e3d --- /dev/null +++ b/src/test/java/net/tfminecraft/cooking/heat/HeatRuntimeCoverageTest.java @@ -0,0 +1,110 @@ +package net.tfminecraft.cooking.heat; + +import static org.junit.jupiter.api.Assertions.*; +import static org.mockito.Mockito.*; + +import java.util.LinkedHashMap; +import java.util.Locale; +import java.util.Map; +import java.util.Optional; +import java.util.UUID; +import net.tfminecraft.cooking.oven.OvenSlots; +import net.tfminecraft.cooking.oven.OvenState; +import net.tfminecraft.cooking.sausagemaker.SausageMakerCoverageTest.Station; +import net.tfminecraft.cooking.sausagemaker.SausageMakerCoverageTest.Workshop; +import net.tfminecraft.interactiblefurniture.events.FurnitureInteractEvent; +import net.tfminecraft.interactiblefurniture.furniture.Furniture; +import org.bukkit.Location; +import org.bukkit.Material; +import org.bukkit.World; +import org.bukkit.block.data.type.Campfire; +import org.bukkit.configuration.file.YamlConfiguration; +import org.bukkit.inventory.ItemStack; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; + +class HeatRuntimeCoverageTest { + Workshop env; + World world; + Station fire,pan,oven; + Map placed=new LinkedHashMap<>(); + @BeforeEach void setup() { + env=new Workshop(); world=env.server.addSimpleWorld("heat"); + fire=at("fire",0,64,0); pan=at("pan",0,65,0); oven=at("oven",5,64,0); + when(env.manager.getPlacedFurniture()).thenReturn(placed); + HeatSources.load(Map.of("fire",new HeatSourceDefinition("fire",HeatSourceType.CAMPFIRE), + "oven",new HeatSourceDefinition("oven",HeatSourceType.OVEN)), + Map.of("pan",new HeatConsumerDefinition("pan","fire",HeatLookup.BLOCK_BELOW))); + } + @AfterEach void cleanup() { HeatLoader.load(new YamlConfiguration()); env.close(); } + Station at(String id,int x,int y,int z) { + Station s=env.station(id); when(s.furniture.getOriginBlockLocation()).thenReturn(Optional.of(new Location(world,x,y,z))); + placed.put(s.id,s.furniture); return s; + } + @Test void heatRequiresALitCampfireOrALitFueledOven() { + assertFalse(HeatSources.isSource(null)); assertFalse(HeatSources.isConsumer(null)); assertFalse(HeatSources.hasHeat(null)); + assertFalse(HeatSources.stationHasHeat(null)); assertFalse(HeatSources.stationHasHeat(at("unknown",9,9,9).furniture)); + assertFalse(HeatSources.hasHeat(pan.furniture)); assertFalse(HeatSources.hasHeat(fire.furniture)); + for(Material type:new Material[]{Material.CAMPFIRE,Material.SOUL_CAMPFIRE}) { + var block=world.getBlockAt(0,64,0); block.setType(type); Campfire data=(Campfire)block.getBlockData(); + data.setLit(false); block.setBlockData(data); assertFalse(HeatSources.hasHeat(fire.furniture)); + data.setLit(true); block.setBlockData(data); assertTrue(HeatSources.stationHasHeat(fire.furniture)); + assertTrue(HeatSources.stationHasHeat(pan.furniture)); + } + assertFalse(HeatSources.stationHasHeat(oven.furniture)); OvenState.setLit(oven.furniture,true); + OvenState.setStage(oven.furniture,OvenSlots.FILL_ORDER[0],OvenSlots.WoodStage.FRESH); + assertTrue(HeatSources.stationHasHeat(oven.furniture)); + when(fire.furniture.getOriginBlockLocation()).thenReturn(Optional.empty()); assertFalse(HeatSources.hasHeat(fire.furniture)); + } + @Test void lookupUsesUnattachedFurnitureAtExactlyTheBlockBelow() { + assertTrue(HeatSources.findSource(null).isEmpty()); assertTrue(HeatSources.findSource(fire.furniture).isEmpty()); + assertSame(fire.furniture,HeatSources.findSource(pan.furniture).orElseThrow()); + placed.remove(fire.id); assertFalse(HeatSources.consumerHasHeat(pan.furniture)); + Station attached=at("fire",0,64,0); when(attached.furniture.isAttached()).thenReturn(true); + Station noOrigin=at("fire",0,64,0); when(noOrigin.furniture.getOriginBlockLocation()).thenReturn(Optional.empty()); + at("fire",1,64,0); at("fire",0,63,0); at("fire",0,64,1); + Station nowhere=at("fire",0,64,0); when(nowhere.furniture.getOriginBlockLocation()).thenReturn(Optional.of(new Location(null,0,64,0))); + assertTrue(HeatSources.findSource(pan.furniture).isEmpty()); + placed.put(fire.id,fire.furniture); assertSame(fire.furniture,HeatSources.findSource(pan.furniture).orElseThrow()); + when(pan.furniture.getOriginBlockLocation()).thenReturn(Optional.empty()); assertTrue(HeatSources.findSource(pan.furniture).isEmpty()); + when(pan.furniture.getOriginBlockLocation()).thenReturn(Optional.of(new Location(world,0,65,0))); + HeatSources.load(Map.of(),Map.of("pan",new HeatConsumerDefinition("pan","fire",HeatLookup.BLOCK_BELOW))); + assertTrue(HeatSources.findSource(pan.furniture).isEmpty()); + HeatSources.load(Map.of(),Map.of("pan",new HeatConsumerDefinition("pan",null,HeatLookup.BLOCK_BELOW))); + assertTrue(HeatSources.findSource(pan.furniture).isEmpty()); + } + @Test void aConsumerAbovePreventsPickingUpItsHeatSource() { + HeatPickupGuard listener=new HeatPickupGuard(); + assertFalse(HeatSources.hasBlockingConsumerAbove(pan.furniture)); + assertTrue(HeatSources.hasBlockingConsumerAbove(fire.furniture)); + assertFalse(HeatSources.hasBlockingConsumerAbove(oven.furniture)); + when(fire.type.canPickup()).thenReturn(true); env.hold(new ItemStack(Material.AIR)); + FurnitureInteractEvent event=fire.interact(); listener.onInteract(event); assertTrue(event.isCancelled()); + assertTrue(env.messages.getLast().contains("Remove the piece above")); + listener.onInteract(new FurnitureInteractEvent(null,fire.furniture)); listener.onInteract(new FurnitureInteractEvent(env.player,null)); + when(env.player.isSneaking()).thenReturn(true); assertAllowed(listener); when(env.player.isSneaking()).thenReturn(false); + env.hold(new ItemStack(Material.STICK)); assertAllowed(listener); env.hold(new ItemStack(Material.AIR)); + when(fire.furniture.getType()).thenReturn(null); assertAllowed(listener); when(fire.furniture.getType()).thenReturn(fire.type); + when(fire.type.canPickup()).thenReturn(false); assertAllowed(listener); when(fire.type.canPickup()).thenReturn(true); + fire.place("food",new ItemStack(Material.COD)); assertAllowed(listener); fire.active.clear(); + when(fire.furniture.getActiveFurnitureSlots()).thenReturn(Map.of("top",mock(net.tfminecraft.interactiblefurniture.furniture.PlacedFurnitureSlot.class))); assertAllowed(listener); + when(fire.furniture.getActiveFurnitureSlots()).thenReturn(Map.of()); placed.remove(pan.id); assertAllowed(listener); + when(fire.furniture.getOriginBlockLocation()).thenReturn(Optional.empty()); assertFalse(HeatSources.hasBlockingConsumerAbove(fire.furniture)); + HeatSourceDefinition source=new HeatSourceDefinition("fire",HeatSourceType.CAMPFIRE); assertEquals("fire",source.getFurnitureId()); + HeatSources.load(Map.of("fire",source),Map.of("pan",new HeatConsumerDefinition("pan","fire",null))); + when(fire.furniture.getOriginBlockLocation()).thenReturn(Optional.of(new Location(world,0,64,0))); + assertFalse(HeatSources.hasBlockingConsumerAbove(fire.furniture)); + } + @Test void furnitureIdentifiersAreIndependentOfServerLocale() { + Locale old=Locale.getDefault(); + try { Locale.setDefault(Locale.forLanguageTag("tr-TR")); + YamlConfiguration config=new YamlConfiguration(); config.set("heat.sources.FIRE.type","campfire"); + config.set("heat.consumers.SKILLET.source","FIRE"); config.set("heat.consumers.SKILLET.lookup","block-below"); + HeatLoader.load(config); assertTrue(HeatSources.isSource(fire.furniture)); + Station skillet=at("skillet",0,65,0); assertTrue(HeatSources.isConsumer(skillet.furniture)); + assertSame(fire.furniture,HeatSources.findSource(skillet.furniture).orElseThrow()); + } finally { Locale.setDefault(old); } + } + void assertAllowed(HeatPickupGuard listener) { FurnitureInteractEvent event=fire.interact(); listener.onInteract(event); assertFalse(event.isCancelled()); } +} diff --git a/src/test/java/net/tfminecraft/cooking/husbandry/HusbandryInspectionCoverageTest.java b/src/test/java/net/tfminecraft/cooking/husbandry/HusbandryInspectionCoverageTest.java new file mode 100644 index 0000000..2f789dc --- /dev/null +++ b/src/test/java/net/tfminecraft/cooking/husbandry/HusbandryInspectionCoverageTest.java @@ -0,0 +1,181 @@ +package net.tfminecraft.cooking.husbandry; + +import static org.junit.jupiter.api.Assertions.*; +import static org.mockito.Mockito.*; + +import java.nio.file.Path; +import java.util.*; +import org.bukkit.*; +import org.bukkit.entity.*; +import org.bukkit.event.inventory.*; +import org.bukkit.event.entity.*; +import org.bukkit.event.player.PlayerQuitEvent; +import org.mockito.MockedStatic; +import org.bukkit.event.player.PlayerInteractEntityEvent; +import org.bukkit.inventory.*; +import org.bukkit.persistence.PersistentDataType; +import org.junit.jupiter.api.*; +import org.junit.jupiter.api.io.TempDir; + +class HusbandryInspectionCoverageTest { + @TempDir Path directory; + HusbandryRuntimeCoverageTest.Farm f; + private final HusbandryInspectListener listener = new HusbandryInspectListener(); + @BeforeEach void setUp() throws Exception { f = new HusbandryRuntimeCoverageTest.Farm(directory); } + @AfterEach void tearDown() throws Exception { f.close(); } + + @Test void clickingABookInThePlayerInventoryDoesNotRemoveAnimalOwnership() { + Cow cow = f.spawn(Cow.class); HusbandryAnimal animal = f.record(cow); f.own(animal, f.player); + HusbandryInspectGui.open(f.player, cow, animal); f.player.getInventory().setItem(9, new ItemStack(Material.WRITABLE_BOOK)); + InventoryClickEvent click = click(54); click.setCurrentItem(new ItemStack(Material.WRITABLE_BOOK)); + assertSame(f.player.getInventory(), click.getClickedInventory()); assertEquals(Material.WRITABLE_BOOK, click.getCurrentItem().getType()); listener.onClick(click); + assertTrue(click.isCancelled()); assertTrue(HusbandryOwnershipService.isOwner(f.player, animal.uuid()), "Only the top inventory's remove button may revoke ownership"); + assertInstanceOf(HusbandryInspectHolder.class, f.player.getOpenInventory().getTopInventory().getHolder()); + } + + @Test void maximumLegalGeneticsProducesAFullBarWithoutIntegerOverflow() { + assertEquals(5, HusbandryGuiBars.filledSegments(Integer.MAX_VALUE, Integer.MAX_VALUE)); + } + + @Test void unnamedAnimalsHaveAReadableInspectionTitleAndName() { + Cow cow = f.spawn(Cow.class); HusbandryAnimal animal = f.record(cow); animal.setName(null); + HusbandryInspectGui.open(f.player, cow, animal); + assertEquals("§6Cow", f.player.getOpenInventory().getTitle()); assertEquals("§6Cow", name(f.player.getOpenInventory().getTopInventory(), 4)); + } + + @Test void inspectionShowsCareYieldOwnersProductsAndGrowth() { + Cow cow = f.spawn(Cow.class); HusbandryAnimal animal = f.record(cow); animal.setCare(40); animal.setGenetics(600); f.own(animal, f.player); + HusbandryInspectGui.open(f.player, cow, animal); Inventory inventory = f.player.getOpenInventory().getTopInventory(); + HusbandryInspectHolder holder = (HusbandryInspectHolder) inventory.getHolder(); assertEquals(animal.uuid(), holder.animalUuid()); assertSame(inventory, holder.getInventory()); + assertEquals(54, inventory.getSize()); assertEquals("§aHappy", name(inventory, 0)); assertEquals("§aNot neutered", name(inventory, 1)); + assertEquals(List.of("§7owner: Farmer"), lore(inventory, 2)); assertTrue(lore(inventory, 3).contains("§aYield 24%")); assertTrue(lore(inventory, 4).contains("§7Mature")); + assertEquals(Material.WRITABLE_BOOK, inventory.getItem(8).getType()); + for (int slot = 20; slot < 25; slot++) assertEquals(slot < 22 ? Material.GREEN_CONCRETE : Material.GRAY_CONCRETE, inventory.getItem(slot).getType()); + assertEquals(List.of("§740/100"), lore(inventory, 20)); assertEquals(Material.GREEN_CONCRETE, inventory.getItem(40).getType()); assertEquals(Material.GRAY_CONCRETE, inventory.getItem(41).getType()); + animal.setNeutered(true); animal.setMatureAt(System.currentTimeMillis() + 3_600_000); animal.setHungrySince(System.currentTimeMillis()); + HusbandryInspectGui.open(f.neighbour, cow, animal); inventory = f.neighbour.getOpenInventory().getTopInventory(); assertNull(inventory.getItem(8)); assertEquals("§cNeutered", name(inventory, 1)); assertTrue(lore(inventory, 0).contains("§6Hungry")); assertTrue(lore(inventory, 0).stream().anyMatch(l -> l.startsWith("§eDecay in "))); assertTrue(lore(inventory, 4).get(2).startsWith("§7Grows up in ")); + } + + @Test void inspectionDisplaysMountStatsAndAllAfflictionStatuses() throws Exception { + Horse horse = f.spawn(Horse.class); HusbandryAnimal animal = f.record(horse); HusbandryMounts.applyStats(horse, animal); + HusbandryInspectGui.open(f.player, horse, animal); Inventory inventory = f.player.getOpenInventory().getTopInventory(); + assertEquals("§dHealth: 12.5 hearts", name(inventory, 46)); assertEquals("§bSpeed: 8.43 b/s", name(inventory, 49)); assertTrue(name(inventory, 52).endsWith(" blocks")); assertNull(inventory.getItem(3)); assertEquals(List.of("§7None (untamed)"), lore(inventory, 2)); + animal.setDirtySince(1L); HusbandryInspectGui.open(f.player, horse, animal); inventory = f.player.getOpenInventory().getTopInventory(); assertTrue(lore(inventory, 0).contains("§eDirty")); assertTrue(lore(inventory, 0).get(1).startsWith("§cDecaying (−1 / ")); + f.config.set("care-down-per-hour", 0.5); f.reload(); animal.setHungrySince(2L); HusbandryInspectGui.open(f.player, horse, animal); inventory = f.player.getOpenInventory().getTopInventory(); assertTrue(lore(inventory, 0).contains("§6Hungry")); assertTrue(lore(inventory, 0).get(2).startsWith("§cDecaying (−0.5 / ")); + animal.setDirtySince(null); HusbandryInspectGui.open(f.player, horse, animal); assertTrue(lore(f.player.getOpenInventory().getTopInventory(), 0).get(1).startsWith("§cDecaying")); + Pig pig = f.spawn(Pig.class); HusbandryInspectGui.open(f.player, pig, f.record(pig)); assertNull(f.player.getOpenInventory().getTopInventory().getItem(3)); + HusbandryInspectGui.open(null, horse, animal); HusbandryInspectGui.open(f.player, null, animal); HusbandryInspectGui.open(f.player, horse, null); + } + + @Test void ownerNamesFallBackToUuidWhenOfflineProfileHasNoName() { + Cow cow = f.spawn(Cow.class); HusbandryAnimal animal = f.record(cow); UUID owner = UUID.randomUUID(); f.repository.upsertOwner(new HusbandryOwner(animal.uuid(), owner, "coowner")); + OfflinePlayer unnamed = mock(OfflinePlayer.class); + try (MockedStatic bukkit = mockStatic(Bukkit.class, CALLS_REAL_METHODS)) { bukkit.when(() -> Bukkit.getOfflinePlayer(owner)).thenReturn(unnamed); HusbandryInspectGui.open(f.player, cow, animal); } + assertEquals(List.of("§7coowner: " + owner), lore(f.player.getOpenInventory().getTopInventory(), 2)); + assertEquals(0, HusbandryGuiBars.yieldPercent(null)); assertEquals(0, HusbandryGuiBars.filledSegments(10, 0)); assertEquals(0, HusbandryGuiBars.filledSegments(0, 100)); assertEquals(5, HusbandryGuiBars.filledSegments(200, 100)); + } + + @Test void rightClickInspectHonoursHandRepositoryOwnershipStaffAndUnownedAccess() { + Cow cow = f.spawn(Cow.class); HusbandryAnimal animal = f.record(cow); f.own(animal, f.player); + f.player.getInventory().setItemInMainHand(new ItemStack(Material.CLOCK)); + var offhand = new PlayerInteractEntityEvent(f.player, cow, EquipmentSlot.OFF_HAND); listener.onInteract(offhand); assertFalse(offhand.isCancelled()); + var display = interact(f.spawn(ItemDisplay.class)); listener.onInteract(display); assertFalse(display.isCancelled()); + when(f.plugin.getHusbandryRepository()).thenReturn(null); var unavailable = interact(cow); listener.onInteract(unavailable); assertFalse(unavailable.isCancelled()); when(f.plugin.getHusbandryRepository()).thenReturn(f.repository); + var unknown = interact(f.spawn(Cow.class)); listener.onInteract(unknown); assertFalse(unknown.isCancelled()); + f.player.getInventory().setItemInMainHand(new ItemStack(Material.STONE)); var unrelated = interact(cow); listener.onInteract(unrelated); assertFalse(unrelated.isCancelled()); + f.player.getInventory().setItemInMainHand(new ItemStack(Material.CLOCK)); var own = interact(cow); listener.onInteract(own); assertTrue(own.isCancelled()); assertInstanceOf(HusbandryInspectHolder.class, f.player.getOpenInventory().getTopInventory().getHolder()); + f.neighbour.getInventory().setItemInMainHand(new ItemStack(Material.CLOCK)); var denied = new PlayerInteractEntityEvent(f.neighbour, cow); listener.onInteract(denied); assertTrue(denied.isCancelled()); assertTrue(f.neighbour.nextMessage().contains("not your animal")); + f.neighbour.setOp(true); listener.onInteract(new PlayerInteractEntityEvent(f.neighbour, cow)); assertInstanceOf(HusbandryInspectHolder.class, f.neighbour.getOpenInventory().getTopInventory().getHolder()); + f.neighbour.setOp(false); HusbandryOwnershipService.removeSelf(f.player, animal); f.player.getInventory().setItemInMainHand(null); f.player.setSneaking(true); listener.onInteract(interact(cow)); assertInstanceOf(HusbandryInspectHolder.class, f.player.getOpenInventory().getTopInventory().getHolder()); + f.player.setSneaking(false); var empty = interact(cow); listener.onInteract(empty); assertFalse(empty.isCancelled()); + } + + @Test void inspectionItemsNeverOverrideFeedingTamingSharingMilkingOrNeutering() throws Exception { + Cow cow = f.spawn(Cow.class); f.record(cow); + Map paths = new LinkedHashMap<>(); paths.put("feed", Material.WHEAT); paths.put("glove", Material.LEATHER); paths.put("tame", Material.NAME_TAG); paths.put("share", Material.PAPER); paths.put("neuter", Material.SHEARS); paths.put("bucket", Material.BUCKET); + for (var path : paths.entrySet()) { f.config.set("items.inspect", path.getKey()); f.reload(); f.player.getInventory().setItemInMainHand(new ItemStack(path.getValue())); var event = interact(cow); listener.onInteract(event); assertFalse(event.isCancelled(), path.getKey()); } + } + + @Test void removeButtonReleasesOnlyThisOwnerAndHandlesConcurrentDeletionOrLostOwnership() { + Cow cow = f.spawn(Cow.class); HusbandryAnimal animal = f.record(cow); f.own(animal, f.player); HusbandryInspectGui.open(f.player, cow, animal); + InventoryClickEvent unrelated = click(4); listener.onClick(unrelated); assertTrue(unrelated.isCancelled()); assertTrue(HusbandryOwnershipService.isOwner(f.player, animal.uuid())); listener.onClick(click(5)); + when(f.plugin.getHusbandryRepository()).thenReturn(null); listener.onClick(click(8)); when(f.plugin.getHusbandryRepository()).thenReturn(f.repository); assertTrue(HusbandryOwnershipService.isOwner(f.player, animal.uuid())); + listener.onClick(click(8)); assertFalse(HusbandryOwnershipService.isOwner(f.player, animal.uuid())); assertEquals(HusbandryAnimalState.UNTAMED, f.repository.getAnimal(animal.uuid()).orElseThrow().state()); assertTrue(f.player.nextMessage().contains("no longer own")); + f.own(animal, f.player); HusbandryInspectGui.open(f.player, cow, animal); f.repository.deleteOwner(animal.uuid(), f.player.getUniqueId()); listener.onClick(click(8)); assertTrue(f.player.nextMessage().contains("Could not remove")); + f.own(animal, f.player); HusbandryInspectGui.open(f.player, cow, animal); HusbandryEntities.evict(animal.uuid()); f.repository.deleteAnimal(animal.uuid()); listener.onClick(click(8)); assertNull(f.player.getOpenInventory().getTopInventory()); + f.player.openInventory(Bukkit.createInventory(null, 9)); + var ordinary = click(0); listener.onClick(ordinary); assertFalse(ordinary.isCancelled()); + } + + @Test void ownershipClaimsRenameEnforceCapsAndPermitStaff() { + Cow first = f.spawn(Cow.class), second = f.spawn(Cow.class), third = f.spawn(Cow.class); HusbandryAnimal a = f.record(first), b = f.record(second), c = f.record(third); + assertEquals(HusbandryOwnershipService.Result.OK, HusbandryOwnershipService.claimOwner(f.player, a, first, " Bessie ")); assertEquals("Bessie", a.name()); assertEquals("Bessie", first.getCustomName()); + assertEquals(HusbandryOwnershipService.Result.OK, HusbandryOwnershipService.claimOwner(f.player, a, first, " Renamed ")); assertEquals("Renamed", f.repository.getAnimal(a.uuid()).orElseThrow().name()); + assertEquals(HusbandryOwnershipService.Result.NOT_OWNER, HusbandryOwnershipService.claimOwner(f.neighbour, a, first, "Steal")); + assertEquals(HusbandryOwnershipService.Result.OK, HusbandryOwnershipService.claimOwner(f.player, b, second, "???")); assertEquals("Cow", b.name()); + assertFalse(HusbandryOwnershipService.canAccept(f.player)); assertEquals(HusbandryOwnershipService.Result.AT_CAP, HusbandryOwnershipService.claimOwner(f.player, c, third, "Third")); assertFalse(HusbandryOwnershipService.hasAnyOwner(c.uuid())); + f.player.setOp(true); assertTrue(HusbandryOwnershipService.canAccept(f.player)); assertEquals(HusbandryOwnershipService.Result.OK, HusbandryOwnershipService.claimOwner(f.player, c, third, "Third")); assertEquals(3, f.repository.countForPlayer(f.player.getUniqueId())); + assertFalse(HusbandryOwnershipService.isOwner((Player) null, a.uuid())); assertFalse(HusbandryOwnershipService.isOwner((UUID) null, a.uuid())); assertFalse(HusbandryOwnershipService.isOwner(f.player.getUniqueId(), null)); assertFalse(HusbandryOwnershipService.hasAnyOwner(null)); assertFalse(HusbandryOwnershipService.isStaff(null)); + } + + @Test void coOwnershipKeepsStateUntilLastOwnerLeavesAndChecksActorAndTargetCaps() { + HusbandryAnimal animal = f.record(f.spawn(Cow.class)); + assertEquals(HusbandryOwnershipService.Result.NOT_OWNER, HusbandryOwnershipService.addCoOwner(f.player, f.neighbour, animal)); f.own(animal, f.player); + var stranger = f.server.addPlayer("Stranger"); assertEquals(HusbandryOwnershipService.Result.NOT_OWNER, HusbandryOwnershipService.addCoOwner(stranger, f.neighbour, animal)); + f.own(f.offline("COW", "One"), f.neighbour); f.own(f.offline("COW", "Two"), f.neighbour); + assertEquals(HusbandryOwnershipService.Result.TARGET_AT_CAP, HusbandryOwnershipService.addCoOwner(f.player, f.neighbour, animal)); + f.neighbour.setOp(true); assertEquals(HusbandryOwnershipService.Result.OK, HusbandryOwnershipService.addCoOwner(f.player, f.neighbour, animal)); assertEquals(HusbandryOwnershipService.Result.ALREADY_TARGET_OWNER, HusbandryOwnershipService.addCoOwner(f.player, f.neighbour, animal)); + assertEquals(HusbandryOwnershipService.Result.OK, HusbandryOwnershipService.removeSelf(f.player, animal)); assertEquals(HusbandryAnimalState.OWNED, f.repository.getAnimal(animal.uuid()).orElseThrow().state()); + assertEquals(HusbandryOwnershipService.Result.NOT_OWNER, HusbandryOwnershipService.removeSelf(f.player, animal)); + assertEquals(HusbandryOwnershipService.Result.OK, HusbandryOwnershipService.removeSelf(f.neighbour, animal)); assertEquals(HusbandryAnimalState.UNTAMED, f.repository.getAnimal(animal.uuid()).orElseThrow().state()); assertTrue(HusbandryOwnershipService.listOwners(animal.uuid()).isEmpty()); + f.own(animal, f.player); assertEquals(HusbandryOwnershipService.Result.OK, HusbandryOwnershipService.addCoOwner(stranger, stranger, animal)); assertEquals("coowner", f.repository.listOwners(animal.uuid()).stream().filter(o -> o.playerUuid().equals(stranger.getUniqueId())).findFirst().orElseThrow().role()); + } + + @Test void ownershipUnavailableArgumentsAndNamingFallbacksAreSafe() { + Cow cow = f.spawn(Cow.class); HusbandryAnimal animal = f.record(cow); + assertFalse(HusbandryOwnershipService.canAccept(null)); + assertEquals(HusbandryOwnershipService.Result.NO_REPOSITORY, HusbandryOwnershipService.claimOwner(null, animal, cow, "Name")); assertEquals(HusbandryOwnershipService.Result.NO_REPOSITORY, HusbandryOwnershipService.claimOwner(f.player, null, cow, "Name")); + assertEquals(HusbandryOwnershipService.Result.NO_REPOSITORY, HusbandryOwnershipService.addCoOwner(null, f.neighbour, animal)); assertEquals(HusbandryOwnershipService.Result.NO_REPOSITORY, HusbandryOwnershipService.addCoOwner(f.player, null, animal)); assertEquals(HusbandryOwnershipService.Result.NO_REPOSITORY, HusbandryOwnershipService.addCoOwner(f.player, f.neighbour, null)); + assertEquals(HusbandryOwnershipService.Result.NO_REPOSITORY, HusbandryOwnershipService.removeSelf(null, animal)); assertEquals(HusbandryOwnershipService.Result.NO_REPOSITORY, HusbandryOwnershipService.removeSelf(f.player, null)); + when(f.plugin.getHusbandryRepository()).thenReturn(null); assertFalse(HusbandryOwnershipService.canAccept(f.player)); assertFalse(HusbandryOwnershipService.isOwner(f.player, animal.uuid())); assertFalse(HusbandryOwnershipService.hasAnyOwner(animal.uuid())); assertTrue(HusbandryOwnershipService.listOwners(animal.uuid()).isEmpty()); + assertEquals(HusbandryOwnershipService.Result.NO_REPOSITORY, HusbandryOwnershipService.claimOwner(f.player, animal, cow, "Name")); assertEquals(HusbandryOwnershipService.Result.NO_REPOSITORY, HusbandryOwnershipService.addCoOwner(f.player, f.neighbour, animal)); assertEquals(HusbandryOwnershipService.Result.NO_REPOSITORY, HusbandryOwnershipService.removeSelf(f.player, animal)); when(f.plugin.getHusbandryRepository()).thenReturn(f.repository); + animal.setName(""); HusbandryOwnershipService.applyName(animal, cow, "???"); assertEquals("Cow", animal.name()); animal.setName(""); HusbandryOwnershipService.applyName(animal, null, null); assertEquals("Animal", animal.name()); + HusbandryOwnershipService.applyName(animal, null, " "); assertEquals("Animal", animal.name()); cow.remove(); HusbandryOwnershipService.applyName(animal, cow, "Removed"); assertEquals("Removed", animal.name()); + } + + @Test void stateDisplayMigratesOnlyLegacyLabelsAndShowsEveryAffliction() { + Cow cow = f.spawn(Cow.class); HusbandryAnimal animal = f.record(cow); animal.setName(null); + TextDisplay legacy = f.spawn(TextDisplay.class), unrelated = f.spawn(TextDisplay.class); Pig passenger = f.spawn(Pig.class); + legacy.getPersistentDataContainer().set(new NamespacedKey(f.plugin, "state_label"), PersistentDataType.STRING, "old"); cow.addPassenger(legacy); cow.addPassenger(unrelated); cow.addPassenger(passenger); + HusbandryStateDisplay.sync(cow, animal); assertFalse(legacy.isValid()); assertTrue(unrelated.isValid()); assertTrue(passenger.isValid()); assertEquals("Cow", cow.getCustomName()); assertFalse(cow.isCustomNameVisible()); + animal.setName("Bess"); animal.setHungrySince(1L); HusbandryStateDisplay.sync(cow, animal); assertEquals("Bess (Hungry)", cow.getCustomName()); assertTrue(cow.isCustomNameVisible()); + animal.setDirtySince(1L); HusbandryStateDisplay.sync(cow, animal); assertEquals("Bess (Hungry, Dirty)", cow.getCustomName()); animal.setHungrySince(null); HusbandryStateDisplay.sync(cow, animal); assertEquals("Bess (Dirty)", cow.getCustomName()); + HusbandryStateDisplay.removeAll(cow); assertTrue(unrelated.isValid()); HusbandryStateDisplay.sync(null, animal); HusbandryStateDisplay.sync(cow, null); HusbandryStateDisplay.removeAll(null); cow.remove(); HusbandryStateDisplay.sync(cow, animal); HusbandryStateDisplay.removeAll(cow); + } + + @Test void mountAccessAllowsOwnersAndStaffAndRemindsUnclaimedRiders() throws Exception { + HusbandryMountListener mounts = new HusbandryMountListener(); Horse horse = f.spawn(Horse.class); Cow cow = f.spawn(Cow.class); + var notMount = new EntityMountEvent(f.player, cow); mounts.onMount(notMount); assertFalse(notMount.isCancelled()); var notPlayer = new EntityMountEvent(cow, horse); mounts.onMount(notPlayer); assertFalse(notPlayer.isCancelled()); + f.config.set("remove-unowned", List.of("HORSE")); f.reload(); var unclaimed = new EntityMountEvent(f.player, horse); mounts.onMount(unclaimed); assertFalse(unclaimed.isCancelled()); assertTrue(f.player.nextMessage().contains("not claimed")); + HusbandryAnimal animal = HusbandryEntities.lookup(horse.getUniqueId()).orElseThrow(); f.own(animal, f.player); + var own = new EntityMountEvent(f.player, horse); mounts.onMount(own); assertFalse(own.isCancelled()); var other = new EntityMountEvent(f.neighbour, horse); mounts.onMount(other); assertTrue(other.isCancelled()); assertTrue(f.neighbour.nextMessage().contains("not your animal")); + f.neighbour.setOp(true); var staff = new EntityMountEvent(f.neighbour, horse); mounts.onMount(staff); assertFalse(staff.isCancelled()); + mounts.onTame(new EntityTameEvent(horse, f.player)); mounts.onTame(new EntityTameEvent(horse, Bukkit.getOfflinePlayer(UUID.randomUUID()))); mounts.onQuit(new PlayerQuitEvent(f.player, "bye")); + when(f.plugin.getHusbandryRepository()).thenReturn(null); var unavailable = new EntityMountEvent(f.player, f.spawn(Horse.class)); mounts.onMount(unavailable); assertFalse(unavailable.isCancelled()); + } + + @Test void removalIgnoresOtherHumanViewersAndAReplacedButton() { + Cow cow = f.spawn(Cow.class); HusbandryAnimal animal = f.record(cow); f.own(animal, f.player); HusbandryInspectGui.open(f.player, cow, animal); + Inventory top = f.player.getOpenInventory().getTopInventory(); InventoryView view = mock(InventoryView.class); HumanEntity viewer = mock(HumanEntity.class); + when(view.getTopInventory()).thenReturn(top); when(view.getInventory(8)).thenReturn(top); when(view.getPlayer()).thenReturn(viewer); + InventoryClickEvent nonPlayer = new InventoryClickEvent(view, InventoryType.SlotType.CONTAINER, 8, ClickType.LEFT, InventoryAction.PICKUP_ALL); listener.onClick(nonPlayer); assertTrue(nonPlayer.isCancelled()); assertTrue(HusbandryOwnershipService.isOwner(f.player, animal.uuid())); + top.setItem(8, new ItemStack(Material.STONE)); listener.onClick(click(8)); top.setItem(8, null); listener.onClick(click(8)); assertTrue(HusbandryOwnershipService.isOwner(f.player, animal.uuid())); + InventoryClickEvent outside = new InventoryClickEvent(f.player.getOpenInventory(), InventoryType.SlotType.OUTSIDE, -999, ClickType.LEFT, InventoryAction.NOTHING); listener.onClick(outside); assertTrue(outside.isCancelled()); + } + + private PlayerInteractEntityEvent interact(Entity entity) { return new PlayerInteractEntityEvent(f.player, entity, EquipmentSlot.HAND); } + private static List lore(Inventory inventory, int slot) { return inventory.getItem(slot).getItemMeta().getLore(); } + + private InventoryClickEvent click(int rawSlot) { return new InventoryClickEvent(f.player.getOpenInventory(), InventoryType.SlotType.CONTAINER, rawSlot, ClickType.LEFT, InventoryAction.PICKUP_ALL); } + private static String name(Inventory inventory, int slot) { return inventory.getItem(slot).getItemMeta().getDisplayName(); } +} diff --git a/src/test/java/net/tfminecraft/cooking/husbandry/HusbandryListenersCoverageTest.java b/src/test/java/net/tfminecraft/cooking/husbandry/HusbandryListenersCoverageTest.java new file mode 100644 index 0000000..837e0ec --- /dev/null +++ b/src/test/java/net/tfminecraft/cooking/husbandry/HusbandryListenersCoverageTest.java @@ -0,0 +1,345 @@ +package net.tfminecraft.cooking.husbandry; + +import static org.junit.jupiter.api.Assertions.*; +import static org.mockito.ArgumentMatchers.*; +import static org.mockito.Mockito.*; + +import java.nio.file.Path; +import java.util.*; +import net.tfminecraft.cooking.Cooking; +import net.tfminecraft.cooking.utils.Keys; +import net.tfminecraft.tlibs.TLibs; +import net.tfminecraft.tlibs.objects.api.ItemAPI; +import org.bukkit.*; +import org.bukkit.attribute.Attribute; +import org.bukkit.block.BlockFace; +import org.bukkit.configuration.file.YamlConfiguration; +import org.bukkit.entity.*; +import org.bukkit.event.block.Action; +import org.bukkit.event.entity.*; +import org.bukkit.event.inventory.PrepareAnvilEvent; +import org.bukkit.event.player.*; +import org.bukkit.inventory.EquipmentSlot; +import org.bukkit.inventory.ItemStack; +import org.bukkit.inventory.view.AnvilView; +import org.bukkit.persistence.PersistentDataType; +import org.junit.jupiter.api.*; +import org.junit.jupiter.api.io.TempDir; +import org.mockbukkit.mockbukkit.MockBukkit; +import org.mockbukkit.mockbukkit.ServerMock; +import org.mockbukkit.mockbukkit.world.WorldMock; +import org.mockbukkit.mockbukkit.entity.PlayerMock; +import org.mockbukkit.mockbukkit.inventory.AnvilInventoryMock; +import org.mockito.MockedStatic; + +class HusbandryListenersCoverageTest { + @TempDir Path directory; + private ServerMock server; + private WorldMock world; + private PlayerMock player, target; + private Cooking oldPlugin; + private HusbandryRepository repository; + private HusbandryLoaderCoverageTest.ConfigSnapshot configSnapshot; + private final List> mocks = new ArrayList<>(); + private MockedStatic harvestApi; + private YamlConfiguration config; + private final HusbandryBreedListener breeding = new HusbandryBreedListener(); + private final HusbandryHarvestListener harvest = new HusbandryHarvestListener(); + private final HusbandryTamingListener taming = new HusbandryTamingListener(); + + @BeforeEach void setUp() throws Exception { + configSnapshot = new HusbandryLoaderCoverageTest.ConfigSnapshot(); oldPlugin = Cooking.plugin; + server = MockBukkit.mock(); world = server.addSimpleWorld("farm"); player = server.addPlayer("Farmer"); target = server.addPlayer("Neighbour"); + player.teleport(new Location(world, 0, 65, 0)); target.teleport(new Location(world, 2, 65, 0)); + Cooking.plugin = mock(Cooking.class); when(Cooking.plugin.getName()).thenReturn("Cooking"); when(Cooking.plugin.namespace()).thenReturn("cooking"); when(Cooking.plugin.isEnabled()).thenReturn(true); + repository = HusbandryRepository.open(directory.resolve("husbandry.db").toFile()); when(Cooking.plugin.getHusbandryRepository()).thenReturn(repository); + HusbandryEntities.clearLoaded(); HusbandryFeedQuality.clear(); + config = new YamlConfiguration(); config.loadFromString(""" + max-animals: 2 + care-max: 100 + initial-genetic-max: 0 + grow-up: 1h + milk-timer: 10s + wool-timer: 20s + items: {tame: tame, co-own: share, feed: feed, neuter: neuter} + species: + COW: {milk: true} + GOAT: {milk: true, shear: {drops: {common: [{path: wool}]}}} + SHEEP: {shear: {drops: {common: [{path: wool}]}}} + CHICKEN: {egg: vanilla} + HORSE: {} + MULE: {} + WOLF: {} + mounts: + HORSE: {min-health: 25, max-health: 25, min-speed: 0.2, max-speed: 0.2, min-jump: 0.6, max-jump: 0.6} + MULE: {min-health: 25, max-health: 25, min-speed: 0.2, max-speed: 0.2, min-jump: 0.6, max-jump: 0.6} + """); reloadConfig(); + ItemAPI itemApi = mock(ItemAPI.class, RETURNS_DEEP_STUBS); + scoped(TLibs.class).when(TLibs::getItemAPI).thenReturn(itemApi); + when(itemApi.getChecker().checkItemWithPath(any(), anyString())).thenAnswer(call -> { + ItemStack item = call.getArgument(0); String path = call.getArgument(1); + if (item == null) return false; + return switch (path) { + case "tame" -> item.getType() == Material.NAME_TAG; + case "share" -> item.getType() == Material.PAPER; + case "feed" -> item.getType() == Material.DRIED_KELP; + case "neuter" -> item.getType() == Material.SHEARS && item.getItemMeta().isUnbreakable(); + default -> false; + }; + }); + harvestApi = mockStatic(HusbandryHarvest.class, CALLS_REAL_METHODS); mocks.add(harvestApi); + harvestApi.when(() -> HusbandryHarvest.buildMilk(any(), any())).thenAnswer(call -> new ItemStack(Material.MILK_BUCKET)); + scoped(HusbandryDropRoller.class).when(() -> HusbandryDropRoller.rollShearDrops(any(), any(), anyLong())).thenAnswer(call -> List.of(new ItemStack(Material.WHITE_WOOL, 2))); + } + + @AfterEach void tearDown() throws Exception { + HusbandryEntities.clearLoaded(); HusbandryFeedQuality.clear(); repository.close(); + MockBukkit.unmock(); for (int i = mocks.size() - 1; i >= 0; i--) mocks.get(i).close(); + Cooking.plugin = oldPlugin; configSnapshot.close(); + } + + @Test void breedingFeedTracksTheUsedHandAndCommitsOnlyWhenLoveModeIsAllowed() { + Cow cow = spawn(Cow.class); record(cow); + ItemStack qualityFeed = new ItemStack(Material.WHEAT); var meta = qualityFeed.getItemMeta(); meta.getPersistentDataContainer().set(Keys.QUALITY, PersistentDataType.INTEGER, 2); qualityFeed.setItemMeta(meta); + player.getInventory().setItemInOffHand(qualityFeed); + breeding.onFeed(interact(cow, EquipmentSlot.OFF_HAND)); + var love = new EntityEnterLoveModeEvent(cow, player, 600); breeding.onEnterLove(love); + assertFalse(love.isCancelled()); assertEquals(0.4, HusbandryFeedQuality.takePair(cow.getUniqueId(), null, System.currentTimeMillis())); + hold(new ItemStack(Material.DRIED_KELP)); breeding.onFeed(interact(cow)); breeding.onEnterLove(new EntityEnterLoveModeEvent(cow, player, 600)); + assertEquals(1, HusbandryFeedQuality.takePair(cow.getUniqueId(), null, System.currentTimeMillis())); + breeding.onFeed(interact(spawn(ItemDisplay.class))); breeding.onFeed(interact(spawn(Pig.class))); + } + + @Test void unavailableUnrecordedNeuteredUnwellAndYoungAnimalsCannotEnterLoveMode() { + Cow cow = spawn(Cow.class); + when(Cooking.plugin.getHusbandryRepository()).thenReturn(null); assertBlockedLove(cow, "Could not breed"); + when(Cooking.plugin.getHusbandryRepository()).thenReturn(repository); assertBlockedLove(cow, "cannot breed"); + HusbandryAnimal animal = record(cow); animal.setNeutered(true); assertBlockedLove(cow, "Neutered"); + animal.setNeutered(false); animal.setHungrySince(1L); assertBlockedLove(cow, "Hungry"); + animal.setHungrySince(null); animal.setDirtySince(1L); assertBlockedLove(cow, "dirty"); + animal.setDirtySince(null); animal.setMatureAt(System.currentTimeMillis() + 60_000); assertBlockedLove(cow, "growing"); + var unattended = new EntityEnterLoveModeEvent(cow, null, 600); breeding.onEnterLove(unattended); assertTrue(unattended.isCancelled()); + assertEquals(1, HusbandryFeedQuality.takePair(cow.getUniqueId(), null, System.currentTimeMillis())); + } + + @Test void breedingReportsFirstBlockedParentAndClearsBothLoveModes() { + Cow mother = spawn(Cow.class), father = spawn(Cow.class), calf = spawn(Cow.class); HusbandryAnimal mom = record(mother), dad = record(father); + mom.setNeutered(true); dad.setHungrySince(1L); mother.setLoveModeTicks(400); father.setLoveModeTicks(400); + var first = breed(calf, mother, father, player); breeding.onBreed(first); + assertTrue(first.isCancelled()); assertTrue(player.nextMessage().contains("Neutered")); assertEquals(0, mother.getLoveModeTicks()); assertEquals(0, father.getLoveModeTicks()); + mom.setNeutered(false); var second = breed(calf, mother, father, player); breeding.onBreed(second); assertTrue(second.isCancelled()); assertTrue(player.nextMessage().contains("Hungry")); + var unattended = breed(calf, mother, father, null); breeding.onBreed(unattended); assertTrue(unattended.isCancelled()); assertFalse(repository.exists(calf.getUniqueId())); + } + + @Test void unmanagedBreedingIsUntouchedAndOwnedParentsRequireBothOwnersOrStaff() { + Pig pig = spawn(Pig.class), mate = spawn(Pig.class), piglet = spawn(Pig.class); var wild = breed(piglet, pig, mate, null); breeding.onBreed(wild); assertFalse(wild.isCancelled()); assertFalse(repository.exists(piglet.getUniqueId())); + Cow mother = spawn(Cow.class), father = spawn(Cow.class); HusbandryAnimal mom = record(mother), dad = record(father); own(mom, player); + var noPlayer = breed(spawn(Cow.class), mother, father, null); breeding.onBreed(noPlayer); assertTrue(noPlayer.isCancelled()); + var stranger = breed(spawn(Cow.class), mother, father, target); breeding.onBreed(stranger); assertTrue(stranger.isCancelled()); assertTrue(target.nextMessage().contains("own both")); + var oneParent = breed(spawn(Cow.class), mother, father, player); breeding.onBreed(oneParent); assertTrue(oneParent.isCancelled()); assertTrue(player.nextMessage().contains("own both")); + own(dad, player); Cow calf = spawn(Cow.class); var both = breed(calf, mother, father, player); breeding.onBreed(both); assertFalse(both.isCancelled()); assertTrue(repository.exists(calf.getUniqueId())); + target.setOp(true); Cow staffCalf = spawn(Cow.class); var staff = breed(staffCalf, mother, father, target); breeding.onBreed(staff); assertFalse(staff.isCancelled()); assertTrue(repository.exists(staffCalf.getUniqueId())); + } + + @Test void babiesPersistGeneticsMaturityAndManagedFlagsWithoutOverwritingExistingRecords() { + Cow mother = spawn(Cow.class), father = spawn(Cow.class), calf = spawn(Cow.class); HusbandryAnimal mom = record(mother), dad = record(father); mom.setGenetics(20); dad.setGenetics(40); + long before = System.currentTimeMillis(); breeding.onBreed(breed(calf, mother, father, null)); + HusbandryAnimal baby = repository.getAnimal(calf.getUniqueId()).orElseThrow(); + assertEquals(30, baby.genetics()); assertEquals(0, baby.care()); assertEquals(HusbandryAnimalState.UNTAMED, baby.state()); assertEquals("Cow", baby.name()); assertEquals(HusbandryConfig.statsRevision(), baby.statsRevision()); + assertTrue(baby.matureAt() >= before + 3_600_000); assertTrue(baby.lastProcessedAt() >= before); assertNotNull(baby.loadedVisitStart()); assertNull(baby.unloadedAt()); + assertTrue(HusbandryEntities.isManaged(calf)); assertTrue(calf.isPersistent()); assertFalse(calf.getRemoveWhenFarAway()); assertFalse(calf.isAdult()); assertTrue(calf.getAgeLock()); + HusbandryAnimal canonical = HusbandryEntities.getLoaded(calf.getUniqueId()).orElseThrow(); canonical.setName("Retained"); repository.upsertAnimal(canonical); + breeding.onBreed(breed(calf, mother, father, player)); assertEquals("Retained", repository.getAnimal(calf.getUniqueId()).orElseThrow().name()); + } + + @Test void mountBreedingSchedulesStatsAndIgnoresRemovedOrMissingChildren() throws Exception { + Horse mother = spawn(Horse.class), father = spawn(Horse.class), foal = spawn(Horse.class); record(mother); record(father); + breeding.onBreed(breed(foal, mother, father, player)); server.getScheduler().performOneTick(); assertEquals(25, foal.getAttribute(Attribute.MAX_HEALTH).getBaseValue()); + Horse removed = spawn(Horse.class); breeding.onBreed(breed(removed, mother, father, player)); removed.remove(); server.getScheduler().performOneTick(); assertFalse(removed.isValid()); + Horse lost = spawn(Horse.class); breeding.onBreed(breed(lost, mother, father, player)); HusbandryEntities.evict(lost.getUniqueId()); repository.deleteAnimal(lost.getUniqueId()); server.getScheduler().performOneTick(); assertFalse(repository.exists(lost.getUniqueId())); + config.set("species.HORSE", null); reloadConfig(); + Donkey donkey = spawn(Donkey.class); Mule mule = spawn(Mule.class); breeding.onBreed(breed(mule, mother, donkey, null)); assertTrue(repository.exists(mule.getUniqueId())); + // Vanilla horse/donkey breeding can produce a managed mule from two unmanaged parents. + Horse wildHorse = spawn(Horse.class); Mule wildMule = spawn(Mule.class); breeding.onBreed(breed(wildMule, wildHorse, donkey, null)); assertEquals(0, repository.getAnimal(wildMule.getUniqueId()).orElseThrow().genetics()); + when(Cooking.plugin.getHusbandryRepository()).thenReturn(null); Mule unavailable = spawn(Mule.class); breeding.onBreed(breed(unavailable, wildHorse, donkey, null)); server.getScheduler().performOneTick(); assertFalse(repository.exists(unavailable.getUniqueId())); + } + + @Test void milkingIgnoresUnrelatedHandsItemsEntitiesAndUnconfiguredSpecies() { + Cow cow = spawn(Cow.class); hold(new ItemStack(Material.BUCKET)); + var boots = interact(cow, EquipmentSlot.FEET); harvest.onMilk(boots); assertFalse(boots.isCancelled()); + hold(null); var empty = interact(cow); harvest.onMilk(empty); assertFalse(empty.isCancelled()); + hold(new ItemStack(Material.STONE)); harvest.onMilk(interact(cow)); + hold(new ItemStack(Material.BUCKET)); var display = interact(spawn(ItemDisplay.class)); harvest.onMilk(display); assertFalse(display.isCancelled()); + var pig = interact(spawn(Pig.class)); harvest.onMilk(pig); assertFalse(pig.isCancelled()); + var sheep = interact(spawn(Sheep.class)); harvest.onMilk(sheep); assertFalse(sheep.isCancelled()); + } + + @Test void immatureUnrecordedAndCoolingDownAnimalsKeepTheirBuckets() { + Cow cow = spawn(Cow.class); hold(new ItemStack(Material.BUCKET)); + when(Cooking.plugin.getHusbandryRepository()).thenReturn(null); var noRepo = interact(cow); harvest.onMilk(noRepo); assertTrue(noRepo.isCancelled()); + when(Cooking.plugin.getHusbandryRepository()).thenReturn(repository); harvest.onMilk(interact(cow)); assertEquals(Material.BUCKET, player.getInventory().getItemInMainHand().getType()); + HusbandryAnimal animal = record(cow); animal.setMatureAt(System.currentTimeMillis() + 60_000); harvest.onMilk(interact(cow)); assertTrue(player.nextMessage().contains("growing")); + animal.setMatureAt(null); animal.setLastMilkAt(System.currentTimeMillis() - 2_000); harvest.onMilk(interact(cow)); assertTrue(player.nextMessage().contains("not ready")); + animal.setLastMilkAt(System.currentTimeMillis()); harvest.onMilk(interact(cow)); assertNull(player.nextMessage()); + animal.setLastMilkAt(null); harvestApi.when(() -> HusbandryHarvest.buildMilk(any(), any())).thenReturn(null); harvest.onMilk(interact(cow)); assertEquals(Material.BUCKET, player.getInventory().getItemInMainHand().getType()); assertNull(animal.lastMilkAt()); + } + + @Test void milkingConsumesExactlyOneBucketPersistsCooldownAndSupportsTheOffHand() { + Cow cow = spawn(Cow.class); HusbandryAnimal animal = record(cow); hold(new ItemStack(Material.BUCKET)); + long before = System.currentTimeMillis(); var event = interact(cow); harvest.onMilk(event); + assertTrue(event.isCancelled()); assertEquals(Material.MILK_BUCKET, player.getInventory().getItemInMainHand().getType()); assertTrue(repository.getAnimal(cow.getUniqueId()).orElseThrow().lastMilkAt() >= before); + animal.setLastMilkAt(System.currentTimeMillis() - 20_000); hold(new ItemStack(Material.STONE)); player.getInventory().setItemInOffHand(new ItemStack(Material.BUCKET)); harvest.onMilk(interact(cow, EquipmentSlot.OFF_HAND)); assertEquals(Material.MILK_BUCKET, player.getInventory().getItemInOffHand().getType()); + animal.setLastMilkAt(null); hold(new ItemStack(Material.BUCKET)); player.getInventory().setItemInOffHand(new ItemStack(Material.BUCKET)); harvest.onMilk(interact(cow, EquipmentSlot.OFF_HAND)); assertEquals(Material.BUCKET, player.getInventory().getItemInOffHand().getType()); assertNull(animal.lastMilkAt()); + } + + @Test void stackedAndCreativeBucketsReceiveMilkInInventoryAndOverflowDropsAtThePlayer() { + Goat goat = spawn(Goat.class); HusbandryAnimal animal = record(goat); hold(new ItemStack(Material.BUCKET, 2)); harvest.onMilk(interact(goat)); assertEquals(1, player.getInventory().getItemInMainHand().getAmount()); assertEquals(1, player.getInventory().all(Material.MILK_BUCKET).size()); + animal.setLastMilkAt(null); player.getInventory().clear(); player.setGameMode(GameMode.CREATIVE); hold(new ItemStack(Material.BUCKET)); harvest.onMilk(interact(goat)); assertEquals(Material.BUCKET, player.getInventory().getItemInMainHand().getType()); assertEquals(1, player.getInventory().all(Material.MILK_BUCKET).size()); + animal.setLastMilkAt(null); player.setGameMode(GameMode.SURVIVAL); for (int slot = 0; slot < player.getInventory().getSize(); slot++) player.getInventory().setItem(slot, new ItemStack(Material.STONE, 64)); hold(new ItemStack(Material.BUCKET, 2)); + harvest.onMilk(interact(goat)); assertTrue(world.getEntitiesByClass(Item.class).stream().anyMatch(item -> item.getItemStack().getType() == Material.MILK_BUCKET)); assertEquals(1, player.getInventory().getItemInMainHand().getAmount()); + } + + @Test void vanillaMilkFillIsCancelledWithoutAffectingWaterBuckets() { + var block = world.getBlockAt(0, 64, 0); + var milk = new PlayerBucketFillEvent(player, block, block, BlockFace.UP, Material.MILK_BUCKET, new ItemStack(Material.MILK_BUCKET), EquipmentSlot.HAND); harvest.onVanillaMilkFill(milk); assertTrue(milk.isCancelled()); + var water = new PlayerBucketFillEvent(player, block, block, BlockFace.UP, Material.WATER_BUCKET, new ItemStack(Material.WATER_BUCKET), EquipmentSlot.HAND); harvest.onVanillaMilkFill(water); assertFalse(water.isCancelled()); + } + + @Test void shearsInteractionFiltersNeuteringToolsSheepAndMissingRecordsBeforeGrantingWool() { + Goat goat = spawn(Goat.class); hold(new ItemStack(Material.SHEARS)); harvest.onShearsInteract(interact(goat, EquipmentSlot.OFF_HAND)); + hold(null); harvest.onShearsInteract(interact(goat)); + ItemStack tool = new ItemStack(Material.SHEARS); var meta = tool.getItemMeta(); meta.setUnbreakable(true); tool.setItemMeta(meta); hold(tool); harvest.onShearsInteract(interact(goat)); + hold(new ItemStack(Material.SHEARS)); harvest.onShearsInteract(interact(spawn(ItemDisplay.class))); harvest.onShearsInteract(interact(spawn(Sheep.class))); harvest.onShearsInteract(interact(spawn(Pig.class))); harvest.onShearsInteract(interact(spawn(Cow.class))); + when(Cooking.plugin.getHusbandryRepository()).thenReturn(null); harvest.onShearsInteract(interact(goat)); when(Cooking.plugin.getHusbandryRepository()).thenReturn(repository); harvest.onShearsInteract(interact(goat)); + HusbandryAnimal animal = record(goat); long before = System.currentTimeMillis(); var ready = interact(goat); harvest.onShearsInteract(ready); + assertTrue(ready.isCancelled()); assertEquals(2, player.getInventory().all(Material.WHITE_WOOL).values().stream().mapToInt(ItemStack::getAmount).sum()); assertTrue(animal.woolReadyAt() >= before + 20_000); assertNotNull(repository.getAnimal(goat.getUniqueId()).orElseThrow().woolReadyAt()); + } + + @Test void shearEventsRetainVanillaSheepDropsAndApplyManagedGoatCooldowns() { + harvest.onShear(shear(spawn(Pig.class))); harvest.onShear(shear(spawn(Cow.class))); + Goat goat = spawn(Goat.class); when(Cooking.plugin.getHusbandryRepository()).thenReturn(null); harvest.onShear(shear(goat)); when(Cooking.plugin.getHusbandryRepository()).thenReturn(repository); harvest.onShear(shear(goat)); + record(goat); var goatEvent = shear(goat); harvest.onShear(goatEvent); assertTrue(goatEvent.isCancelled()); + Sheep sheep = spawn(Sheep.class); HusbandryAnimal animal = record(sheep); var sheepEvent = shear(sheep); harvest.onShear(sheepEvent); assertFalse(sheepEvent.isCancelled()); assertNotNull(animal.woolReadyAt()); + } + + @Test void onlyManagedConfiguredChickenEggsAreReplacedByTheHusbandryTimer() throws Exception { + Chicken chicken = spawn(Chicken.class); Item egg = world.dropItem(new Location(world, 0, 65, 0), new ItemStack(Material.EGG)); + var otherAnimal = new EntityDropItemEvent(spawn(Cow.class), egg); harvest.onDropEgg(otherAnimal); assertFalse(otherAnimal.isCancelled()); + Item feather = world.dropItem(new Location(world, 0, 65, 0), new ItemStack(Material.FEATHER)); var nonEgg = new EntityDropItemEvent(chicken, feather); harvest.onDropEgg(nonEgg); assertFalse(nonEgg.isCancelled()); + when(Cooking.plugin.getHusbandryRepository()).thenReturn(null); var unavailable = new EntityDropItemEvent(chicken, egg); harvest.onDropEgg(unavailable); assertFalse(unavailable.isCancelled()); when(Cooking.plugin.getHusbandryRepository()).thenReturn(repository); + var unknown = new EntityDropItemEvent(chicken, egg); harvest.onDropEgg(unknown); assertFalse(unknown.isCancelled()); + record(chicken); var managed = new EntityDropItemEvent(chicken, egg); harvest.onDropEgg(managed); assertTrue(managed.isCancelled()); + HusbandryEntities.evict(chicken.getUniqueId()); var persisted = new EntityDropItemEvent(chicken, egg); harvest.onDropEgg(persisted); assertTrue(persisted.isCancelled()); + config.set("species.CHICKEN.egg", ""); reloadConfig(); var disabled = new EntityDropItemEvent(chicken, egg); harvest.onDropEgg(disabled); assertFalse(disabled.isCancelled()); + config.set("species.CHICKEN", null); reloadConfig(); var removed = new EntityDropItemEvent(chicken, egg); harvest.onDropEgg(removed); assertFalse(removed.isCancelled()); + } + + @Test void anvilNamesAreStoredOnlyForNamedTamingItems() { + AnvilView view = mock(AnvilView.class); when(view.getTopInventory()).thenReturn(new AnvilInventoryMock(null)); + taming.onPrepareAnvil(new PrepareAnvilEvent(view, null)); taming.onPrepareAnvil(new PrepareAnvilEvent(view, new ItemStack(Material.STONE))); + ItemStack tag = new ItemStack(Material.NAME_TAG); var missing = new PrepareAnvilEvent(view, tag); taming.onPrepareAnvil(missing); assertNull(HusbandryItems.tameName(missing.getResult())); + when(view.getRenameText()).thenReturn(" "); taming.onPrepareAnvil(missing); assertNull(HusbandryItems.tameName(missing.getResult())); + when(view.getRenameText()).thenReturn(" Daisy "); var named = new PrepareAnvilEvent(view, tag); taming.onPrepareAnvil(named); assertEquals("Daisy", HusbandryItems.tameName(named.getResult())); + } + + @Test void tamingRequiresAMainHandNameSupportedSpeciesAndVanillaTamingFirst() { + Cow cow = spawn(Cow.class); hold(new ItemStack(Material.STONE)); var unrelated = interact(cow); taming.onInteractEntity(unrelated); assertFalse(unrelated.isCancelled()); + hold(namedTag("Daisy")); var offHand = interact(cow, EquipmentSlot.OFF_HAND); taming.onInteractEntity(offHand); assertFalse(offHand.isCancelled()); + var decoration = interact(spawn(ItemDisplay.class)); taming.onInteractEntity(decoration); assertTrue(decoration.isCancelled()); + hold(new ItemStack(Material.NAME_TAG)); taming.onInteractEntity(interact(cow)); assertTrue(player.nextMessage().contains("anvil")); + hold(namedTag("Wolfie")); Wolf wolf = spawn(Wolf.class); taming.onInteractEntity(interact(wolf)); assertTrue(player.nextMessage().contains("Tame this animal first")); + taming.onInteractEntity(interact(spawn(Pig.class))); assertTrue(player.nextMessage().contains("cannot be tamed")); + when(Cooking.plugin.getHusbandryRepository()).thenReturn(null); taming.onInteractEntity(interact(cow)); assertEquals(2, player.getInventory().getItemInMainHand().getAmount()); + } + + @Test void tamingAndRenamingPersistOwnersNamesAndExactlyOneItemUse() { + Cow cow = spawn(Cow.class); hold(namedTag("Daisy")); taming.onInteractEntity(interact(cow)); + HusbandryAnimal animal = repository.getAnimal(cow.getUniqueId()).orElseThrow(); assertEquals("Daisy", animal.name()); assertEquals(HusbandryAnimalState.OWNED, animal.state()); assertTrue(HusbandryOwnershipService.isOwner(player, animal.uuid())); assertEquals("Daisy", cow.getCustomName()); assertTrue(HusbandryEntities.isManaged(cow)); assertEquals(1, player.getInventory().getItemInMainHand().getAmount()); assertTrue(player.nextMessage().contains("Tamed Daisy")); + hold(namedTag("Buttercup")); taming.onInteractEntity(interact(cow)); assertEquals("Buttercup", repository.getAnimal(cow.getUniqueId()).orElseThrow().name()); assertTrue(player.nextMessage().contains("Renamed to Buttercup")); + Wolf wolf = spawn(Wolf.class); wolf.setTamed(true); hold(namedTag("Wolfie")); taming.onInteractEntity(interact(wolf)); assertTrue(repository.exists(wolf.getUniqueId())); assertTrue(player.nextMessage().contains("Tamed Wolfie")); + } + + @Test void tamingRejectsWildAndRecordedAnimalsAtCapacityAndAnimalsOwnedByOthers() { + own(record(spawn(Cow.class)), player); own(record(spawn(Cow.class)), player); hold(namedTag("Too many")); + Cow wild = spawn(Cow.class); taming.onInteractEntity(interact(wild)); assertFalse(repository.exists(wild.getUniqueId())); assertTrue(player.nextMessage().contains("too many")); + Cow recorded = spawn(Cow.class); record(recorded); taming.onInteractEntity(interact(recorded)); assertTrue(player.nextMessage().contains("too many")); assertEquals(2, player.getInventory().getItemInMainHand().getAmount()); + Cow others = spawn(Cow.class); own(record(others), target); taming.onInteractEntity(interact(others)); assertTrue(player.nextMessage().contains("already has an owner")); + } + + @Test void cancelledEntityInteractionsCannotClaimOrConsumeATamingItem() { + Cow cow = spawn(Cow.class); hold(namedTag("Protected")); + server.getPluginManager().registerEvents(taming, MockBukkit.createMockPlugin("HusbandryTest")); + var denied = interact(cow); denied.setCancelled(true); server.getPluginManager().callEvent(denied); + assertFalse(repository.exists(cow.getUniqueId()), "An earlier listener denied this interaction"); assertEquals(2, player.getInventory().getItemInMainHand().getAmount()); + } + + @Test void sharingLinksOnlyOwnedRecordsAndUsesTheFallbackSpeciesName() { + Cow cow = spawn(Cow.class); hold(new ItemStack(Material.PAPER, 2)); + when(Cooking.plugin.getHusbandryRepository()).thenReturn(null); taming.onInteractEntity(interact(cow)); when(Cooking.plugin.getHusbandryRepository()).thenReturn(repository); + taming.onInteractEntity(interact(cow)); assertTrue(player.nextMessage().contains("cannot be shared")); + HusbandryAnimal animal = record(cow); taming.onInteractEntity(interact(cow)); assertTrue(player.nextMessage().contains("not your animal")); + own(animal, player); animal.setName(null); repository.upsertAnimal(animal); taming.onInteractEntity(interact(cow)); + assertEquals(animal.uuid().toString(), HusbandryItems.linkedAnimal(player.getInventory().getItemInMainHand())); assertTrue(player.nextMessage().contains("Linked to Cow")); + animal.setName("Daisy"); taming.onInteractEntity(interact(cow)); assertTrue(player.nextMessage().contains("Linked to Daisy")); + hold(new ItemStack(Material.PAPER)); var decoration = interact(spawn(ItemDisplay.class)); taming.onInteractEntity(decoration); assertTrue(decoration.isCancelled()); + } + + @Test void sharingReportsMissingMalformedAndUnresolvableTokensWithoutConsumption() { + for (String linked : Arrays.asList(null, " ", "bad-uuid", UUID.randomUUID().toString())) { + hold(token(linked)); taming.onInteractEntity(interact(target)); + assertNotNull(player.nextMessage()); assertEquals(2, player.getInventory().getItemInMainHand().getAmount()); + } + HusbandryAnimal animal = record(spawn(Cow.class)); hold(token(animal.uuid().toString())); when(Cooking.plugin.getHusbandryRepository()).thenReturn(null); taming.onInteractEntity(interact(target)); assertEquals(2, player.getInventory().getItemInMainHand().getAmount()); + } + + @Test void sharingRequiresAnOwnerAndRejectsExistingOwnersAndFullRecipients() { + HusbandryAnimal animal = record(spawn(Cow.class)); hold(token(animal.uuid().toString())); taming.onInteractEntity(interact(target)); assertTrue(player.nextMessage().contains("not your animal")); + own(animal, player); own(animal, target); taming.onInteractEntity(interact(target)); assertTrue(player.nextMessage().contains("Already an owner")); + repository.deleteOwner(animal.uuid(), target.getUniqueId()); own(record(spawn(Cow.class)), target); own(record(spawn(Cow.class)), target); taming.onInteractEntity(interact(target)); assertTrue(player.nextMessage().contains("too many")); assertEquals(2, player.getInventory().getItemInMainHand().getAmount()); + } + + @Test void sharingToAnotherPlayerPersistsCoOwnershipAndConsumesOneToken() { + HusbandryAnimal animal = record(spawn(Cow.class)); own(animal, player); hold(token(animal.uuid().toString())); taming.onInteractEntity(interact(target)); + assertTrue(HusbandryOwnershipService.isOwner(target, animal.uuid())); assertEquals(1, player.getInventory().getItemInMainHand().getAmount()); assertTrue(player.nextMessage().contains("Added Neighbour")); assertTrue(target.nextMessage().contains("co-owner")); + } + + @Test void aRecipientCanAcceptALinkedTokenByClickingAnyEntityOrUsingItInTheAir() { + HusbandryAnimal first = record(spawn(Cow.class)); first.setName(null); repository.upsertAnimal(first); own(first, target); hold(token(first.uuid().toString())); taming.onInteractEntity(interact(spawn(ItemDisplay.class))); + assertTrue(HusbandryOwnershipService.isOwner(player, first.uuid())); + assertEquals("§aYou are now a co-owner of animal.", player.nextMessage()); + assertEquals(1, player.getInventory().getItemInMainHand().getAmount()); + HusbandryAnimal second = record(spawn(Cow.class)); own(second, target); hold(token(second.uuid().toString())); + var use = use(Action.RIGHT_CLICK_AIR, EquipmentSlot.HAND); taming.onInteractUse(use); assertTrue(use.isCancelled()); assertTrue(HusbandryOwnershipService.isOwner(player, second.uuid())); assertEquals(1, player.getInventory().getItemInMainHand().getAmount()); + } + + @Test void tokenUseIgnoresOffHandOtherActionsItemsAndTokensAlreadyOwned() { + HusbandryAnimal animal = record(spawn(Cow.class)); own(animal, player); hold(token(animal.uuid().toString())); + taming.onInteractUse(use(Action.RIGHT_CLICK_AIR, EquipmentSlot.OFF_HAND)); taming.onInteractUse(use(Action.LEFT_CLICK_AIR, EquipmentSlot.HAND)); taming.onInteractUse(use(Action.LEFT_CLICK_BLOCK, EquipmentSlot.HAND)); + taming.onInteractUse(use(Action.RIGHT_CLICK_BLOCK, EquipmentSlot.HAND)); assertEquals(2, player.getInventory().getItemInMainHand().getAmount()); + hold(new ItemStack(Material.STONE)); taming.onInteractUse(use(Action.RIGHT_CLICK_AIR, EquipmentSlot.HAND)); assertNull(player.nextMessage()); + } + + private T spawn(Class type) { + T entity = spy(world.spawn(new Location(world, 1, 65, 1), type)); + if (entity instanceof LivingEntity living) { + // MockBukkit does not implement this server despawn flag; retain its real value here. + boolean[] removeWhenFar = {true}; + doAnswer(call -> { removeWhenFar[0] = call.getArgument(0); return null; }).when(living).setRemoveWhenFarAway(anyBoolean()); + doAnswer(call -> removeWhenFar[0]).when(living).getRemoveWhenFarAway(); + } + return entity; + } + private MockedStatic scoped(Class type) { MockedStatic result = mockStatic(type); mocks.add(result); return result; } + private HusbandryAnimal record(LivingEntity entity) { HusbandryAnimal animal = new HusbandryAnimal(entity.getUniqueId(), entity.getType().name(), HusbandryEntities.displayName(entity.getType())); repository.upsertAnimal(animal); HusbandryEntities.putLoaded(animal); return animal; } + private void own(HusbandryAnimal animal, Player owner) { repository.upsertOwner(new HusbandryOwner(animal.uuid(), owner.getUniqueId(), "owner")); } + private void hold(ItemStack item) { player.getInventory().setItemInMainHand(item); } + private ItemStack namedTag(String name) { ItemStack item = new ItemStack(Material.NAME_TAG, 2); HusbandryItems.setTameName(item, name); return item; } + private ItemStack token(String linked) { ItemStack item = new ItemStack(Material.PAPER, 2); if (linked != null) HusbandryItems.setLinkedAnimal(item, linked, null); return item; } + private PlayerInteractEntityEvent interact(Entity clicked) { return interact(clicked, EquipmentSlot.HAND); } + private PlayerInteractEntityEvent interact(Entity clicked, EquipmentSlot hand) { return new PlayerInteractEntityEvent(player, clicked, hand); } + private PlayerShearEntityEvent shear(Entity entity) { return new PlayerShearEntityEvent(player, entity, new ItemStack(Material.SHEARS), EquipmentSlot.HAND, List.of()); } + private EntityBreedEvent breed(LivingEntity child, LivingEntity mother, LivingEntity father, LivingEntity breeder) { return new EntityBreedEvent(child, mother, father, breeder, new ItemStack(Material.WHEAT), 1); } + private PlayerInteractEvent use(Action action, EquipmentSlot hand) { return new PlayerInteractEvent(player, action, player.getInventory().getItemInMainHand(), action == Action.RIGHT_CLICK_BLOCK || action == Action.LEFT_CLICK_BLOCK ? world.getBlockAt(0, 64, 0) : null, BlockFace.UP, hand); } + private void assertBlockedLove(Cow cow, String message) { cow.setLoveModeTicks(500); HusbandryFeedQuality.offer(cow.getUniqueId(), 0.2, System.currentTimeMillis()); var event = new EntityEnterLoveModeEvent(cow, player, 600); breeding.onEnterLove(event); assertTrue(event.isCancelled()); assertEquals(0, cow.getLoveModeTicks()); assertTrue(player.nextMessage().contains(message)); } + private void reloadConfig() throws Exception { Path file = directory.resolve("husbandry.yml"); config.save(file.toFile()); new HusbandryLoader().load(file.toFile()); } +} diff --git a/src/test/java/net/tfminecraft/cooking/husbandry/HusbandryLoaderCoverageTest.java b/src/test/java/net/tfminecraft/cooking/husbandry/HusbandryLoaderCoverageTest.java new file mode 100644 index 0000000..733ed54 --- /dev/null +++ b/src/test/java/net/tfminecraft/cooking/husbandry/HusbandryLoaderCoverageTest.java @@ -0,0 +1,209 @@ +package net.tfminecraft.cooking.husbandry; + +import static org.junit.jupiter.api.Assertions.*; + +import java.lang.reflect.Field; +import java.lang.reflect.Modifier; +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.*; +import org.bukkit.configuration.MemoryConfiguration; +import org.bukkit.configuration.file.YamlConfiguration; +import org.bukkit.entity.EntityType; +import org.junit.jupiter.api.*; +import org.junit.jupiter.api.io.TempDir; +import org.mockbukkit.mockbukkit.MockBukkit; + +class HusbandryLoaderCoverageTest { + @TempDir Path directory; + private ConfigSnapshot snapshot; + @BeforeEach void setUp() throws Exception { snapshot = new ConfigSnapshot(); MockBukkit.mock(); } + @AfterEach void tearDown() throws Exception { snapshot.close(); MockBukkit.unmock(); } + + @Test void completeYamlLoadsDurationsItemsSpeciesDropsBandsAndMounts() throws Exception { + load(""" + max-animals: 3 + care-max: 120 + care: + up: {interval: 2m, amount: 2.5} + down: {interval: 30s, amount: 0.75} + decay-grace: 1h + offline-care: 2h + long-unload-force: 3h + min-loaded: 5m + affliction: {mean: 10h, min: 2h, max: 1h} + milk-timer: 3m + initial-genetic-max: 5 + max-genetics: 900 + min-roast-cuts: 2 + wool-timer: 4m + grow-up: 5m + shed-timer: 6m + shed-chance: 0.25 + egg-timer: 7m + items: {tame: tame, co-own: share, feed: feed, glove: glove, neuter: neuter, inspect: inspect, mount-stats: stats} + remove-unowned: [' cow ', PIG, '', unknown-animal] + damage: {other-players: false, owner: false, mobs: false, environment: false} + breeding: {genetic-variance-multiplier: 0.3, genetic-slowdown-divisor: 0.7, care-influence: 0.04} + stats-revision: ' new-revision ' + restore-lost-animals: false + profession: ' ranching ' + exp: {min: 4, max: 9} + species: + COW: + milk: true + grow-up: 2s + wool-timer: 0s + milk-timer: 2m + exp: {min: 5, max: 7} + slaughter: + meat: roast-beef + drops: + mode: counted + common: + - {path: ' leather ', amount: '2', weight: '3'} + - {amount: -1} + - {path: fallback, amount: bad, weight: bad} + - {path: skipped, weight: 0} + rare: [{path: rare, weight: 2}] + epic: [{path: epic, amount: 3}] + legendary: [{path: legendary, weight: 4}] + shear: {} + shed: + drops: {common: [{path: hair, amount: 2}]} + egg: vanilla + WOLF: {} + invalid-species: {} + quality-from-genetics: + - {min: '200', stars: 9} + - {min: 0, stars: -1} + - {min: 100, stars: '3'} + - {min: bad, stars: bad} + amount-from-genetics: + - {min: '100', roast-cuts: 4, wool: 3, secondary-extra: 9} + - {min: 0, roast-cuts: 0, wool: 0, secondary-extra: -1} + mounts: + speed: {min-pct: 0.1, genetics-pct: 0.2, care-pct: 0.3} + nerf: true + nerf-divisor: 2 + HORSE: {min-health: 8, max-health: 32, min-speed: 0.2, max-speed: 0.7, min-jump: 0.4, max-jump: 0.9} + DONKEY: {} + COW: invalid-scalar + invalid-mount: {} + """); + assertEquals(3, HusbandryConfig.maxAnimals()); assertEquals(120, HusbandryConfig.careMax()); + assertEquals(120, HusbandryConfig.careUpIntervalSeconds()); assertEquals(2.5, HusbandryConfig.careUpAmount()); + assertEquals(30, HusbandryConfig.careDownIntervalSeconds()); assertEquals(0.75, HusbandryConfig.careDownAmount()); + assertEquals(3600, HusbandryConfig.decayGraceSeconds()); assertEquals(7200, HusbandryConfig.offlineCareSeconds()); assertEquals(10800, HusbandryConfig.longUnloadForceSeconds()); assertEquals(300, HusbandryConfig.minLoadedSeconds()); + assertEquals(10, HusbandryConfig.afflictionMeanHours()); assertEquals(2, HusbandryConfig.afflictionMinHours()); assertEquals(2, HusbandryConfig.afflictionMaxHours()); + assertEquals(180, HusbandryConfig.milkCooldownSeconds()); assertEquals(5, HusbandryConfig.initialGeneticMax()); assertEquals(900, HusbandryConfig.maxGenetics()); assertEquals(2, HusbandryConfig.minRoastCuts()); + assertEquals(180, HusbandryConfig.milkTimerSeconds()); + assertEquals(240, HusbandryConfig.woolTimerSeconds()); assertEquals(300, HusbandryConfig.growUpSeconds()); assertEquals(360, HusbandryConfig.shedTimerSeconds()); assertEquals(0.25, HusbandryConfig.shedChance()); assertEquals(420, HusbandryConfig.eggTimerSeconds()); + assertEquals(List.of("tame", "share", "feed", "glove", "neuter", "inspect", "stats"), List.of(HusbandryConfig.tameItem(), HusbandryConfig.coOwnItem(), HusbandryConfig.feedItem(), HusbandryConfig.gloveItem(), HusbandryConfig.neuterItem(), HusbandryConfig.inspectItem(), HusbandryConfig.mountStatsItem())); + assertEquals(Set.of(EntityType.COW, EntityType.PIG), HusbandryConfig.removeUnowned()); assertTrue(HusbandryConfig.isRemoveUnowned(EntityType.COW)); assertFalse(HusbandryConfig.isRemoveUnowned(null)); assertFalse(HusbandryConfig.isRemoveUnowned(EntityType.WOLF)); + assertFalse(HusbandryConfig.damageOtherPlayers()); assertFalse(HusbandryConfig.damageOwner()); assertFalse(HusbandryConfig.damageMobs()); assertFalse(HusbandryConfig.damageEnvironment()); + assertEquals(0.3, HusbandryConfig.geneticVarianceMultiplier()); assertEquals(0.7, HusbandryConfig.geneticSlowdownDivisor()); assertEquals(0.04, HusbandryConfig.careInfluence()); + assertEquals("new-revision", HusbandryConfig.statsRevision()); assertFalse(HusbandryConfig.restoreLostAnimals()); assertEquals("ranching", HusbandryConfig.professionId()); + assertEquals(0.1, HusbandryConfig.mountSpeedMinPct()); assertEquals(0.2, HusbandryConfig.mountSpeedGeneticsPct()); assertEquals(0.3, HusbandryConfig.mountSpeedCarePct()); + assertEquals(Set.of(EntityType.COW, EntityType.WOLF), HusbandryConfig.species().keySet()); + HusbandrySpecies cow = HusbandryConfig.species(EntityType.COW); + assertTrue(cow.canMilk()); assertTrue(cow.canSlaughter()); assertFalse(cow.canShear()); assertTrue(cow.canShed()); assertTrue(cow.hasEgg()); assertTrue(cow.vanillaEggs()); assertEquals("roast-beef", cow.slaughterMeat()); + assertEquals(2, HusbandryConfig.growUpSeconds(EntityType.COW)); assertEquals(1, HusbandryConfig.woolTimerSeconds(EntityType.COW)); assertEquals(120, HusbandryConfig.milkTimerSeconds(EntityType.COW)); + for (EntityType fallback : Arrays.asList(null, EntityType.PIG, EntityType.WOLF)) { + assertEquals(300, HusbandryConfig.growUpSeconds(fallback)); assertEquals(240, HusbandryConfig.woolTimerSeconds(fallback)); assertEquals(180, HusbandryConfig.milkTimerSeconds(fallback)); + assertEquals(4, HusbandryConfig.expFor(fallback).min()); assertEquals(9, HusbandryConfig.expFor(fallback).max()); + } + assertEquals(5, HusbandryConfig.expFor(EntityType.COW).min()); assertEquals(7, HusbandryConfig.expFor(EntityType.COW).max()); + HusbandryDropTable drops = cow.slaughterDrops(); assertTrue(drops.counted()); assertEquals(3, drops.common().size()); + assertEquals("leather", drops.common().getFirst().path()); assertEquals(2, drops.common().getFirst().amount()); assertEquals(3, drops.common().getFirst().weight()); + assertEquals("", drops.common().get(1).path()); assertEquals(1, drops.common().get(1).amount()); assertEquals(1, drops.common().get(2).weight()); + assertEquals("rare", drops.rare().getFirst().path()); assertEquals(3, drops.epic().getFirst().amount()); assertEquals(4, drops.legendary().getFirst().weight()); assertEquals("hair", cow.shedDrops().common().getFirst().path()); + HusbandryMountStats horse = HusbandryConfig.mountStats(EntityType.HORSE); assertEquals(EntityType.HORSE, horse.type()); + assertEquals(8, horse.minHealth()); assertEquals(32, horse.maxHealth()); assertEquals(0.2, horse.minSpeed()); assertEquals(0.7, horse.maxSpeed()); assertEquals(0.4, horse.minJump()); assertEquals(0.9, horse.maxJump()); + assertEquals(Set.of(EntityType.HORSE, EntityType.DONKEY), HusbandryConfig.mounts().keySet()); assertEquals(15, HusbandryConfig.mountStats(EntityType.DONKEY).minHealth()); + assertEquals(List.of(0, 0, 100, 200), HusbandryConfig.qualityBands().stream().map(HusbandryQualityBand::minGenetics).toList()); assertEquals(1, HusbandryConfig.starsForGenetics(0)); assertEquals(5, HusbandryConfig.starsForGenetics(200)); + HusbandryAnimal animal = new HusbandryAnimal(UUID.randomUUID(), "COW", "Daisy"); animal.setGenetics(200); animal.setCare(60); + assertEquals(100, HusbandryConfig.effectiveGenetics(animal)); assertEquals(new HusbandryQualityRange.Bounds(3, 5), HusbandryConfig.qualityRange(animal)); + assertEquals(new HusbandryQualityRange.Bounds(1, 1), HusbandryConfig.qualityRange(null)); assertEquals(0, HusbandryConfig.effectiveGenetics(null)); + assertEquals(2, HusbandryConfig.roastCutsFor(-1)); assertEquals(4, HusbandryConfig.roastCutsFor(100)); assertEquals(1, HusbandryConfig.woolFor(0)); assertEquals(3, HusbandryConfig.woolFor(100)); assertEquals(0, HusbandryConfig.secondaryExtraFor(0)); assertEquals(3, HusbandryConfig.hideCount(100)); assertEquals(0, HusbandryConfig.secondaryExtraFor(100, null)); + assertTrue(HusbandryConfig.isHusbandryType(EntityType.WOLF)); assertTrue(HusbandryConfig.isHusbandryType(EntityType.PIG)); assertFalse(HusbandryConfig.isHusbandryType(EntityType.ZOMBIE)); assertFalse(HusbandryConfig.isHusbandryType(null)); + assertThrows(UnsupportedOperationException.class, () -> HusbandryConfig.species().clear()); assertThrows(UnsupportedOperationException.class, () -> HusbandryConfig.qualityBands().clear()); + } + + @Test void legacyDurationsAndEmptySectionsRemainSupported() throws Exception { + load(""" + care-up-per-hour: 2 + care-down-per-hour: 3 + decay-grace-hours: 2.5 + offline-care-hours: 3 + long-unload-force-hours: 4 + min-loaded-seconds: 5 + affliction-mean-hours: 7 + affliction-min-hours: 5 + affliction-max-hours: 9 + milk-cooldown-minutes: 6 + wool-timer-hours: 7 + """); + assertEquals(3600, HusbandryConfig.careUpIntervalSeconds()); assertEquals(2, HusbandryConfig.careUpAmount()); assertEquals(3600, HusbandryConfig.careDownIntervalSeconds()); assertEquals(3, HusbandryConfig.careDownAmount()); + assertEquals(9000, HusbandryConfig.decayGraceSeconds()); assertEquals(10800, HusbandryConfig.offlineCareSeconds()); assertEquals(14400, HusbandryConfig.longUnloadForceSeconds()); assertEquals(5, HusbandryConfig.minLoadedSeconds()); + assertEquals(7, HusbandryConfig.afflictionMeanHours()); assertEquals(5, HusbandryConfig.afflictionMinHours()); assertEquals(9, HusbandryConfig.afflictionMaxHours()); assertEquals(360, HusbandryConfig.milkCooldownSeconds()); assertEquals(25200, HusbandryConfig.woolTimerSeconds()); + assertTrue(HusbandryConfig.species().isEmpty()); assertTrue(HusbandryConfig.mounts().isEmpty()); assertTrue(HusbandryConfig.qualityBands().isEmpty()); + load("milk-cooldown: 2m\n"); assertEquals(120, HusbandryConfig.milkCooldownSeconds()); + load(""); assertEquals(1200, HusbandryConfig.milkCooldownSeconds()); assertEquals(86400, HusbandryConfig.decayGraceSeconds()); assertEquals(6, HusbandryConfig.expFor(null).min()); + } + + @Test void missingAndMalformedFilesResetToTheSameDefaultsAsEmptyYaml() throws Exception { + load(""); int defaultWool = HusbandryConfig.woolTimerSeconds(); + load("max-animals: 99\nwool-timer: 1s\nprofession: previous\n"); + new HusbandryLoader().load(directory.resolve("absent.yml").toFile()); + assertEquals(15, HusbandryConfig.maxAnimals()); assertEquals(defaultWool, HusbandryConfig.woolTimerSeconds(), "Read failures must not silently change the default wool interval"); assertEquals("farming", HusbandryConfig.professionId()); + load("max-animals: [broken\n"); assertEquals(15, HusbandryConfig.maxAnimals()); assertTrue(HusbandryConfig.species().isEmpty()); assertEquals("1", HusbandryConfig.statsRevision()); assertTrue(HusbandryConfig.restoreLostAnimals()); + } + + @Test void publicConfigApiClampsBoundsAndDefensivelyCopiesCallerCollections() { + Set removed = new HashSet<>(Set.of(EntityType.COW)); + Map species = new HashMap<>(); + List quality = new ArrayList<>(List.of(new HusbandryQualityBand(0, 2))); + List amounts = new ArrayList<>(List.of(new HusbandryAmountBand(0, 3, 4))); + HusbandryConfig.apply(-1, 0, 0, 2, -1, 3, -1, -1, -1, -1, 6, 4, 2, -1, -1, 0, -1, -1, -1, -1, 2, -1, + null, null, null, null, null, null, null, removed, species, true, true, true, true, quality, Map.of(), 0.4, 0.6, amounts); + removed.clear(); species.put(EntityType.PIG, species(EntityType.PIG)); quality.clear(); amounts.clear(); + assertEquals(1, HusbandryConfig.maxAnimals()); assertEquals(1, HusbandryConfig.careMax()); assertEquals(1, HusbandryConfig.careUpIntervalSeconds()); assertEquals(1, HusbandryConfig.careDownIntervalSeconds()); assertEquals(4, HusbandryConfig.afflictionMaxHours()); assertEquals(1, HusbandryConfig.maxGenetics()); + assertEquals(0, HusbandryConfig.decayGraceSeconds()); assertEquals(0, HusbandryConfig.offlineCareSeconds()); assertEquals(0, HusbandryConfig.longUnloadForceSeconds()); assertEquals(0, HusbandryConfig.minLoadedSeconds()); assertEquals(0, HusbandryConfig.milkCooldownSeconds()); assertEquals(0, HusbandryConfig.initialGeneticMax()); assertEquals(0, HusbandryConfig.minRoastCuts()); assertEquals(0, HusbandryConfig.woolTimerSeconds()); assertEquals(0, HusbandryConfig.growUpSeconds()); assertEquals(0, HusbandryConfig.shedTimerSeconds()); assertEquals(0, HusbandryConfig.eggTimerSeconds()); assertEquals(1, HusbandryConfig.shedChance()); + assertEquals(List.of("", "", "", "", "", "", ""), List.of(HusbandryConfig.tameItem(), HusbandryConfig.coOwnItem(), HusbandryConfig.feedItem(), HusbandryConfig.gloveItem(), HusbandryConfig.neuterItem(), HusbandryConfig.inspectItem(), HusbandryConfig.mountStatsItem())); + assertEquals(Set.of(EntityType.COW), HusbandryConfig.removeUnowned()); assertTrue(HusbandryConfig.species().isEmpty()); assertEquals(2, HusbandryConfig.starsForGenetics(0)); assertEquals(3, HusbandryConfig.roastCutsFor(0)); assertEquals(4, HusbandryConfig.woolFor(0)); + HusbandryConfig.setMountSpeedShares(-1, -2, -3); assertEquals(0, HusbandryConfig.mountSpeedMinPct()); assertEquals(0, HusbandryConfig.mountSpeedGeneticsPct()); assertEquals(0, HusbandryConfig.mountSpeedCarePct()); + HusbandryConfig.setBreeding(-1, 0.2, -3); assertEquals(0, HusbandryConfig.geneticVarianceMultiplier()); assertEquals(0.2, HusbandryConfig.geneticSlowdownDivisor()); assertEquals(0, HusbandryConfig.careInfluence()); + HusbandryConfig.setStatsRevision(null); HusbandryConfig.setProfessionExp(null, null); assertEquals("", HusbandryConfig.statsRevision()); assertEquals("", HusbandryConfig.professionId()); assertEquals(6, HusbandryConfig.expFor(null).min()); assertEquals(8, HusbandryConfig.expFor(null).max()); + assertEquals(0, HusbandryConfig.resolveMilkTimerSeconds(0, -1)); assertEquals(2, HusbandryConfig.resolveMilkTimerSeconds(2, -1)); assertEquals(0, HusbandryConfig.resolveWoolTimerSeconds(0, -1)); assertEquals(2, HusbandryConfig.resolveWoolTimerSeconds(2, -1)); + } + + @Test void durationHelpersPreserveModernPrecedenceAndFractionalLegacyHours() { + YamlConfiguration config = new YamlConfiguration(); + assertEquals(5, HusbandryDuration.parseHours(config, "new", "old", 5)); + config.set("old", 2.5); assertEquals(2.5, HusbandryDuration.parseHours(config, "new", "old", 5)); + config.set("new", "30m"); assertEquals(0.5, HusbandryDuration.parseHours(config, "new", "old", 5)); + MemoryConfiguration section = new MemoryConfiguration(); + assertEquals(7, HusbandryDuration.parseHours(section, "new", "old", 7)); assertEquals(7, HusbandryDuration.parseHours((org.bukkit.configuration.ConfigurationSection) null, "new", null, 7)); + section.set("old", 3.5); assertEquals(3.5, HusbandryDuration.parseHours(section, "new", "old", 7)); + section.set("new", "15m"); assertEquals(0.25, HusbandryDuration.parseHours(section, "new", "old", 7)); + assertEquals(3600, HusbandryDuration.parseCareIntervalSeconds(config, "absent", "old", 10)); + assertEquals(2.5, HusbandryDuration.parseCareAmount(config, "absent", "old", 10)); + } + + private void load(String yaml) throws Exception { Path file = directory.resolve("husbandry.yml"); Files.writeString(file, yaml); new HusbandryLoader().load(file.toFile()); } + static HusbandrySpecies species(EntityType type) { return new HusbandrySpecies(type, false, "", null, null, null, "", 0, 0, 0); } + + /** Restores original static configuration only; test inputs always use the public YAML/API. */ + static final class ConfigSnapshot implements AutoCloseable { + private final Map values = new LinkedHashMap<>(); + ConfigSnapshot() throws IllegalAccessException { + for (Field field : HusbandryConfig.class.getDeclaredFields()) { + if (Modifier.isStatic(field.getModifiers()) && !Modifier.isFinal(field.getModifiers())) { + field.setAccessible(true); values.put(field, field.get(null)); + } + } + } + @Override public void close() throws IllegalAccessException { for (var entry : values.entrySet()) entry.getKey().set(null, entry.getValue()); } + } +} diff --git a/src/test/java/net/tfminecraft/cooking/husbandry/HusbandryOwnedMountTest.java b/src/test/java/net/tfminecraft/cooking/husbandry/HusbandryOwnedMountTest.java index 08b7adf..5b9f760 100644 --- a/src/test/java/net/tfminecraft/cooking/husbandry/HusbandryOwnedMountTest.java +++ b/src/test/java/net/tfminecraft/cooking/husbandry/HusbandryOwnedMountTest.java @@ -240,6 +240,9 @@ private static void checkLoad( when(entity.getUniqueId()).thenReturn(uuid); HusbandryRepository repository = mock(HusbandryRepository.class); when(repository.exists(uuid)).thenReturn(hasRow); + when(repository.getAnimal(uuid)).thenReturn(hasRow + ? java.util.Optional.of(new HusbandryAnimal(uuid, "HORSE", "")) + : java.util.Optional.empty()); try (MockedStatic entities = mockStatic(HusbandryEntities.class); MockedStatic config = mockStatic(HusbandryConfig.class); MockedStatic ownership = mockStatic(HusbandryOwnershipService.class)) { diff --git a/src/test/java/net/tfminecraft/cooking/husbandry/HusbandryRemainingCoverageTest.java b/src/test/java/net/tfminecraft/cooking/husbandry/HusbandryRemainingCoverageTest.java new file mode 100644 index 0000000..57dcf72 --- /dev/null +++ b/src/test/java/net/tfminecraft/cooking/husbandry/HusbandryRemainingCoverageTest.java @@ -0,0 +1,458 @@ +package net.tfminecraft.cooking.husbandry; + +import static org.junit.jupiter.api.Assertions.*; +import static org.mockito.ArgumentMatchers.*; +import static org.mockito.Mockito.*; + +import java.io.*; +import java.nio.file.*; +import java.sql.*; +import java.util.*; +import java.util.concurrent.CompletableFuture; +import java.util.function.Consumer; +import java.util.zip.GZIPOutputStream; +import org.bukkit.*; +import org.bukkit.command.CommandSender; +import org.bukkit.configuration.MemoryConfiguration; +import org.bukkit.entity.*; +import org.bukkit.event.entity.*; +import org.bukkit.event.player.PlayerInteractEntityEvent; +import org.bukkit.inventory.EquipmentSlot; +import org.bukkit.inventory.ItemStack; +import org.bukkit.inventory.meta.Damageable; +import org.bukkit.persistence.PersistentDataType; +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.mockito.MockedStatic; +import net.Indyuce.mmocore.MMOCore; +import net.Indyuce.mmocore.api.player.PlayerData; +import net.Indyuce.mmocore.experience.EXPSource; +import net.Indyuce.mmocore.experience.Profession; +import net.Indyuce.mmocore.manager.profession.ProfessionManager; +import net.tfminecraft.cooking.Cooking; +import net.tfminecraft.cooking.carve.CarveCut; +import net.tfminecraft.cooking.carve.CarveSequence; +import net.tfminecraft.cooking.husbandry.HusbandryRuntimeCoverageTest.Farm; +import net.tfminecraft.cooking.item.FoodItem; +import net.tfminecraft.cooking.loader.CarveSequenceLoader; +import net.tfminecraft.cooking.utils.FoodParser; +import net.tfminecraft.cooking.utils.InventoryAdder; +import net.tfminecraft.cooking.utils.ItemBuilder; +import net.tfminecraft.cooking.utils.ItemUpdater; +import net.tfminecraft.cooking.utils.Keys; +import net.tfminecraft.tlibs.TLibs; +import net.tfminecraft.tlibs.database.SqliteDatabase; +import net.tfminecraft.tlibs.database.SqliteDatabaseException; + +class HusbandryRemainingCoverageTest { + @TempDir Path directory; + Farm f; + @BeforeEach void open() throws Exception { f = new Farm(directory); HusbandryFeedQuality.clear(); } + @AfterEach void close() throws Exception { HusbandryFeedQuality.clear(); f.close(); } + PlayerInteractEntityEvent interaction(Entity entity) { return new PlayerInteractEntityEvent(f.player, entity, EquipmentSlot.HAND); } + void hold(Material material, int amount) { f.player.getInventory().setItemInMainHand(new ItemStack(material, amount)); } + int held() { return f.player.getInventory().getItemInMainHand().getAmount(); } + void clearMessages() { while (f.player.nextMessage() != null) { } } + void products() throws Exception { + for (String species : List.of("COW", "SHEEP")) { + f.config.set("species." + species + ".slaughter.meat", "meat(type=roast)"); + for (String mode : List.of("slaughter", "shear", "shed")) { + f.config.set("species." + species + "." + mode + ".drops.mode", "counted"); + f.config.set("species." + species + "." + mode + ".drops.common", List.of(Map.of("path", "wool", "amount", 2, "weight", 1))); + } + } + f.config.set("species.CHICKEN.egg", "vanilla"); f.reload(); + when(TLibs.getItemAPI().getCreator().getItemFromPath("wool")).thenAnswer(inv -> new ItemStack(Material.WHITE_WOOL)); + } + + @Test void careFeedsOnlyHungryAnimalsAndCleansWithoutConsumingTheGlove() { + var listener = new HusbandryCareListener(); Cow cow = f.spawn(Cow.class); HusbandryAnimal animal = f.record(cow); f.own(animal, f.player); + listener.onInteract(new PlayerInteractEntityEvent(f.player, cow, EquipmentSlot.OFF_HAND)); listener.onInteract(interaction(f.spawn(ItemDisplay.class))); + when(f.plugin.getHusbandryRepository()).thenReturn(null); listener.onInteract(interaction(cow)); when(f.plugin.getHusbandryRepository()).thenReturn(f.repository); + listener.onInteract(interaction(f.spawn(Cow.class))); hold(Material.STONE, 3); var ignored = interaction(cow); listener.onInteract(ignored); assertFalse(ignored.isCancelled()); + hold(Material.WHEAT, 3); listener.onInteract(interaction(cow)); assertEquals(3, held()); + animal.setHungrySince(1L); var feed = interaction(cow); listener.onInteract(feed); assertTrue(feed.isCancelled()); assertEquals(2, held()); assertNull(animal.hungrySince()); assertNull(f.repository.getAnimal(animal.uuid()).orElseThrow().hungrySince()); + hold(Material.LEATHER, 1); listener.onInteract(interaction(cow)); assertEquals(1, held()); + animal.setDirtySince(1L); var clean = interaction(cow); listener.onInteract(clean); assertNull(animal.dirtySince()); assertEquals(1, held()); assertTrue(clean.isCancelled()); + assertNull(f.repository.getAnimal(animal.uuid()).orElseThrow().dirtySince()); + animal.setHungrySince(1L); f.repository.deleteOwner(animal.uuid(), f.player.getUniqueId()); hold(Material.WHEAT, 2); listener.onInteract(interaction(cow)); assertEquals(2, held()); assertNotNull(animal.hungrySince()); + f.player.setOp(true); listener.onInteract(interaction(cow)); assertEquals(1, held()); assertNull(animal.hungrySince()); + } + + @Test void neuteringRequiresTheToolARecordAndOwnershipAndOnlyUsesTheToolOnce() { + var listener = new HusbandryNeuterListener(); Cow cow = f.spawn(Cow.class); + listener.onInteract(new PlayerInteractEntityEvent(f.player, cow, EquipmentSlot.OFF_HAND)); listener.onInteract(interaction(cow)); + hold(Material.SHEARS, 1); listener.onInteract(interaction(f.spawn(ItemDisplay.class))); + when(f.plugin.getHusbandryRepository()).thenReturn(null); listener.onInteract(interaction(cow)); when(f.plugin.getHusbandryRepository()).thenReturn(f.repository); + listener.onInteract(interaction(cow)); assertTrue(f.player.nextMessage().contains("cannot be neutered")); + HusbandryAnimal animal = f.record(cow); listener.onInteract(interaction(cow)); assertTrue(f.player.nextMessage().contains("not your")); assertFalse(animal.neutered()); + f.own(animal, f.player); var event = interaction(cow); listener.onInteract(event); assertTrue(event.isCancelled()); assertTrue(animal.neutered()); + assertTrue(f.repository.getAnimal(animal.uuid()).orElseThrow().neutered()); assertEquals(1, ((Damageable) f.player.getInventory().getItemInMainHand().getItemMeta()).getDamage()); + listener.onInteract(interaction(cow)); assertEquals(1, ((Damageable) f.player.getInventory().getItemInMainHand().getItemMeta()).getDamage()); + Cow other = f.spawn(Cow.class); HusbandryAnimal second = f.record(other); f.player.setOp(true); listener.onInteract(interaction(other)); assertTrue(second.neutered()); + } + + @Test void damageRulesDistinguishOwnersStaffStrangersMobsProjectilesAndEnvironment() throws Exception { + var listener = new HusbandryDamageListener(); Cow cow = f.spawn(Cow.class); + var unmanaged = new EntityDamageEvent(cow, EntityDamageEvent.DamageCause.FALL, 2); listener.onDamage(unmanaged); assertFalse(unmanaged.isCancelled()); + HusbandryAnimal animal = f.record(cow); f.own(animal, f.player); + for (String key : List.of("owner", "other-players", "mobs", "environment")) f.config.set("damage." + key, false); f.reload(); + assertTrue(attack(listener, cow, f.player)); assertTrue(attack(listener, cow, f.neighbour)); assertTrue(attack(listener, cow, f.spawn(Cow.class))); + f.neighbour.setOp(true); assertFalse(attack(listener, cow, f.neighbour)); + Arrow arrow = mock(Arrow.class); when(arrow.getShooter()).thenReturn(f.player); assertTrue(attack(listener, cow, arrow)); when(arrow.getShooter()).thenReturn(null); assertFalse(attack(listener, cow, arrow)); + assertFalse(attack(listener, cow, f.spawn(ItemDisplay.class))); + var fall = new EntityDamageEvent(cow, EntityDamageEvent.DamageCause.FALL, 2); listener.onDamage(fall); assertTrue(fall.isCancelled()); + var custom = new EntityDamageEvent(cow, EntityDamageEvent.DamageCause.CUSTOM, 2); listener.onDamage(custom); assertFalse(custom.isCancelled()); + for (String key : List.of("owner", "other-players", "mobs", "environment")) f.config.set("damage." + key, true); f.reload(); + f.neighbour.setOp(false); assertFalse(attack(listener, cow, f.player)); assertFalse(attack(listener, cow, f.neighbour)); assertFalse(attack(listener, cow, f.spawn(Cow.class))); + when(f.plugin.getHusbandryRepository()).thenReturn(null); assertFalse(attack(listener, cow, f.player)); + } + boolean attack(HusbandryDamageListener listener, Entity victim, Entity attacker) { + var event = new EntityDamageByEntityEvent(attacker, victim, EntityDamageEvent.DamageCause.ENTITY_ATTACK, 2); listener.onDamage(event); return event.isCancelled(); + } + + @Test void deathCleansRecordsReplacesMatureRoastsAndPreservesVanillaWool() throws Exception { + products(); var listener = new HusbandryDeathListener(); Cow absent = f.spawn(Cow.class); var untouched = death(absent); listener.onDeath(untouched); assertEquals(2, untouched.getDrops().size()); + when(f.plugin.getHusbandryRepository()).thenReturn(null); listener.onDeath(untouched); when(f.plugin.getHusbandryRepository()).thenReturn(f.repository); + try (var harvest = mockStatic(HusbandryHarvest.class, CALLS_REAL_METHODS)) { + harvest.when(() -> HusbandryHarvest.buildFood(any(), anyString())).thenAnswer(inv -> new ItemStack(Material.COOKED_BEEF)); + Cow mature = f.spawn(Cow.class); HusbandryAnimal adult = f.record(mature); f.own(adult, f.player); var event = death(mature); listener.onDeath(event); + assertTrue(event.getDrops().stream().anyMatch(item -> item.getType() == Material.COOKED_BEEF)); assertTrue(event.getDrops().stream().anyMatch(item -> item.getType() == Material.WHITE_WOOL)); + assertFalse(event.getDrops().stream().anyMatch(item -> item.getType() == Material.BEEF)); assertFalse(f.repository.exists(adult.uuid())); assertTrue(HusbandryEntities.getLoaded(adult.uuid()).isEmpty()); + Cow young = f.spawn(Cow.class); HusbandryAnimal baby = f.record(young); baby.setMatureAt(System.currentTimeMillis() + 60_000); var babyDrops = death(young); listener.onDeath(babyDrops); assertEquals(Material.BEEF, babyDrops.getDrops().getFirst().getType()); + for (ItemStack invalid : Arrays.asList(null, new ItemStack(Material.AIR))) { + harvest.when(() -> HusbandryHarvest.buildFood(any(), anyString())).thenReturn(invalid); Cow failed = f.spawn(Cow.class); f.record(failed); var failedOutput = death(failed); listener.onDeath(failedOutput); + assertTrue(failedOutput.getDrops().stream().anyMatch(item -> item.getType() == Material.BEEF), "Failed custom roast creation must preserve vanilla meat"); + assertTrue(failedOutput.getDrops().stream().noneMatch(item -> item.getType().isAir())); + } + } + f.config.set("remove-unowned", List.of("COW")); f.reload(); Cow wild = f.spawn(Cow.class); HusbandryAnimal record = f.record(wild); var wiped = death(wild); listener.onDeath(wiped); + assertTrue(wiped.getDrops().isEmpty()); assertEquals(0, wiped.getDroppedExp()); assertFalse(f.repository.exists(record.uuid())); + } + EntityDeathEvent death(LivingEntity entity) { return new EntityDeathEvent(entity, mock(org.bukkit.damage.DamageSource.class), new ArrayList<>(List.of(new ItemStack(Material.BEEF), new ItemStack(Material.WHITE_WOOL))), 5); } + + @Test void animalCommandsReportAvailabilityPermissionsAndCurrentOwnedLocations() { + var command = new HusbandryAnimalsCommand(); CommandSender console = mock(CommandSender.class); + Cooking.plugin = null; assertTrue(command.onCommand(f.player, null, "animals", new String[0])); assertTrue(f.player.nextMessage().contains("not available")); Cooking.plugin = f.plugin; + command.onCommand(f.player, null, "animals", new String[]{"Neighbour"}); assertTrue(f.player.nextMessage().contains("only list your own")); + command.onCommand(console, null, "animals", new String[0]); verify(console).sendMessage(any(net.kyori.adventure.text.Component.class)); + f.player.setOp(true); command.onCommand(f.player, null, "animals", new String[]{"NeverJoined"}); assertTrue(f.player.nextMessage().contains("No player")); + Cow cow = f.spawn(Cow.class); HusbandryAnimal animal = f.record(cow); f.own(animal, f.player); cow.teleport(new Location(f.world, 9, 66, 8)); animal.setDirtySince(1L); + command.onCommand(f.player, null, "animals", new String[0]); assertEquals(9, animal.x()); assertNotNull(f.player.nextMessage()); clearMessages(); + HusbandryAnimal offline = f.ownedOffline("Offline"); command.onCommand(f.player, null, "animals", new String[]{f.player.getName()}); assertNotNull(f.player.nextMessage()); clearMessages(); + assertEquals(List.of("Farmer"), command.onTabComplete(f.player, null, "animals", new String[]{"f"})); assertEquals(List.of("Farmer", "Neighbour"), command.onTabComplete(f.player, null, "animals", new String[]{""})); + assertTrue(command.onTabComplete(f.neighbour, null, "animals", new String[]{""}).isEmpty()); assertTrue(command.onTabComplete(f.player, null, "animals", new String[0]).isEmpty()); + assertTrue(f.repository.exists(offline.uuid())); + } + + @Test void itemUsesHandleCreativeDurabilityBreakingStacksAndStoredLabels() { + assertFalse(HusbandryItems.matches(null, "feed")); assertFalse(HusbandryItems.matches(new ItemStack(Material.WHEAT), " ")); assertFalse(HusbandryItems.matches(new ItemStack(Material.WHEAT), null)); + HusbandryItems.useFromMainHand(null); HusbandryItems.consumeOne(null); ItemStack empty = new ItemStack(Material.AIR); HusbandryItems.consumeOne(empty); assertSame(empty, HusbandryItems.applyUse(empty)); assertNull(HusbandryItems.applyUse(null)); + hold(Material.SHEARS, 1); var tool = f.player.getInventory().getItemInMainHand(); var meta = (Damageable) tool.getItemMeta(); meta.setDamage(Material.SHEARS.getMaxDurability() - 1); tool.setItemMeta(meta); HusbandryItems.useFromMainHand(f.player); assertTrue(f.player.getInventory().getItemInMainHand().getType().isAir()); + hold(Material.WHEAT, 3); f.player.setGameMode(GameMode.CREATIVE); HusbandryItems.useFromMainHand(f.player); assertEquals(3, held()); f.player.setGameMode(GameMode.SPECTATOR); HusbandryItems.useFromMainHand(f.player); assertEquals(3, held()); + f.player.setGameMode(GameMode.SURVIVAL); HusbandryItems.useFromMainHand(f.player); assertEquals(2, held()); + ItemStack label = new ItemStack(Material.NAME_TAG); HusbandryItems.setTameName(label, " ??? "); assertNull(HusbandryItems.tameName(label)); HusbandryItems.setTameName(label, " Hazel "); assertEquals("Hazel", HusbandryItems.tameName(label)); + assertNull(HusbandryItems.applyUse(new ItemStack(Material.WHEAT)), "Using the last consumable must empty the hand"); + HusbandryItems.setTameName(null, "Hazel"); HusbandryItems.setTameName(label, null); HusbandryItems.setTameName(label, " "); HusbandryItems.setTameName(empty, "Hazel"); + assertNull(HusbandryItems.tameName(null)); assertNull(HusbandryItems.linkedAnimal(null)); assertNull(HusbandryItems.linkedAnimal(empty)); + HusbandryItems.setLinkedAnimal(null, "id", ""); HusbandryItems.setLinkedAnimal(label, null, ""); HusbandryItems.setLinkedAnimal(empty, "id", ""); + HusbandryItems.setLinkedAnimal(label, "first", " "); assertEquals("first", HusbandryItems.linkedAnimal(label)); HusbandryItems.setLinkedAnimal(label, "second", "Animal name"); assertEquals(List.of("Animal name"), label.getItemMeta().getLore()); + } + + @Test void weightedDropTablesRespectCountersMissingSpeciesMissesAndUnavailableTemplates() throws Exception { + products(); Random random = new Random(7); HusbandryAnimal animal = f.record(f.spawn(Cow.class)); animal.setCare(HusbandryConfig.careMax()); + assertFalse(HusbandryDropRoller.rollSlaughterExtras(animal, random, 0).isEmpty()); assertFalse(HusbandryDropRoller.rollShearDrops(animal, random, 0).isEmpty()); assertFalse(HusbandryDropRoller.rollShedDrops(animal, random).isEmpty()); + f.config.set("species.COW.shed.drops.mode", "single"); f.reload(); assertEquals(1, HusbandryDropRoller.rollShedDrops(animal, random).size()); + for (String type : List.of("PIG", "not-an-entity")) { + HusbandryAnimal missing = new HusbandryAnimal(UUID.randomUUID(), type, "Missing"); assertTrue(HusbandryDropRoller.rollSlaughterExtras(missing, random, 0).isEmpty()); assertTrue(HusbandryDropRoller.rollShearDrops(missing, random, 0).isEmpty()); assertTrue(HusbandryDropRoller.rollShedDrops(missing, random).isEmpty()); + } + assertTrue(HusbandryDropRoller.rollSlaughterExtras(null, random, 0).isEmpty()); assertTrue(HusbandryDropRoller.rollShearDrops(null, random, 0).isEmpty()); assertTrue(HusbandryDropRoller.rollShedDrops(null, random).isEmpty()); + var present = new HusbandryDropTable(List.of(new HusbandryDropEntry("wool", 2, 1)), null, null, null); assertEquals(2, HusbandryDropRoller.rollExtras(present, animal, random).orElseThrow().getAmount()); + var counted = new HusbandryDropTable(present.common(), null, null, null, true); assertEquals(1, HusbandryDropRoller.roll(counted, animal, random, 0, HusbandryDropRoller.CountMode.SINGLE).size()); + var miss = new HusbandryDropTable(List.of(new HusbandryDropEntry("", 1, 1)), null, null, null, true); assertTrue(HusbandryDropRoller.roll(miss, animal, random, 0, HusbandryDropRoller.CountMode.SINGLE).isEmpty()); assertTrue(HusbandryDropRoller.rollExtras(miss, animal, random).isEmpty()); + var locked = new HusbandryDropTable(List.of(), null, null, List.of(new HusbandryDropEntry("wool", 1, 1)), true); assertTrue(HusbandryDropRoller.roll(locked, animal, random, 0, HusbandryDropRoller.CountMode.SINGLE).isEmpty()); assertTrue(HusbandryDropRoller.rollExtras(locked, animal, random).isEmpty()); + assertTrue(HusbandryDropRoller.rollExtras(null, animal, random).isEmpty()); assertTrue(HusbandryDropRoller.rollExtras(present, null, random).isEmpty()); assertTrue(HusbandryDropRoller.rollExtras(present, animal, null).isEmpty()); assertTrue(HusbandryDropRoller.unlockedPool(null, 5).isEmpty()); + assertEquals(0, HusbandryDropRoller.hideCount(null, 0)); assertFalse(HusbandryDropRoller.woolBlocked(null, animal, 0)); assertNull(HusbandryDropRoller.pickEntry(null, random)); + when(TLibs.getItemAPI().getCreator().getItemFromPath("wool")).thenReturn(null); assertTrue(HusbandryDropRoller.roll(counted, animal, random, 0, HusbandryDropRoller.CountMode.SINGLE).isEmpty()); + HusbandryAnimal sheep = f.record(f.spawn(Sheep.class)); sheep.setWoolReadyAt(100L); assertTrue(HusbandryDropRoller.rollSlaughterExtras(sheep, random, 0).isEmpty()); + } + + @Test void largePositiveDropWeightsStillSelectAReward() { + var first = new HusbandryDropEntry("first", 1, Integer.MAX_VALUE); var second = new HusbandryDropEntry("second", 1, Integer.MAX_VALUE); + assertNotNull(HusbandryDropRoller.pickEntry(List.of(first, second), new Random(7)), "Positive weights must not overflow into an empty reward pool"); + } + + @Test void sheepHarvestReadinessTimersAndOutputCreationHandleSupportedAndMissingValues() throws Exception { + products(); HusbandryAnimal sheep = f.record(f.spawn(Sheep.class)); HusbandrySpecies species = HusbandryConfig.species(EntityType.SHEEP); + HusbandryHarvest.prepareNewAnimal(null, null); HusbandryHarvest.prepareNewAnimal(sheep, EntityType.SHEEP); assertNotNull(sheep.woolReadyAt()); assertNotNull(sheep.shedReadyAt()); + HusbandryAnimal chicken = f.record(f.spawn(Chicken.class)); HusbandryHarvest.prepareNewAnimal(chicken, EntityType.CHICKEN); assertNotNull(chicken.eggReadyAt()); + assertEquals(1, HusbandryHarvest.stars(null)); assertEquals(HusbandryConfig.starsForGenetics(sheep.genetics()), HusbandryHarvest.stars(sheep)); + assertEquals(HusbandryHarvest.ShearResult.COOLDOWN, HusbandryHarvest.shearReadiness(null, species, 100)); + sheep.setMatureAt(200L); assertEquals(HusbandryHarvest.ShearResult.IMMATURE, HusbandryHarvest.tryShear(f.player, null, sheep, species, f.repository, 100)); + sheep.setMatureAt(null); sheep.setWoolReadyAt(200L); assertEquals(HusbandryHarvest.ShearResult.COOLDOWN, HusbandryHarvest.tryShear(f.player, null, sheep, species, f.repository, 100)); + sheep.setWoolReadyAt(null); assertEquals(HusbandryHarvest.ShearResult.DONE, HusbandryHarvest.tryShear(null, null, sheep, species, null, 100)); assertTrue(sheep.woolReadyAt() > 100); + for (String path : new String[] {null, "", "vanilla"}) assertNull(HusbandryHarvest.buildTlibs(path, 2)); + assertEquals(64, HusbandryHarvest.buildTlibs("wool", 500).getAmount()); assertEquals(1, HusbandryHarvest.buildTlibs("wool", 0).getAmount()); assertFalse(HusbandryHarvest.isWoolDrop(null)); assertTrue(HusbandryHarvest.isWoolDrop(new ItemStack(Material.RED_WOOL))); + when(TLibs.getItemAPI().getCreator().getItemFromPath("missing")).thenReturn(null); assertNull(HusbandryHarvest.buildTlibs("missing", 1)); + when(TLibs.getItemAPI().getCreator().getItemFromPath("bad")).thenThrow(new IllegalArgumentException("Bad external template")); assertNull(HusbandryHarvest.buildTlibs("bad", 1)); + } + + @Test void professionExperienceHonorsAvailabilityConfiguredAmountsAndExternalFailures() throws Exception { + HusbandryProfessionXp.tryGive(null, EntityType.COW); HusbandryProfessionXp.tryGive(f.player, EntityType.COW); + MockBukkit.createMockPlugin("MMOCore"); HusbandryConfig.setProfessionExp("", HusbandryExpBracket.of(4, 4)); HusbandryProfessionXp.tryGive(f.player, EntityType.COW); + HusbandryConfig.setProfessionExp("farming", HusbandryExpBracket.of(0, 0)); HusbandryProfessionXp.tryGive(f.player, EntityType.COW); + MMOCore previous = MMOCore.plugin; MMOCore api = mock(MMOCore.class); ProfessionManager professions = mock(ProfessionManager.class); + // MMOCore exposes this final collaborator as a public field; adapt that external plugin boundary only. + var managerField = MMOCore.class.getField("professionManager"); managerField.setAccessible(true); managerField.set(api, professions); MMOCore.plugin = api; + try (MockedStatic players = mockStatic(PlayerData.class)) { + PlayerData data = mock(PlayerData.class, RETURNS_DEEP_STUBS); players.when(() -> PlayerData.get(f.player)).thenReturn(data); + HusbandryProfessionGrant.give(null, "farming", 3); HusbandryProfessionGrant.give(f.player, null, 3); HusbandryProfessionGrant.give(f.player, " ", 3); HusbandryProfessionGrant.give(f.player, "farming", 0); + HusbandryProfessionGrant.give(f.player, "absent", 3); verify(data, never()).getCollectionSkills(); + Profession profession = mock(Profession.class); when(professions.get("farming")).thenReturn(profession); HusbandryConfig.setProfessionExp("farming", HusbandryExpBracket.of(4, 4)); + HusbandryProfessionXp.tryGive(f.player, EntityType.COW); verify(data.getCollectionSkills()).giveExperience(profession, 4, EXPSource.SOURCE); + var collections = data.getCollectionSkills(); doThrow(new IllegalStateException("MMOCore unavailable during reload")).when(collections).giveExperience(profession, 4, EXPSource.SOURCE); + assertDoesNotThrow(() -> HusbandryProfessionXp.tryGive(f.player, EntityType.COW)); + } finally { MMOCore.plugin = previous; } + } + + @Test void publicModelsNormalizeMissingValuesAndGrowthReleasesAgeLocks() throws Exception { + HusbandryAnimal animal = new HusbandryAnimal(UUID.randomUUID(), "COW", "Cow"); UUID replacement = UUID.randomUUID(); animal.setUuid(replacement); assertEquals(replacement, animal.uuid()); + animal.setLastLocation("farm", 1, 2, 3); animal.setLastLocation(" ", 4, 5, 6); assertFalse(animal.hasLocation()); + assertEquals("owner", new HusbandryOwned(animal, " ").role()); assertEquals(HusbandryAnimalState.UNTAMED, HusbandryAnimalState.fromStorage(null)); assertEquals(HusbandryAnimalState.UNTAMED, HusbandryAnimalState.fromStorage("unknown")); + assertEquals("Big Cow", HusbandryRoster.speciesLabel("BIG__COW")); + var bands = List.of(new HusbandryQualityBand(0, 1), new HusbandryQualityBand(500, 3), new HusbandryQualityBand(1000, 5)); + var bounds = HusbandryQualityRange.of(500, 200, 100, bands); assertEquals(new HusbandryQualityRange.Bounds(3, 5), bounds); + assertEquals(1, HusbandryQualityRange.roll(null, null)); assertTrue(HusbandryQualityRange.roll(bounds, null) >= bounds.min()); + Cow cow = f.spawn(Cow.class); cow.setBaby(); cow.setAgeLock(true); HusbandryGrowth.applyMaturity(cow, animal, 100); assertTrue(cow.isAdult()); assertFalse(cow.getAgeLock()); + HusbandryGrowth.applyMaturity(null, animal, 100); HusbandryGrowth.applyMaturity(cow, null, 100); + Villager villager = mock(Villager.class); when(villager.isAdult()).thenReturn(false); HusbandryGrowth.applyMaturity(villager, animal, 100); verify(villager).setAdult(); + Ageable ageable = mock(Ageable.class); when(ageable.isAdult()).thenReturn(false); HusbandryGrowth.applyMaturity(ageable, animal, 100); verify(ageable).setAdult(); + LivingEntity nonAgeable = mock(LivingEntity.class); HusbandryGrowth.applyMaturity(nonAgeable, animal, 100); verifyNoInteractions(nonAgeable); + animal.setHungrySince(1L); var roster = HusbandryRoster.render("Farmer", true, List.of(new HusbandryOwned(animal, "owner")), 2, 100); + assertTrue(net.kyori.adventure.text.serializer.plain.PlainTextComponentSerializer.plainText().serialize(roster.getLast()).contains("Hungry")); + animal.setMatureAt(200L); cow.setAdult(); cow.setAgeLock(false); HusbandryGrowth.applyMaturity(cow, animal, 100); assertFalse(cow.isAdult()); assertTrue(cow.getAgeLock()); + assertTrue(HusbandryEntities.getLoaded(null).isEmpty()); + products(); assertTrue(HusbandryProducts.modeLines(HusbandryConfig.species(EntityType.COW)).contains("Shed")); assertTrue(HusbandryProducts.modeLines(null).isEmpty()); + f.config.set("remove-unowned", List.of("COW")); f.reload(); HusbandryClaimHint.remind(f.player, cow); clearMessages(); HusbandryClaimHint.remind(f.player, cow); assertNull(f.player.nextMessage()); + } + + @Test void feedingQualityExpiresOffersAndFallsBackWhenExternalMatchingFails() { + assertEquals(1, HusbandryFeedQuality.clampScale(Double.NaN)); assertEquals(0.5, HusbandryFeedQuality.combine(null, 0.5)); + HusbandryFeedQuality.offer(null, 0.5, 0); HusbandryFeedQuality.commitOffer(null, 0); HusbandryFeedQuality.remember(null, 0.5, 0); + UUID id = UUID.randomUUID(); HusbandryFeedQuality.offer(id, 0.4, 0); HusbandryFeedQuality.commitOffer(id, 3000); assertEquals(1, HusbandryFeedQuality.takePair(id, null, 3000)); + HusbandryFeedQuality.remember(id, 0.2, 0); assertEquals(1, HusbandryFeedQuality.takePair(id, null, 46_000)); + HusbandryFeedQuality.offer(id, 0.5, 0); HusbandryFeedQuality.discardOffer(id); HusbandryFeedQuality.commitOffer(id, 1); assertEquals(1, HusbandryFeedQuality.consumeBreedingScale(id, null, new ItemStack(Material.STONE), 1)); + UUID father = UUID.randomUUID(); HusbandryFeedQuality.remember(id, 0.4, 0); HusbandryFeedQuality.remember(father, 0.8, 0); assertEquals(0.6, HusbandryFeedQuality.consumeBreedingScale(id, father, null, 1), 0.0001); + assertEquals(1, HusbandryFeedQuality.scaleOf(null)); assertEquals(1, HusbandryFeedQuality.scaleOf(new ItemStack(Material.AIR))); + ItemStack quality = new ItemStack(Material.CARROT); var meta = quality.getItemMeta(); meta.getPersistentDataContainer().set(Keys.QUALITY, PersistentDataType.INTEGER, 2); quality.setItemMeta(meta); + when(TLibs.getItemAPI().getChecker().checkItemWithPath(quality, "feed")).thenThrow(new IllegalArgumentException("External matcher unavailable")); assertEquals(0.4, HusbandryFeedQuality.scaleOf(quality)); + ItemStack noMeta = spy(new ItemStack(Material.CARROT)); doReturn(false).when(noMeta).hasItemMeta(); assertEquals(1, HusbandryFeedQuality.scaleOf(noMeta)); + ItemStack absentMeta = mock(ItemStack.class); when(absentMeta.getType()).thenReturn(Material.CARROT); when(absentMeta.hasItemMeta()).thenReturn(true); assertEquals(1, HusbandryFeedQuality.scaleOf(absentMeta)); + } + + @Test void spawningClampsStatsPersistsTheRecordAndRemovesAnUnrecordableSpawn() { + assertNull(HusbandrySpawner.spawn(null, EntityType.COW, 1, 1)); assertNull(HusbandrySpawner.spawn(f.player, null, 1, 1)); assertNull(HusbandrySpawner.spawn(f.player, EntityType.UNKNOWN, 1, 1)); assertNull(HusbandrySpawner.spawn(f.player, EntityType.ITEM_DISPLAY, 1, 1)); + assertNull(HusbandrySpawner.createRecord(null, 1, 1)); Cow cow = f.spawn(Cow.class); Player player = mock(Player.class); World world = mock(World.class); + when(player.getWorld()).thenReturn(world); when(player.getLocation()).thenReturn(new Location(world, 1, 65, 1)); when(world.spawnEntity(any(Location.class), eq(EntityType.COW))).thenReturn(cow); + assertSame(cow, HusbandrySpawner.spawn(player, EntityType.COW, Integer.MAX_VALUE, Integer.MAX_VALUE)); + HusbandryAnimal saved = f.repository.getAnimal(cow.getUniqueId()).orElseThrow(); assertEquals(HusbandryConfig.maxGenetics(), saved.genetics()); assertEquals(HusbandryConfig.careMax(), saved.care()); assertEquals(saved.name(), cow.getCustomName()); assertFalse(cow.isCustomNameVisible()); + when(f.plugin.getHusbandryRepository()).thenReturn(null); assertNull(HusbandrySpawner.spawn(player, EntityType.COW, 1, 1)); assertFalse(cow.isValid()); assertNull(HusbandrySpawner.createRecord(cow, 1, 1)); + } + + @Test void harvestFoodKeepsMissingTemplatesSafeAndStampsTheConfiguredRoastPortion() { + HusbandryAnimal animal = f.record(f.spawn(Cow.class)); MemoryConfiguration config = new MemoryConfiguration(); config.set("name", "Roast"); config.set("update", false); FoodItem food = new FoodItem("roast", config); + FoodParser.Result parsed = new FoodParser.Result(); parsed.template = food; ItemStack stack = new ItemStack(Material.BEEF); + try (var parser = mockStatic(FoodParser.class); var builder = mockStatic(ItemBuilder.class); var codec = mockStatic(FoodItem.class, CALLS_REAL_METHODS); var sequences = mockStatic(CarveSequenceLoader.class); var updater = mockStatic(ItemUpdater.class)) { + assertNull(HusbandryHarvest.buildFood(null, "roast")); assertNull(HusbandryHarvest.buildFood(animal, null)); assertNull(HusbandryHarvest.buildFood(animal, " ")); assertNull(HusbandryHarvest.buildFood(animal, "roast")); + parser.when(() -> FoodParser.parse("roast")).thenReturn(new FoodParser.Result()); assertNull(HusbandryHarvest.buildFood(animal, "roast")); parser.when(() -> FoodParser.parse("roast")).thenReturn(parsed); + builder.when(() -> ItemBuilder.buildSingleWithQuality(eq(food), any(), anyInt())).thenReturn(stack); + assertSame(stack, HusbandryHarvest.buildFood(animal, "roast")); codec.when(() -> FoodItem.fromItem(stack)).thenReturn(food); assertSame(stack, HusbandryHarvest.buildFood(animal, "roast")); + food.setCarveState("roast_seq", 0, 3); assertSame(stack, HusbandryHarvest.buildFood(animal, "roast")); + CarveSequence sequence = new CarveSequence("roast_seq", 3, List.of(CarveCut.food("slice", 4, 4), CarveCut.food("slice", 4, 4), CarveCut.item("bone", 1, 0, 0))); + sequences.when(() -> CarveSequenceLoader.get("roast_seq")).thenReturn(sequence); updater.when(() -> ItemUpdater.applyItemUpdate(stack, food, null)).thenReturn(new ItemStack(Material.COOKED_BEEF)); + assertEquals(Material.COOKED_BEEF, HusbandryHarvest.buildFood(animal, "roast").getType()); assertEquals("roast_seq", stack.getItemMeta().getPersistentDataContainer().get(Keys.CARVE_SEQUENCE, PersistentDataType.STRING)); + updater.when(() -> ItemUpdater.applyItemUpdate(stack, food, null)).thenReturn(null); assertSame(stack, HusbandryHarvest.buildFood(animal, "roast")); + updater.when(() -> ItemUpdater.applyItemUpdate(stack, food, null)).thenReturn(new ItemStack(Material.AIR)); assertSame(stack, HusbandryHarvest.buildFood(animal, "roast"), "An AIR render cannot replace the successfully created roast"); + } + } + + @Test void shearingDropsRewardsWhenThePlayersInventoryIsFull() throws Exception { + products(); Sheep sheep = f.spawn(Sheep.class); HusbandryAnimal animal = f.record(sheep); + // MockBukkit's generic addItem scans every backing slot, including equipment/reserved slots. + for (int slot = 0; slot < f.player.getInventory().getSize(); slot++) f.player.getInventory().setItem(slot, new ItemStack(Material.STONE, 64)); + f.player.getInventory().setArmorContents(new ItemStack[]{new ItemStack(Material.IRON_BOOTS), new ItemStack(Material.IRON_LEGGINGS), new ItemStack(Material.IRON_CHESTPLATE), new ItemStack(Material.IRON_HELMET)}); + f.player.getInventory().setItemInOffHand(new ItemStack(Material.STONE, 64)); + assertEquals(-1, f.player.getInventory().firstEmpty(), () -> Arrays.toString(f.player.getInventory().getContents())); + assertEquals(HusbandryHarvest.ShearResult.DONE, HusbandryHarvest.tryShear(f.player, sheep, animal, HusbandryConfig.species(EntityType.SHEEP), f.repository, 100)); + int droppedWool = f.world.getEntities().stream().filter(Item.class::isInstance).map(Item.class::cast).map(Item::getItemStack).filter(stack -> stack.getType() == Material.WHITE_WOOL).mapToInt(ItemStack::getAmount).sum(); + assertEquals(2 * HusbandryConfig.woolFor(HusbandryConfig.effectiveGenetics(animal)), droppedWool, () -> "A full inventory must drop all earned wool instead of discarding it: " + Arrays.toString(f.player.getInventory().getContents())); + assertEquals(100 + HusbandryConfig.woolTimerSeconds(EntityType.SHEEP) * 1000L, f.repository.getAnimal(animal.uuid()).orElseThrow().woolReadyAt()); + } + + @Test void anAirExternalHarvestItemIsNotAReward() { + when(TLibs.getItemAPI().getCreator().getItemFromPath("air")).thenReturn(new ItemStack(Material.AIR)); assertNull(HusbandryHarvest.buildTlibs("air", 1)); + } + + @Test void repositoryRejectsIncompleteInputsAndPreservesValidRows() { + HusbandryRepository repository = f.repository; HusbandryAnimal good = f.offline("COW", "???"), invalid = new HusbandryAnimal(null, "COW", "No ID"); + repository.upsertAnimal(null); repository.upsertAnimal(invalid); repository.upsertAnimals(null); repository.upsertAnimals(List.of()); repository.upsertAnimals(Arrays.asList(null, invalid)); repository.upsertAnimals(Arrays.asList(null, good)); + assertEquals("", repository.getAnimal(good.uuid()).orElseThrow().name()); assertTrue(repository.getAnimal(null).isEmpty()); assertFalse(repository.exists(null)); repository.deleteAnimal(null); + repository.upsertOwner(null); repository.upsertOwner(new HusbandryOwner(null, null, null)); repository.deleteOwner(null, null); assertEquals(0, repository.countForPlayer(null)); assertTrue(repository.listOwners(null).isEmpty()); assertTrue(repository.listForPlayer(null).isEmpty()); + repository.upsertSnapshots(null, 0); repository.upsertSnapshots(Map.of(), 0); Map snapshots = new LinkedHashMap<>(); snapshots.put(null, new byte[]{1}); snapshots.put(good.uuid(), null); repository.upsertSnapshots(snapshots, 1); + assertTrue(repository.getSnapshot(good.uuid()).isEmpty()); assertTrue(repository.getSnapshot(null).isEmpty()); repository.deleteSnapshot(null); assertEquals(0, repository.resetStaleStats(" ", null)); + } + + @Test void legacySchemaMigrationAddsEveryGenerationOfFieldsWithoutLosingAnimals() throws Exception { + File file = directory.resolve("legacy.db").toFile(); HusbandryRepository created = HusbandryRepository.open(file); UUID id = UUID.randomUUID(); created.upsertAnimal(new HusbandryAnimal(id, "COW", "Legacy")); created.close(); + try (Connection db = DriverManager.getConnection("jdbc:sqlite:" + file)) { + for (String column : List.of("mature_at", "shed_ready_at", "egg_ready_at", "care_up_remainder", "care_down_remainder", "stats_revision", "world", "x", "y", "z")) db.createStatement().execute("ALTER TABLE animals DROP COLUMN " + column); + db.createStatement().execute("PRAGMA user_version=2"); + } + HusbandryRepository migrated = HusbandryRepository.open(file); + try { HusbandryAnimal restored = migrated.getAnimal(id).orElseThrow(); assertEquals("Legacy", restored.name()); assertNull(restored.matureAt()); assertNull(restored.statsRevision()); migrated.upsertAnimal(restored); assertEquals(1, migrated.resetStaleStats("new", new Random(3))); } + finally { migrated.close(); } + } + + @Test void repositoryWrapsDatabaseFailuresAndClosesFailedInitialization() throws Exception { + try (Connection db = DriverManager.getConnection("jdbc:sqlite:" + directory.resolve("husbandry.db"))) { + db.createStatement().execute("CREATE TRIGGER reject_animals BEFORE INSERT ON animals BEGIN SELECT RAISE(FAIL,'read only animal'); END"); + assertThrows(SqliteDatabaseException.class, () -> f.repository.upsertAnimal(new HusbandryAnimal(UUID.randomUUID(), "COW", "Rejected"))); + db.createStatement().execute("DROP TRIGGER reject_animals"); + HusbandryAnimal animal = f.offline("COW", "Snapshot"); db.createStatement().execute("CREATE TRIGGER reject_snapshots BEFORE INSERT ON snapshots BEGIN SELECT RAISE(FAIL,'read only snapshot'); END"); + assertThrows(SqliteDatabaseException.class, () -> f.repository.upsertSnapshots(Map.of(animal.uuid(), new byte[]{1}), 1)); + db.createStatement().execute("DROP TABLE snapshots"); assertThrows(SqliteDatabaseException.class, () -> f.repository.deleteSnapshot(animal.uuid())); assertThrows(SqliteDatabaseException.class, () -> f.repository.getSnapshot(animal.uuid())); + } + RuntimeException openFailure = new SqliteDatabaseException("Cannot initialize"), closeFailure = new SqliteDatabaseException("Cannot close"); + try (var construction = mockConstruction(SqliteDatabase.class, (db, context) -> { doThrow(openFailure).when(db).execute(anyString()); doThrow(closeFailure).when(db).close(); })) { + RuntimeException thrown = assertThrows(RuntimeException.class, () -> HusbandryRepository.open(directory.resolve("broken.db").toFile())); assertSame(openFailure, thrown); assertArrayEquals(new Throwable[]{closeFailure}, thrown.getSuppressed()); + } + try (var construction = mockConstruction(SqliteDatabase.class, (db, context) -> doThrow(openFailure).when(db).execute(anyString()))) { + assertThrows(RuntimeException.class, () -> HusbandryRepository.open(directory.resolve("uninitialized.db").toFile())); verify(construction.constructed().getFirst()).close(); + } + } + + @Test void legacyRowReadersTolerateUnavailableOptionalColumnsAndCheckpointFailures() throws Exception { + HusbandryAnimal animal = f.offline("COW", "Legacy"); + try (Connection db = DriverManager.getConnection("jdbc:sqlite:" + directory.resolve("husbandry.db"))) { + for (String column : List.of("world", "x", "care_up_remainder")) db.createStatement().execute("ALTER TABLE animals DROP COLUMN " + column); + HusbandryAnimal read = f.repository.getAnimal(animal.uuid()).orElseThrow(); assertNull(read.world()); assertNull(read.x()); assertEquals(0, read.careUpRemainderSeconds()); + } + HusbandryRepository closing = spy(HusbandryRepository.open(directory.resolve("closing.db").toFile())); + doThrow(new SqliteDatabaseException("Checkpoint unavailable")).when(closing).checkpointWal(true); closing.close(); + assertThrows(SqliteDatabaseException.class, () -> closing.exists(animal.uuid())); + } + + @Test void snapshotRestoreWaitsForEnabledPluginAndRecordsChunkLoadFailureForRetry() { + HusbandryAnimal animal = f.ownedOffline("Snapshot"); f.repository.upsertSnapshots(Map.of(animal.uuid(), new byte[]{1}), 1); + World world = mock(World.class); when(world.getName()).thenReturn("farm"); Entity copy = mock(Entity.class); when(copy.getUniqueId()).thenReturn(animal.uuid()); when(copy.getLocation()).thenAnswer(inv -> new Location(world, 0, 65, 0)); + UnsafeValues unsafe = mock(UnsafeValues.class); when(unsafe.deserializeEntity(any(byte[].class), eq(world), eq(true))).thenReturn(copy); + try (var bukkit = mockStatic(Bukkit.class, CALLS_REAL_METHODS)) { + bukkit.when(() -> Bukkit.getWorld("farm")).thenReturn(world); bukkit.when(Bukkit::getUnsafe).thenReturn(unsafe); + CompletableFuture stopped = new CompletableFuture<>(); when(world.getChunkAtAsync(anyInt(), anyInt())).thenReturn(stopped); + assertTrue(HusbandrySnapshots.restore(f.repository, animal, f.repository.listOwners(animal.uuid()))); when(f.plugin.isEnabled()).thenReturn(false); stopped.complete(mock(Chunk.class)); verify(copy, never()).spawnAt(any(), any()); + when(f.plugin.isEnabled()).thenReturn(true); CompletableFuture failed = new CompletableFuture<>(); when(world.getChunkAtAsync(anyInt(), anyInt())).thenReturn(failed); + assertTrue(HusbandrySnapshots.restore(f.repository, animal, f.repository.listOwners(animal.uuid()))); failed.completeExceptionally(new IOException("Chunk unavailable")); f.server.getScheduler().performTicks(1); + assertTrue(HusbandryLocator.isMissing(animal.uuid())); assertTrue(f.repository.getSnapshot(animal.uuid()).isPresent()); verify(copy, never()).spawnAt(any(), any()); + } + } + + @Test void savedEntityScanHandlesMissingUnreadableAndEmptyDirectories() throws Exception { + UUID target = UUID.randomUUID(); Path file = directory.resolve("not-a-directory"); Files.write(file, new byte[]{1}); + assertTrue(HusbandryEntityScan.scan(List.of(), Set.of()).complete()); + assertFalse(HusbandryEntityScan.scan(List.of(new HusbandryEntityScan.WorldDir("farm", file.toFile())), Set.of(target)).complete()); + assertFalse(HusbandryEntityScan.scan(List.of(), file.toFile(), Set.of(target)).complete()); + Path entities = Files.createDirectory(directory.resolve("entities")); Files.createDirectory(entities.resolve("r.0.0.mca")); + assertFalse(HusbandryEntityScan.scan(List.of(new HusbandryEntityScan.WorldDir("farm", entities.toFile())), Set.of(target)).complete()); + } + + @ParameterizedTest @ValueSource(strings = {"entity", "player"}) + void corruptNbtRootsMakeTheScanIncompleteInsteadOfDeclaringAnimalsMissing(String kind) throws Exception { + Path entities = Files.createDirectory(directory.resolve("entities")), players = Files.createDirectory(directory.resolve("players")); + if (kind.equals("entity")) region(entities, new byte[]{1}, 3); + else Files.write(players.resolve("owner.dat"), gzip(new byte[]{1})); + var result = HusbandryEntityScan.scan(List.of(new HusbandryEntityScan.WorldDir("farm", entities.toFile())), players.toFile(), Set.of(UUID.randomUUID())); + assertFalse(result.complete(), "Unreadable save data must not authorize removal of missing animal records"); + } + + @Test void savedEntityScanSkipsUnrelatedNbtTypesAndReadsLaterValidPositions() throws Exception { + UUID target = UUID.randomUUID(); Path entities = Files.createDirectory(directory.resolve("entities")); + byte[] data = nbt(out -> { + for (int type : new int[]{1, 2, 3, 4, 5, 6}) { out.writeByte(type); out.writeUTF("unrelated" + type); out.write(new byte[switch(type) { case 1 -> 1; case 2 -> 2; case 3, 5 -> 4; default -> 8; }]); } + out.writeByte(7); out.writeUTF("bytes"); out.writeInt(2); out.write(new byte[2]); + out.writeByte(8); out.writeUTF("name"); out.writeUTF("World"); + out.writeByte(9); out.writeUTF("list"); out.writeByte(2); out.writeInt(1); out.writeShort(2); + out.writeByte(10); out.writeUTF("compound"); out.writeByte(1); out.writeUTF("flag"); out.writeByte(1); out.writeByte(0); + out.writeByte(11); out.writeUTF("ints"); out.writeInt(1); out.writeInt(5); + out.writeByte(12); out.writeUTF("longs"); out.writeInt(1); out.writeLong(5); + out.writeByte(9); out.writeUTF("Entities"); out.writeByte(10); out.writeInt(1); + uuid(out, target); + out.writeByte(9); out.writeUTF("Pos"); out.writeByte(6); out.writeInt(3); out.writeDouble(-1.1); out.writeDouble(65); out.writeDouble(2.9); out.writeByte(0); + }); + region(entities, gzip(data), 1); var result = HusbandryEntityScan.scan(List.of(new HusbandryEntityScan.WorldDir("farm", entities.toFile())), Set.of(target)); + assertTrue(result.complete()); assertEquals(new HusbandryEntityScan.Found("farm", -2, 65, 2), result.found().get(target)); + region(entities, nbt(out -> { out.writeByte(9); out.writeUTF("Entities"); out.writeByte(3); out.writeInt(2); out.writeInt(1); out.writeInt(2); }), 3); + var wrongEntities = HusbandryEntityScan.scan(List.of(new HusbandryEntityScan.WorldDir("farm", entities.toFile())), Set.of(target)); + assertTrue(wrongEntities.found().isEmpty()); assertFalse(wrongEntities.complete(), "A nonempty Entities list of integers is corrupt data, not proof that animals are absent"); + region(entities, nbt(out -> { out.writeByte(9); out.writeUTF("Entities"); out.writeByte(0); out.writeInt(0); }), 3); + assertTrue(HusbandryEntityScan.scan(List.of(new HusbandryEntityScan.WorldDir("farm", entities.toFile())), Set.of(target)).complete(), "NBT permits an empty list to use the END element type"); + region(entities, nbt(out -> { out.writeByte(99); out.writeUTF("invalid"); }), 3); assertFalse(HusbandryEntityScan.scan(List.of(new HusbandryEntityScan.WorldDir("farm", entities.toFile())), Set.of(target)).complete()); + } + + @ParameterizedTest @ValueSource(strings = {"negative-list", "uuid-size", "uuid-type", "pos-size", "pos-type", "missing-uuid", "missing-pos", "nonfinite-pos"}) + void malformedEntityIdentityAndPositionCannotAuthorizeGhostDeletion(String problem) throws Exception { + UUID target = UUID.randomUUID(); Path entities = Files.createDirectory(directory.resolve("entities")); + region(entities, nbt(out -> { + out.writeByte(9); out.writeUTF("Entities"); out.writeByte(10); out.writeInt(problem.equals("negative-list") ? -1 : 1); + if (problem.equals("negative-list")) return; + if (problem.equals("uuid-size")) { out.writeByte(11); out.writeUTF("UUID"); out.writeInt(2); out.writeLong(1); } + else if (problem.equals("uuid-type")) { out.writeByte(8); out.writeUTF("UUID"); out.writeUTF(target.toString()); } + else if (!problem.equals("missing-uuid")) uuid(out, target); + if (!problem.equals("missing-pos")) { + out.writeByte(9); out.writeUTF("Pos"); out.writeByte(problem.equals("pos-type") ? 3 : 6); int length = problem.equals("pos-size") ? 2 : 3; out.writeInt(length); + for (int i = 0; i < length; i++) { if (problem.equals("pos-type")) out.writeInt(i); else out.writeDouble(problem.equals("nonfinite-pos") ? Double.NaN : i); } + } + out.writeByte(0); + }), 3); + assertFalse(HusbandryEntityScan.scan(List.of(new HusbandryEntityScan.WorldDir("farm", entities.toFile())), Set.of(target)).complete(), problem + " must leave animal records eligible for retry"); + } + + @ParameterizedTest @ValueSource(strings = {"Entities", "Passengers", "RootVehicle", "Entity"}) + void malformedEntityContainerTagsCannotAuthorizeGhostDeletion(String key) throws Exception { + UUID target = UUID.randomUUID(); Path entities = Files.createDirectory(directory.resolve("entities")), players = Files.createDirectory(directory.resolve("players")); + byte[] data = nbt(out -> { + if (key.equals("Passengers")) { + out.writeByte(9); out.writeUTF("Entities"); out.writeByte(10); out.writeInt(1); uuid(out, UUID.randomUUID()); + out.writeByte(9); out.writeUTF("Pos"); out.writeByte(6); out.writeInt(3); out.writeDouble(0); out.writeDouble(65); out.writeDouble(0); + } else if (key.equals("Entity")) { out.writeByte(10); out.writeUTF("RootVehicle"); } + out.writeByte(3); out.writeUTF(key); out.writeInt(1); + if (key.equals("Passengers") || key.equals("Entity")) out.writeByte(0); + }); + if (key.equals("RootVehicle") || key.equals("Entity")) Files.write(players.resolve("owner.dat"), gzip(data)); + else region(entities, data, 3); + var result = HusbandryEntityScan.scan(List.of(new HusbandryEntityScan.WorldDir("farm", entities.toFile())), players.toFile(), Set.of(target)); + assertFalse(result.complete(), "A malformed " + key + " tag cannot prove an animal is absent"); + assertFalse(result.found().containsKey(target)); assertFalse(result.ridden().contains(target)); + } + + @Test void savedEntityScanRejectsOversizedExternalAndDecompressedChunks() throws Exception { + Path entities = Files.createDirectory(directory.resolve("entities")); List worlds = List.of(new HusbandryEntityScan.WorldDir("farm", entities.toFile())); Set target = Set.of(UUID.randomUUID()); + region(entities, new byte[0], 0x83); + try (RandomAccessFile external = new RandomAccessFile(entities.resolve("c.0.0.mcc").toFile(), "rw")) { external.setLength(32L * 1024 * 1024 + 1); } + assertFalse(HusbandryEntityScan.scan(worlds, target).complete()); + ByteArrayOutputStream compressed = new ByteArrayOutputStream(); + try (GZIPOutputStream zip = new GZIPOutputStream(compressed)) { byte[] block = new byte[64 * 1024]; for (int i = 0; i < 512; i++) zip.write(block); zip.write(1); } + region(entities, compressed.toByteArray(), 1); assertFalse(HusbandryEntityScan.scan(worlds, target).complete()); + } + + interface NbtBody { void write(DataOutputStream out) throws IOException; } + byte[] nbt(NbtBody body) throws IOException { ByteArrayOutputStream bytes = new ByteArrayOutputStream(); try (DataOutputStream out = new DataOutputStream(bytes)) { out.writeByte(10); out.writeUTF(""); body.write(out); out.writeByte(0); } return bytes.toByteArray(); } + void uuid(DataOutputStream out, UUID id) throws IOException { out.writeByte(11); out.writeUTF("UUID"); out.writeInt(4); out.writeInt((int)(id.getMostSignificantBits() >>> 32)); out.writeInt((int)id.getMostSignificantBits()); out.writeInt((int)(id.getLeastSignificantBits() >>> 32)); out.writeInt((int)id.getLeastSignificantBits()); } + byte[] gzip(byte[] data) throws IOException { ByteArrayOutputStream bytes = new ByteArrayOutputStream(); try (GZIPOutputStream zip = new GZIPOutputStream(bytes)) { zip.write(data); } return bytes.toByteArray(); } + void region(Path entities, byte[] payload, int compression) throws IOException { + byte[] data = new byte[((8197 + payload.length + 4095) / 4096) * 4096]; java.nio.ByteBuffer bytes = java.nio.ByteBuffer.wrap(data); + bytes.putInt(0, (2 << 8) | 1); bytes.putInt(8192, payload.length + 1); data[8196] = (byte) compression; System.arraycopy(payload, 0, data, 8197, payload.length); Files.write(entities.resolve("r.0.0.mca"), data); + } +} diff --git a/src/test/java/net/tfminecraft/cooking/husbandry/HusbandryRuntimeCoverageTest.java b/src/test/java/net/tfminecraft/cooking/husbandry/HusbandryRuntimeCoverageTest.java new file mode 100644 index 0000000..91a2b7a --- /dev/null +++ b/src/test/java/net/tfminecraft/cooking/husbandry/HusbandryRuntimeCoverageTest.java @@ -0,0 +1,374 @@ +package net.tfminecraft.cooking.husbandry; + +import static org.junit.jupiter.api.Assertions.*; +import static org.mockito.ArgumentMatchers.*; +import static org.mockito.Mockito.*; + +import java.io.File; +import java.nio.file.Path; +import java.util.*; +import net.tfminecraft.cooking.Cooking; +import net.tfminecraft.tlibs.TLibs; +import net.tfminecraft.tlibs.database.SqliteDatabaseException; +import net.tfminecraft.tlibs.objects.api.ItemAPI; +import org.bukkit.*; +import org.bukkit.attribute.Attribute; +import org.bukkit.configuration.file.YamlConfiguration; +import org.bukkit.entity.*; +import org.bukkit.event.entity.EntityRemoveEvent; +import org.bukkit.event.world.EntitiesLoadEvent; +import org.bukkit.event.world.EntitiesUnloadEvent; +import org.bukkit.inventory.ItemStack; +import org.bukkit.plugin.Plugin; +import org.bukkit.scheduler.BukkitScheduler; +import org.junit.jupiter.api.*; +import org.junit.jupiter.api.io.TempDir; +import org.mockbukkit.mockbukkit.MockBukkit; +import org.mockbukkit.mockbukkit.ServerMock; +import org.mockbukkit.mockbukkit.entity.*; +import org.mockbukkit.mockbukkit.world.WorldMock; +import org.mockito.MockedStatic; +import io.papermc.paper.entity.EntitySerializationFlag; + +class HusbandryRuntimeCoverageTest { + @TempDir Path directory; + Farm f; + private final HusbandryLifecycleListener lifecycle = new HusbandryLifecycleListener(); + @BeforeEach void setUp() throws Exception { f = new Farm(directory); } + @AfterEach void tearDown() throws Exception { f.close(); } + + @Test void loadAndUnloadRestoreCareMaturityLocationAndSnapshots() { + Horse horse = f.spawn(Horse.class); horse.teleport(new Location(f.world, -3.4, 68, 14.2)); + HusbandryAnimal animal = f.record(horse); f.own(animal, f.player); HusbandryEntities.evict(animal.uuid()); + animal.setMatureAt(System.currentTimeMillis() - 1); animal.setUnloadedAt(System.currentTimeMillis() - 1000); f.repository.upsertAnimal(animal); + HusbandryLocator.markMissing(animal.uuid()); + lifecycle.onEntitiesLoad(new EntitiesLoadEvent(horse.getChunk(), List.of(horse, f.spawn(ItemDisplay.class)))); + HusbandryAnimal loaded = HusbandryEntities.getLoaded(animal.uuid()).orElseThrow(); + assertNull(loaded.unloadedAt()); assertNotNull(loaded.loadedVisitStart()); assertNull(loaded.matureAt()); + assertTrue(horse.isPersistent()); assertFalse(horse.getRemoveWhenFarAway()); assertTrue(HusbandryEntities.isManaged(horse)); + assertFalse(HusbandryLocator.isMissing(animal.uuid())); assertEquals(-4, loaded.x()); assertEquals(25, HusbandryMounts.maxHealth(horse)); + UnsafeValues unsafe = mock(UnsafeValues.class); + when(unsafe.serializeEntity(horse, EntitySerializationFlag.FORCE)).thenReturn(new byte[]{4, 2}); + try (MockedStatic bukkit = mockStatic(Bukkit.class, CALLS_REAL_METHODS)) { + bukkit.when(Bukkit::getUnsafe).thenReturn(unsafe); + lifecycle.onEntitiesUnload(new EntitiesUnloadEvent(horse.getChunk(), List.of(horse, f.spawn(ItemDisplay.class)))); + } + assertTrue(HusbandryEntities.getLoaded(animal.uuid()).isEmpty()); assertNotNull(f.repository.getAnimal(animal.uuid()).orElseThrow().unloadedAt()); + assertArrayEquals(new byte[]{4, 2}, f.repository.getSnapshot(animal.uuid()).orElseThrow().data()); + HusbandryLifecycleListener.handleLoad(horse); + HusbandryLifecycleListener.handleRemove(animal.uuid(), EntityRemoveEvent.Cause.UNLOAD); + HusbandryLifecycleListener.handleRemove(UUID.randomUUID(), EntityRemoveEvent.Cause.PLUGIN); + assertTrue(f.repository.getSnapshot(animal.uuid()).isPresent()); + lifecycle.onEntityRemove(new EntityRemoveEvent(horse, EntityRemoveEvent.Cause.PLUGIN)); + assertTrue(f.repository.getSnapshot(animal.uuid()).isEmpty()); + } + + @Test void wipePolicyRemovesUnownedAnimalsButKeepsOwnersAndEnrolledMounts() throws Exception { + f.config.set("remove-unowned", List.of("COW", "HORSE")); f.reload(); + Cow wild = f.spawn(Cow.class), tracked = f.spawn(Cow.class), owned = f.spawn(Cow.class); + HusbandryAnimal discarded = f.record(tracked), protectedAnimal = f.record(owned); f.own(protectedAnimal, f.player); + Horse mount = f.spawn(Horse.class), unknownMount = f.spawn(Horse.class); f.record(mount); + HusbandryLifecycleListener.handleLoad(wild); HusbandryLifecycleListener.handleLoad(tracked); HusbandryLifecycleListener.handleLoad(owned); + HusbandryLifecycleListener.handleLoad(mount); HusbandryLifecycleListener.handleLoad(unknownMount); + assertFalse(wild.isValid()); assertFalse(tracked.isValid()); assertFalse(f.repository.exists(discarded.uuid())); assertTrue(HusbandryEntities.getLoaded(discarded.uuid()).isEmpty()); + assertTrue(owned.isValid()); assertTrue(mount.isValid()); assertFalse(unknownMount.isValid()); + Cow unrelated = f.spawn(Cow.class); when(f.plugin.getHusbandryRepository()).thenReturn(null); HusbandryLifecycleListener.handleLoad(unrelated); assertTrue(unrelated.isValid()); + when(f.plugin.getHusbandryRepository()).thenReturn(f.repository); Pig unmanaged = f.spawn(Pig.class); HusbandryLifecycleListener.handleLoad(unmanaged); assertFalse(HusbandryEntities.isManaged(unmanaged)); + } + + @Test void resumeAndRevisionRefreshPersistedStatsAndEvictDeletedRows() { + Horse horse = f.spawn(Horse.class); HusbandryAnimal old = f.record(horse); old.setStatsRevision("old"); old.setCare(99); old.setGenetics(900); f.repository.upsertAnimal(old); + HusbandryAnimal absentEntity = f.offline("COW", "Missing entity"); absentEntity.setStatsRevision("old"); f.repository.upsertAnimal(absentEntity); HusbandryEntities.putLoaded(absentEntity); + HusbandryAnimal deleted = f.record(f.spawn(Cow.class)); f.repository.deleteAnimal(deleted.uuid()); f.spawn(ItemDisplay.class); + HusbandryLifecycleListener.applyStatsRevision(); + HusbandryAnimal fresh = HusbandryEntities.getLoaded(old.uuid()).orElseThrow(); assertNotSame(old, fresh); assertEquals(0, fresh.care()); assertEquals(0, fresh.genetics()); assertEquals(HusbandryConfig.statsRevision(), fresh.statsRevision()); + assertTrue(HusbandryEntities.getLoaded(deleted.uuid()).isEmpty()); assertEquals(25, HusbandryMounts.maxHealth(horse)); + HusbandryEntities.clearLoaded(); HusbandryLifecycleListener.applyStatsRevision(); HusbandryLifecycleListener.resumeLoadedWorlds(); assertTrue(HusbandryEntities.getLoaded(old.uuid()).isPresent()); + when(f.plugin.getHusbandryRepository()).thenReturn(null); HusbandryLifecycleListener.applyStatsRevision(); + } + + @Test void shutdownFlushSavesLoadedAndMissingEntitiesAndCapturesOnlyOwnedAnimals() { + Cow owned = f.spawn(Cow.class), wild = f.spawn(Cow.class), refused = f.spawn(Cow.class); + HusbandryAnimal first = f.record(owned), second = f.record(wild), third = f.record(refused), absent = f.offline("COW", "Offline"); f.own(first, f.player); f.own(third, f.player); HusbandryEntities.putLoaded(absent); + UnsafeValues unsafe = mock(UnsafeValues.class); when(unsafe.serializeEntity(owned)).thenReturn(new byte[]{8}); when(unsafe.serializeEntity(refused)).thenThrow(new IllegalArgumentException("not serializable")); + try (MockedStatic bukkit = mockStatic(Bukkit.class, CALLS_REAL_METHODS)) { + bukkit.when(Bukkit::getUnsafe).thenReturn(unsafe); HusbandryLifecycleListener.flushLoadedForDisable(); + } + assertTrue(HusbandryEntities.loadedIds().isEmpty()); + for (HusbandryAnimal animal : List.of(first, second, third, absent)) assertNotNull(f.repository.getAnimal(animal.uuid()).orElseThrow().unloadedAt()); + assertArrayEquals(new byte[]{8}, f.repository.getSnapshot(first.uuid()).orElseThrow().data()); assertTrue(f.repository.getSnapshot(second.uuid()).isEmpty()); assertTrue(f.repository.getSnapshot(third.uuid()).isEmpty()); + HusbandryLifecycleListener.flushLoadedForDisable(); HusbandryEntities.putLoaded(first); when(f.plugin.getHusbandryRepository()).thenReturn(null); HusbandryLifecycleListener.flushLoadedForDisable(); assertTrue(HusbandryEntities.loadedIds().isEmpty()); + } + + @Test void unloadHandlesUntrackedAnimalsMissingRepositoriesAndRefusedSnapshots() { + Cow cow = f.spawn(Cow.class); HusbandryLifecycleListener.handleUnload(cow); + HusbandryAnimal animal = f.record(cow); HusbandryEntities.evict(animal.uuid()); HusbandryLifecycleListener.handleUnload(cow); assertNotNull(f.repository.getAnimal(animal.uuid()).orElseThrow().unloadedAt()); + f.own(animal, f.player); HusbandryEntities.putLoaded(animal); + UnsafeValues unsafe = mock(UnsafeValues.class); when(unsafe.serializeEntity(cow, EntitySerializationFlag.FORCE)).thenThrow(new IllegalArgumentException("refused")); + try (MockedStatic bukkit = mockStatic(Bukkit.class, CALLS_REAL_METHODS)) { bukkit.when(Bukkit::getUnsafe).thenReturn(unsafe); HusbandryLifecycleListener.handleUnload(cow); } + assertTrue(f.repository.getSnapshot(animal.uuid()).isEmpty()); HusbandryEntities.putLoaded(animal); when(f.plugin.getHusbandryRepository()).thenReturn(null); + HusbandryLifecycleListener.handleRemove(animal.uuid(), EntityRemoveEvent.Cause.PLUGIN); HusbandryLifecycleListener.handleUnload(cow); assertTrue(HusbandryEntities.getLoaded(animal.uuid()).isEmpty()); + } + + @Test void lifecycleLogsDatabaseFailuresWithoutLeavingLoadedShutdownState() { + Cow cow = f.spawn(Cow.class); HusbandryAnimal animal = f.record(cow); + doThrow(new SqliteDatabaseException("snapshot unavailable")).when(f.repository).deleteSnapshot(animal.uuid()); + assertDoesNotThrow(() -> HusbandryLifecycleListener.handleRemove(animal.uuid(), EntityRemoveEvent.Cause.PLUGIN)); + doThrow(new SqliteDatabaseException("reset unavailable")).when(f.repository).resetStaleStats(anyString(), any()); + assertDoesNotThrow(HusbandryLifecycleListener::applyStatsRevision); assertSame(animal, HusbandryEntities.getLoaded(animal.uuid()).orElseThrow()); + doThrow(new SqliteDatabaseException("flush unavailable")).when(f.repository).upsertAnimals(anyList()); + HusbandryLifecycleListener.flushLoadedForDisable(); assertTrue(HusbandryEntities.loadedIds().isEmpty()); + } + + @Test void scanUpdatesLocationsSkipsRidersAndDeletesConfirmedGhosts() { + HusbandryAnimal moved = f.ownedOffline("Moved"), same = f.ownedOffline("Same"), rider = f.ownedOffline("Rider"), ghost = f.ownedOffline("Ghost"); + same.setLastLocation("farm", 1, 2, 3); f.repository.upsertAnimal(same); HusbandryLocator.markMissing(rider.uuid()); + WorldMock nether = f.addWorld("nether"); nether.setEnvironment(World.Environment.NETHER); + WorldMock end = f.addWorld("end"); end.setEnvironment(World.Environment.THE_END); + var result = new HusbandryEntityScan.Result(Map.of(moved.uuid(), new HusbandryEntityScan.Found("nether", 9, 8, 7), same.uuid(), new HusbandryEntityScan.Found("farm", 1, 2, 3)), Set.of(rider.uuid()), true); + try (Scan scan = new Scan(result)) { + HusbandryLocator.scanUnloaded(); scan.background(); + assertEquals(List.of(new File(f.world.getWorldFolder(), "entities"), new File(nether.getWorldFolder(), "DIM-1/entities"), new File(end.getWorldFolder(), "DIM1/entities")), scan.worlds.stream().map(HusbandryEntityScan.WorldDir::entities).toList()); + scan.apply(); + } + HusbandryAnimal saved = f.repository.getAnimal(moved.uuid()).orElseThrow(); assertEquals("nether", saved.world()); assertEquals(9, saved.x()); assertEquals(8, saved.y()); assertEquals(7, saved.z()); + assertTrue(f.repository.exists(rider.uuid())); assertFalse(HusbandryLocator.isMissing(rider.uuid())); assertFalse(f.repository.exists(ghost.uuid())); assertTrue(f.repository.listOwners(ghost.uuid()).isEmpty()); + assertEquals(1, f.repository.getAnimal(same.uuid()).orElseThrow().x()); + } + + @Test void scanDoesNotOverwriteNewerLoadsMovesDeletesOrUnscannedWorlds() { + HusbandryAnimal loaded = f.ownedOffline("Loaded"), changed = f.ownedOffline("Changed"), deleted = f.ownedOffline("Deleted"), foreign = f.ownedOffline("Foreign"); + foreign.setLastLocation("unscanned", 1, 2, 3); f.repository.upsertAnimal(foreign); + try (Scan scan = new Scan(new HusbandryEntityScan.Result(Map.of(), Set.of(), true))) { + HusbandryLocator.scanUnloaded(); scan.background(); + HusbandryEntities.putLoaded(loaded); changed.setUnloadedAt(999L); f.repository.upsertAnimal(changed); f.repository.deleteAnimal(deleted.uuid()); scan.apply(); + } + assertTrue(f.repository.exists(loaded.uuid())); assertEquals(999L, f.repository.getAnimal(changed.uuid()).orElseThrow().unloadedAt()); assertTrue(f.repository.exists(foreign.uuid())); + HusbandryEntities.clearLoaded(); + try (Scan scan = new Scan(new HusbandryEntityScan.Result(Map.of(), Set.of(), false))) { HusbandryLocator.scanUnloaded(); scan.background(); scan.apply(); } + assertTrue(f.repository.exists(loaded.uuid())); assertTrue(f.repository.exists(changed.uuid())); + } + + @Test void scanWaitsForSnapshotRestorationAndKeepsFailedRecordsForRetry() { + HusbandryAnimal restoring = f.ownedOffline("Restoring"), failedRead = f.ownedOffline("Failed read"), failedDrop = f.ownedOffline("Failed delete"), failedMove = f.ownedOffline("Failed write"); + doThrow(new SqliteDatabaseException("read failure")).when(f.repository).getSnapshot(failedRead.uuid()); + doThrow(new SqliteDatabaseException("delete failure")).when(f.repository).deleteAnimal(failedDrop.uuid()); + doThrow(new SqliteDatabaseException("write failure")).when(f.repository).upsertAnimal(argThat(a -> a != null && a.uuid().equals(failedMove.uuid()))); + try (MockedStatic snapshots = mockStatic(HusbandrySnapshots.class, CALLS_REAL_METHODS); + Scan scan = new Scan(new HusbandryEntityScan.Result(Map.of(failedMove.uuid(), new HusbandryEntityScan.Found("farm", 3, 4, 5)), Set.of(), true))) { + snapshots.when(() -> HusbandrySnapshots.restore(eq(f.repository), argThat(a -> a != null && a.uuid().equals(restoring.uuid())), anyList())).thenReturn(true); + HusbandryLocator.scanUnloaded(); scan.background(); scan.apply(); + } + assertTrue(f.repository.exists(restoring.uuid())); assertFalse(HusbandryLocator.isMissing(restoring.uuid())); assertTrue(HusbandryLocator.isMissing(failedRead.uuid())); assertTrue(HusbandryLocator.isMissing(failedDrop.uuid())); + assertEquals(0, f.repository.getAnimal(failedMove.uuid()).orElseThrow().x()); + } + + @Test void scanningStopsAcrossRepositoryAndPluginShutdownBoundaries() { + try (Scan scan = new Scan(new HusbandryEntityScan.Result(Map.of(), Set.of(), true))) { + HusbandryLocator.scanUnloaded(); assertTrue(scan.async.isEmpty()); HusbandryAnimal owned = f.ownedOffline("Pending"); HusbandryEntities.putLoaded(owned); HusbandryLocator.scanUnloaded(); assertTrue(scan.async.isEmpty()); HusbandryEntities.clearLoaded(); + when(f.plugin.getHusbandryRepository()).thenReturn(null); HusbandryLocator.scanUnloaded(); assertTrue(scan.async.isEmpty()); when(f.plugin.getHusbandryRepository()).thenReturn(f.repository); + HusbandryLocator.scanUnloaded(); when(f.plugin.isEnabled()).thenReturn(false); scan.background(); assertTrue(scan.sync.isEmpty()); + when(f.plugin.isEnabled()).thenReturn(true); HusbandryLocator.scanUnloaded(); Cooking.plugin = null; scan.background(); assertTrue(scan.sync.isEmpty()); Cooking.plugin = f.plugin; + HusbandryLocator.scanUnloaded(); scan.background(); when(f.plugin.getHusbandryRepository()).thenReturn(null); scan.apply(); assertTrue(f.repository.exists(owned.uuid())); + } + HusbandryLocator.markMissing(null); HusbandryLocator.markFound(null); assertFalse(HusbandryLocator.isMissing(null)); + assertTrue(HusbandryLocator.isConfirmedGhost(true, null, List.of("farm"))); assertTrue(HusbandryLocator.isConfirmedGhost(true, " ", List.of("farm"))); assertFalse(HusbandryLocator.isConfirmedGhost(true, "farm", null)); + HusbandryAnimal unnamed = new HusbandryAnimal(UUID.randomUUID(), "", ""); assertTrue(HusbandryLocator.describe(unnamed, null).startsWith("(unnamed) (unknown)")); assertTrue(HusbandryLocator.ghostLog(unnamed, List.of()).endsWith("last seen unknown")); + } + + @Test void simulationTracksVisitThresholdsAndResetsOnlyFullyClearedAfflictions() { + HusbandryAnimal animal = f.offline("COW", "Simulation"); animal.setLoadedVisitStart(null); assertFalse(HusbandrySimulator.visitLongEnough(null, 0)); assertFalse(HusbandrySimulator.visitLongEnough(animal, 0)); animal.setLoadedVisitStart(1000L); + assertFalse(HusbandrySimulator.visitLongEnough(animal, 1000)); assertTrue(HusbandrySimulator.visitLongEnough(animal, 61_000)); assertFalse(HusbandrySimulator.isHappy(null)); + Random random = mock(Random.class); when(random.nextDouble()).thenReturn(0.5); animal.setAfflictionElapsed(2); animal.setHungrySince(1L); animal.setDirtySince(1L); + HusbandrySimulator.clearHungry(animal, random); assertEquals(2, animal.afflictionElapsed()); HusbandrySimulator.clearDirty(animal, random); assertEquals(0, animal.afflictionElapsed()); assertEquals(6, animal.afflictionAt()); + animal.setHungrySince(1L); HusbandrySimulator.clearHungry(animal, random); assertTrue(HusbandrySimulator.isHappy(animal)); animal.setHungrySince(1L); animal.setDirtySince(1L); HusbandrySimulator.clearDirty(animal, random); assertFalse(HusbandrySimulator.isHappy(animal)); + HusbandrySimulator.clearHungry(null, random); HusbandrySimulator.clearDirty(null, random); HusbandrySimulator.resetAfflictionCycle(null, random); HusbandrySimulator.catchUp(null, 0, null); + animal.setLastProcessedAt(100); HusbandrySimulator.tickLoaded(animal, 50); assertEquals(50, animal.lastProcessedAt()); HusbandrySimulator.tickLoaded(animal, 50, null); assertEquals(50, animal.lastProcessedAt()); + } + + @Test void unloadedCareIsCappedAndLongAbsencesApplyEachMissingAffliction() throws Exception { + f.config.set("offline-care", "2h"); f.config.set("long-unload-force", "3h"); f.config.set("affliction-min-hours", 4); f.config.set("affliction-max-hours", 4); f.reload(); + for (boolean firstHungry : List.of(true, false)) { + Random random = mock(Random.class); when(random.nextBoolean()).thenReturn(firstHungry); + HusbandryAnimal animal = simulationAnimal(); animal.setUnloadedAt(0L); + HusbandrySimulator.catchUp(animal, 4 * 3_600_000L, random); assertEquals(2, animal.care()); assertEquals(firstHungry, animal.hungrySince() != null); assertEquals(!firstHungry, animal.dirtySince() != null); assertEquals(4, animal.afflictionAt()); + animal.setUnloadedAt(animal.lastProcessedAt()); HusbandrySimulator.catchUp(animal, 8 * 3_600_000L, random); assertNotNull(animal.hungrySince()); assertNotNull(animal.dirtySince()); + animal.setUnloadedAt(animal.lastProcessedAt()); HusbandrySimulator.catchUp(animal, 12 * 3_600_000L, random); assertEquals(0, animal.afflictionElapsed()); + } + HusbandryAnimal split = simulationAnimal(); split.setUnloadedAt(3_600_000L); HusbandrySimulator.catchUp(split, 2 * 3_600_000L, new Random(1)); assertEquals(2, split.care()); + } + + @Test void loadedAfflictionCyclesChooseEitherFirstStateAndEventuallyBoth() throws Exception { + f.config.set("affliction-min-hours", 1); f.config.set("affliction-max-hours", 1); f.reload(); + for (boolean hungryFirst : List.of(true, false)) { + HusbandryAnimal animal = simulationAnimal(); animal.setAfflictionAt(0); Random random = mock(Random.class); when(random.nextBoolean()).thenReturn(hungryFirst); + HusbandrySimulator.tickLoaded(animal, 3_600_000, random); assertEquals(hungryFirst, animal.hungrySince() != null); assertEquals(!hungryFirst, animal.dirtySince() != null); + HusbandrySimulator.tickLoaded(animal, 2 * 3_600_000, random); assertNotNull(animal.hungrySince()); assertNotNull(animal.dirtySince()); assertEquals(0, animal.afflictionElapsed()); + } + } + + @Test void careDecayRespectsGraceAccumulatesRemaindersAndClampsAtBothEnds() throws Exception { + f.config.set("decay-grace", "1h"); f.reload(); + HusbandryAnimal animal = simulationAnimal(); animal.setCare(3); animal.setDirtySince(0L); + HusbandrySimulator.tickLoaded(animal, 3_600_000, new Random(1)); assertEquals(3, animal.care()); + HusbandrySimulator.tickLoaded(animal, 3_600_000 + 30_000, new Random(1)); assertEquals(30, animal.careDownRemainderSeconds()); + HusbandrySimulator.tickLoaded(animal, 2 * 3_600_000, new Random(1)); assertEquals(2, animal.care()); assertEquals(0, animal.careDownRemainderSeconds()); + animal.setHungrySince(0L); HusbandrySimulator.tickLoaded(animal, 10 * 3_600_000, new Random(1)); assertEquals(0, animal.care()); + HusbandryAnimal healthy = simulationAnimal(); healthy.setCare(100); HusbandrySimulator.tickLoaded(healthy, 3_600_000, new Random(1)); assertEquals(100, healthy.care()); + f.config.set("care-up-per-hour", 0); f.reload(); healthy.setCare(5); HusbandrySimulator.tickLoaded(healthy, 3_600_001, new Random(1)); HusbandrySimulator.tickLoaded(healthy, 2 * 3_600_000, new Random(1)); assertEquals(5, healthy.care()); + } + + @Test void mountEnrollmentAndScalingPreserveExistingRecordsAndRespectAttributeSupport() { + Horse horse = f.spawn(Horse.class); assertNull(HusbandryMounts.enrollIfNeeded(null)); assertNull(HusbandryMounts.enrollIfNeeded(f.spawn(Cow.class))); + when(f.plugin.getHusbandryRepository()).thenReturn(null); assertNull(HusbandryMounts.enrollIfNeeded(horse)); when(f.plugin.getHusbandryRepository()).thenReturn(f.repository); + HusbandryAnimal animal = HusbandryMounts.enrollIfNeeded(horse); assertNotNull(animal); assertSame(animal, HusbandryMounts.enrollIfNeeded(horse)); HusbandryEntities.evict(animal.uuid()); assertEquals(animal.uuid(), HusbandryMounts.enrollIfNeeded(horse).uuid()); + animal.setGenetics(1000); animal.setCare(100); HusbandryMounts.applyStats(horse, animal); assertEquals(25, HusbandryMounts.maxHealth(horse)); assertEquals(0.2, HusbandryMounts.speed(horse)); assertEquals(0.6, HusbandryMounts.jump(horse)); + assertEquals(12.5, HusbandryMounts.healthHearts(horse)); assertEquals(8.432, HusbandryMounts.speedBlocksPerSecond(horse), 1e-9); assertTrue(HusbandryMounts.jumpBlockHeight(horse) > 2); + HusbandryMounts.setSpeed(horse, 0.2); HusbandryMounts.setMaxHealth(horse, 5); assertEquals(5, horse.getHealth()); + LivingEntity unsupported = mock(LivingEntity.class); HusbandryMounts.setMaxHealth(unsupported, 5); HusbandryMounts.setSpeed(unsupported, 0.5); assertEquals(-1, HusbandryMounts.maxHealth(unsupported)); assertEquals(-1, HusbandryMounts.speed(unsupported)); + Cow cow = f.spawn(Cow.class); HusbandryMounts.setJump(cow, 1); assertEquals(-1, HusbandryMounts.jump(cow)); assertEquals(-1, HusbandryMounts.jumpBlockHeight(cow)); + HusbandryMounts.applyStats(null, animal); HusbandryMounts.applyStats(horse, null); HusbandryMounts.applyStats(cow, animal); HusbandryMounts.applyStats(f.spawn(Donkey.class), animal); + assertEquals(-1, HusbandryMounts.speedFor(null, EntityType.HORSE)); assertEquals(-1, HusbandryMounts.healthFor(animal, null)); assertEquals(-1, HusbandryMounts.jumpFor(animal, EntityType.COW)); + assertEquals(0, HusbandryMounts.mountFraction(-1, -1, 0, 0, -1, -1, -1)); assertEquals(0.9, HusbandryMounts.mountFraction(2000, 200, 1000, 100, 0.4, 0.3, 0.2), 1e-9); + assertEquals(0.45, HusbandryMounts.speedFor(0.5, 1000, 100, 1000, 100, 0.4, 0.3, 0.2), 1e-9); assertFalse(HusbandryMounts.hasConfiguredStats(null)); assertFalse(HusbandryMounts.isMount(cow)); + } + + @Test void periodicTickPersistsCareAndSnapshotsThenStopsCleanly() { + Horse horse = f.spawn(Horse.class); HusbandryAnimal animal = f.record(horse); f.own(animal, f.player); animal.setLastProcessedAt(System.currentTimeMillis() - 3_600_000); animal.setLoadedVisitStart(1L); + Cow recent = f.spawn(Cow.class); HusbandryAnimal notVisited = f.record(recent); notVisited.setLoadedVisitStart(System.currentTimeMillis()); long unchanged = notVisited.lastProcessedAt(); + Cow refused = f.spawn(Cow.class); HusbandryAnimal cannotCapture = f.record(refused); f.own(cannotCapture, f.player); + HusbandryAnimal gone = f.offline("COW", "Gone"); HusbandryEntities.putLoaded(gone); + ItemDisplay display = f.spawn(ItemDisplay.class); HusbandryAnimal nonLiving = new HusbandryAnimal(display.getUniqueId(), "ITEM_DISPLAY", "Display"); HusbandryEntities.putLoaded(nonLiving); + UnsafeValues unsafe = mock(UnsafeValues.class); when(unsafe.serializeEntity(horse)).thenReturn(new byte[]{3}); when(unsafe.serializeEntity(refused)).thenThrow(new IllegalArgumentException("unavailable")); + try (MockedStatic bukkit = mockStatic(Bukkit.class, CALLS_REAL_METHODS)) { + bukkit.when(Bukkit::getUnsafe).thenReturn(unsafe); HusbandryTickTask.start(); HusbandryTickTask.start(); f.server.getScheduler().performTicks(1200); + assertEquals(1, f.repository.getAnimal(animal.uuid()).orElseThrow().care()); assertEquals(unchanged, notVisited.lastProcessedAt()); assertArrayEquals(new byte[]{3}, f.repository.getSnapshot(animal.uuid()).orElseThrow().data()); assertTrue(f.repository.getSnapshot(cannotCapture.uuid()).isEmpty()); + assertTrue(HusbandryEntities.getLoaded(gone.uuid()).isEmpty()); assertTrue(HusbandryEntities.getLoaded(nonLiving.uuid()).isEmpty()); + doThrow(new SqliteDatabaseException("tick write failure")).when(f.repository).upsertAnimals(anyList()); + doThrow(new SqliteDatabaseException("snapshot write failure")).when(f.repository).upsertSnapshots(anyMap(), anyLong()); + assertDoesNotThrow(() -> f.server.getScheduler().performTicks(1200)); + when(f.plugin.getHusbandryRepository()).thenReturn(null); f.server.getScheduler().performTicks(1200); when(f.plugin.getHusbandryRepository()).thenReturn(f.repository); + HusbandryEntities.clearLoaded(); f.server.getScheduler().performTicks(1200); HusbandryTickTask.stop(); HusbandryTickTask.stop(); + Cooking.plugin = null; HusbandryTickTask.start(); Cooking.plugin = f.plugin; + } + } + + @Test void locationMemoryFloorsCoordinatesAndIgnoresUnavailableEntities() { + Cow cow = f.spawn(Cow.class); HusbandryAnimal animal = f.record(cow); cow.teleport(new Location(f.world, -1.2, 63.9, 2.7)); HusbandryLocation.remember(animal, cow); + assertEquals("farm", animal.world()); assertEquals(-2, animal.x()); assertEquals(63, animal.y()); assertEquals(2, animal.z()); + HusbandryLocation.remember(null, cow); HusbandryLocation.remember(animal, null); HusbandryLocation.remember(animal, mock(Entity.class)); assertEquals(-2, animal.x()); + HusbandryEntities.applyPersistFlags(null); HusbandryEntities.stampManaged(null); HusbandryEntities.putLoaded(null); assertFalse(HusbandryEntities.isManaged(null)); assertEquals("Animal", HusbandryEntities.displayName(null)); assertEquals("Skeleton Horse", HusbandryEntities.displayName(EntityType.SKELETON_HORSE)); + HusbandryEntities.untrack(animal.uuid()); assertTrue(HusbandryEntities.lookup(animal.uuid()).isPresent()); Cooking.plugin = null; assertNull(HusbandryEntities.repository()); assertTrue(HusbandryEntities.lookup(UUID.randomUUID()).isEmpty()); Cooking.plugin = f.plugin; + } + + @Test void naturalProductsWaitForMaturityHappinessAndTheirOwnTimers() throws Exception { + f.config.set("species.CHICKEN.egg", "vanilla"); f.config.set("egg-timer", "20s"); f.config.set("species.SHEEP.shed.drops.common", List.of(Map.of("path", "wool"))); f.config.set("shed-timer", "30s"); f.config.set("shed-chance", 1); f.reload(); + assertTrue(HusbandryEggs.isVanillaEgg(Material.EGG)); assertTrue(HusbandryEggs.isVanillaEgg(Material.BLUE_EGG)); assertTrue(HusbandryEggs.isVanillaEgg(Material.BROWN_EGG)); assertFalse(HusbandryEggs.isVanillaEgg(Material.CHICKEN)); + Chicken chicken = f.spawn(Chicken.class); HusbandryAnimal hen = f.record(chicken); Sheep sheep = f.spawn(Sheep.class); HusbandryAnimal wool = f.record(sheep); long now = 1_000_000L; + HusbandryEggs.tryLay(null, hen, now); HusbandryEggs.tryLay(chicken, null, now); HusbandryShed.tryShed(null, wool, now); HusbandryShed.tryShed(sheep, null, now); + hen.setMatureAt(now + 1); wool.setMatureAt(now + 1); HusbandryEggs.tryLay(chicken, hen, now); HusbandryShed.tryShed(sheep, wool, now); assertNull(hen.eggReadyAt()); assertNull(wool.shedReadyAt()); + hen.setMatureAt(null); wool.setMatureAt(null); hen.setHungrySince(1L); wool.setDirtySince(1L); HusbandryEggs.tryLay(chicken, hen, now); HusbandryShed.tryShed(sheep, wool, now); assertNull(hen.eggReadyAt()); assertNull(wool.shedReadyAt()); hen.setHungrySince(null); wool.setDirtySince(null); + hen.setType("NOT_A_MINECRAFT_MOB"); wool.setType("NOT_A_MINECRAFT_MOB"); HusbandryEggs.tryLay(chicken, hen, now); HusbandryShed.tryShed(sheep, wool, now); hen.setType("PIG"); wool.setType("PIG"); HusbandryEggs.tryLay(chicken, hen, now); HusbandryShed.tryShed(sheep, wool, now); hen.setType("COW"); wool.setType("COW"); HusbandryEggs.tryLay(chicken, hen, now); HusbandryShed.tryShed(sheep, wool, now); hen.setType("CHICKEN"); wool.setType("SHEEP"); + HusbandryEggs.tryLay(chicken, hen, now); assertEquals(now + 20_000, hen.eggReadyAt()); assertEquals(1, itemCount(Material.EGG)); HusbandryEggs.tryLay(chicken, hen, now + 1); assertEquals(1, itemCount(Material.EGG)); + try (MockedStatic drops = mockStatic(HusbandryDropRoller.class)) { + drops.when(() -> HusbandryDropRoller.rollShedDrops(eq(wool), any())).thenReturn(Arrays.asList(new ItemStack(Material.WHITE_WOOL, 2), null)); + HusbandryShed.tryShed(sheep, wool, now); assertEquals(now + 30_000, wool.shedReadyAt()); assertEquals(2, itemCount(Material.WHITE_WOOL)); HusbandryShed.tryShed(sheep, wool, now + 1); assertEquals(2, itemCount(Material.WHITE_WOOL)); + f.config.set("shed-chance", 0); f.reload(); HusbandryShed.tryShed(sheep, wool, now + 30_000); assertEquals(now + 60_000, wool.shedReadyAt()); assertEquals(2, itemCount(Material.WHITE_WOOL)); + } + try (MockedStatic harvest = mockStatic(HusbandryHarvest.class, CALLS_REAL_METHODS)) { + harvest.when(() -> HusbandryHarvest.buildFood(hen, "food(egg)")).thenReturn(new ItemStack(Material.BROWN_EGG)); + f.config.set("species.CHICKEN.egg", "food(egg)"); f.reload(); HusbandryEggs.tryLay(chicken, hen, now + 20_000); assertEquals(1, itemCount(Material.BROWN_EGG)); + harvest.when(() -> HusbandryHarvest.buildTlibs("custom_egg", 1)).thenReturn(null); f.config.set("species.CHICKEN.egg", "custom_egg"); f.reload(); HusbandryEggs.tryLay(chicken, hen, now + 40_000); assertEquals(now + 60_000, hen.eggReadyAt()); + } + } + + private int itemCount(Material material) { return f.world.getEntitiesByClass(Item.class).stream().map(Item::getItemStack).filter(item -> item.getType() == material).mapToInt(ItemStack::getAmount).sum(); } + + static HusbandryAnimal simulationAnimal() { HusbandryAnimal animal = new HusbandryAnimal(UUID.randomUUID(), "COW", "Simulated"); animal.setLastProcessedAt(0); animal.setAfflictionAt(100); return animal; } + + /** Real Bukkit values and SQLite; only the external item API and unimplemented despawn flag are adapters. */ + static final class Farm implements AutoCloseable { + final Path directory; + final ServerMock server; + final WorldMock world; + final PlayerMock player, neighbour; + final Cooking plugin, previous; + final HusbandryRepository repository; + final YamlConfiguration config = new YamlConfiguration(); + final HusbandryLoaderCoverageTest.ConfigSnapshot snapshot; + final MockedStatic tlibs; + Farm(Path directory) throws Exception { + this.directory = directory; snapshot = new HusbandryLoaderCoverageTest.ConfigSnapshot(); previous = Cooking.plugin; + server = MockBukkit.mock(); world = addWorld("farm"); player = server.addPlayer("Farmer"); neighbour = server.addPlayer("Neighbour"); player.teleport(new Location(world, 0, 65, 0)); neighbour.teleport(new Location(world, 1, 65, 0)); + plugin = mock(Cooking.class); when(plugin.getName()).thenReturn("Cooking"); when(plugin.namespace()).thenReturn("cooking"); when(plugin.isEnabled()).thenReturn(true); Cooking.plugin = plugin; + repository = spy(HusbandryRepository.open(directory.resolve("husbandry.db").toFile())); when(plugin.getHusbandryRepository()).thenReturn(repository); HusbandryEntities.clearLoaded(); + config.loadFromString(""" + max-animals: 2 + initial-genetic-max: 0 + max-genetics: 1000 + care-max: 100 + items: {inspect: inspect, tame: tame, co-own: share, feed: feed, glove: glove, neuter: neuter} + species: {COW: {milk: true}, HORSE: {}, SHEEP: {}} + mounts: + HORSE: {min-health: 25, max-health: 25, min-speed: 0.2, max-speed: 0.2, min-jump: 0.6, max-jump: 0.6} + """); reload(); + ItemAPI api = mock(ItemAPI.class, RETURNS_DEEP_STUBS); tlibs = mockStatic(TLibs.class); tlibs.when(TLibs::getItemAPI).thenReturn(api); + when(api.getChecker().checkItemWithPath(any(), anyString())).thenAnswer(call -> { + ItemStack item = call.getArgument(0); String path = call.getArgument(1); if (item == null) return false; + return switch (path) { + case "inspect" -> item.getType() == Material.CLOCK; + case "tame" -> item.getType() == Material.NAME_TAG; + case "share" -> item.getType() == Material.PAPER; + case "feed" -> item.getType() == Material.WHEAT; + case "glove" -> item.getType() == Material.LEATHER; + case "neuter" -> item.getType() == Material.SHEARS; + case "bucket" -> item.getType() == Material.BUCKET; + default -> false; + }; + }); + } + void reload() throws Exception { File file = directory.resolve("husbandry.yml").toFile(); config.save(file); new HusbandryLoader().load(file); } + WorldMock addWorld(String name) { WorldMock added = new DiskWorld(directory.resolve(name).toFile()); added.setName(name); server.addWorld(added); return added; } + T spawn(Class type) { + Map, Class> types = Map.of(Cow.class, CowMock.class, Horse.class, HorseMock.class, Pig.class, PigMock.class, Sheep.class, SheepMock.class, Donkey.class, DonkeyMock.class, Chicken.class, ChickenMock.class, ItemDisplay.class, ItemDisplayMock.class, TextDisplay.class, TextDisplayMock.class); + Class mockType = Objects.requireNonNull(types.get(type), "Unsupported fixture entity " + type); + T raw = type.cast(world.spawn(new Location(world, 0, 65, 0), mockType)); + if (!(raw instanceof LivingEntity)) return raw; + T entity = spy(raw); LivingEntity living = (LivingEntity) entity; boolean[] despawns = {true}; + doAnswer(call -> { despawns[0] = call.getArgument(0); return null; }).when(living).setRemoveWhenFarAway(anyBoolean()); doAnswer(call -> despawns[0]).when(living).getRemoveWhenFarAway(); + raw.remove(); server.registerEntity((EntityMock) entity); return entity; + } + HusbandryAnimal record(LivingEntity entity) { HusbandryAnimal animal = new HusbandryAnimal(entity.getUniqueId(), entity.getType().name(), HusbandryEntities.displayName(entity.getType())); animal.setLastProcessedAt(System.currentTimeMillis()); animal.setAfflictionAt(100); animal.setStatsRevision(HusbandryConfig.statsRevision()); repository.upsertAnimal(animal); HusbandryEntities.putLoaded(animal); return animal; } + HusbandryAnimal offline(String type, String name) { HusbandryAnimal animal = new HusbandryAnimal(UUID.randomUUID(), type, name); animal.setLastProcessedAt(System.currentTimeMillis()); animal.setUnloadedAt(1L); animal.setLastLocation("farm", 0, 65, 0); repository.upsertAnimal(animal); return animal; } + HusbandryAnimal ownedOffline(String name) { HusbandryAnimal animal = offline("COW", name); own(animal, player); return animal; } + void own(HusbandryAnimal animal, PlayerMock owner) { animal.setState(HusbandryAnimalState.OWNED); repository.upsertAnimal(animal); repository.upsertOwner(new HusbandryOwner(animal.uuid(), owner.getUniqueId(), "owner")); } + @Override public void close() throws Exception { + for (HusbandryAnimal animal : repository.listOwnedAnimals()) HusbandryLocator.markFound(animal.uuid()); + HusbandryTickTask.stop(); HusbandryEntities.clearLoaded(); repository.close(); MockBukkit.unmock(); tlibs.close(); Cooking.plugin = previous; snapshot.close(); + } + } + + static final class DiskWorld extends WorldMock { + private final File folder; + DiskWorld(File folder) { this.folder = folder; } + @Override public File getWorldFolder() { return folder; } + } + + /** Scheduling and disk scan are boundaries; applying their results still exercises the public scan workflow. */ + static final class Scan implements AutoCloseable { + final Deque async = new ArrayDeque<>(), sync = new ArrayDeque<>(); + List worlds; + final MockedStatic bukkit; + final MockedStatic scanner; + Scan(HusbandryEntityScan.Result result) { + BukkitScheduler scheduler = mock(BukkitScheduler.class); + when(scheduler.runTaskAsynchronously(any(Plugin.class), any(Runnable.class))).thenAnswer(call -> { async.add(call.getArgument(1)); return null; }); + when(scheduler.runTask(any(Plugin.class), any(Runnable.class))).thenAnswer(call -> { sync.add(call.getArgument(1)); return null; }); + bukkit = mockStatic(Bukkit.class, CALLS_REAL_METHODS); bukkit.when(Bukkit::getScheduler).thenReturn(scheduler); + scanner = mockStatic(HusbandryEntityScan.class); scanner.when(() -> HusbandryEntityScan.scan(anyList(), any(File.class), anySet())).thenAnswer(call -> { worlds = call.getArgument(0); return result; }); + } + void background() { async.removeFirst().run(); } + void apply() { sync.removeFirst().run(); } + @Override public void close() { scanner.close(); bukkit.close(); } + } +} diff --git a/src/test/java/net/tfminecraft/cooking/item/FoodItemCoverageTest.java b/src/test/java/net/tfminecraft/cooking/item/FoodItemCoverageTest.java new file mode 100644 index 0000000..84011d9 --- /dev/null +++ b/src/test/java/net/tfminecraft/cooking/item/FoodItemCoverageTest.java @@ -0,0 +1,311 @@ +package net.tfminecraft.cooking.item; + +import static org.junit.jupiter.api.Assertions.*; +import static org.mockito.Mockito.*; + +import java.util.ArrayList; +import java.util.List; +import java.util.Locale; +import java.util.Map; +import java.util.function.Consumer; +import org.bukkit.Material; +import org.bukkit.configuration.MemoryConfiguration; +import org.bukkit.inventory.ItemStack; +import org.bukkit.inventory.meta.ItemMeta; +import org.bukkit.persistence.PersistentDataType; +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 net.tfminecraft.cooking.Cooking; +import net.tfminecraft.cooking.enums.Method; +import net.tfminecraft.cooking.enums.Tag; +import net.tfminecraft.cooking.item.data.OverrideData; +import net.tfminecraft.cooking.item.model.FoodModel; +import net.tfminecraft.cooking.item.tag.TagStep; +import net.tfminecraft.cooking.item.tag.TagTrack; +import net.tfminecraft.cooking.loader.FoodLoader; +import net.tfminecraft.cooking.loader.ModelLoader; +import net.tfminecraft.cooking.loader.TrackLoader; +import net.tfminecraft.cooking.quality.QualityConfig; +import net.tfminecraft.cooking.utils.ItemBuilder; +import net.tfminecraft.cooking.utils.Keys; + +class FoodItemCoverageTest { + List foods; + List models; + List tracks; + Cooking previousPlugin; + + @BeforeEach void setup() { + MockBukkit.mock(); + previousPlugin = Cooking.plugin; + Cooking.plugin = mock(Cooking.class); + when(Cooking.plugin.getName()).thenReturn("Cooking"); + when(Cooking.plugin.namespace()).thenReturn("cooking"); + foods = new ArrayList<>(FoodLoader.oList); + models = new ArrayList<>(ModelLoader.models); + tracks = new ArrayList<>(TrackLoader.oList); + FoodLoader.oList.clear(); ModelLoader.models.clear(); TrackLoader.oList.clear(); + QualityConfig.apply(1, 5, null, null); + TrackLoader.oList.add(track("cooked", false, 0, "raw", "cooked", "burnt")); + TrackLoader.oList.add(track("freshness", true, 2, "fresh", "stale", "rotten")); + TrackLoader.oList.add(track("warmth", true, 1, "hot", "warm", "cold")); + } + + @AfterEach void cleanup() { + FoodLoader.oList.clear(); FoodLoader.oList.addAll(foods); + ModelLoader.models.clear(); ModelLoader.models.addAll(models); + TrackLoader.oList.clear(); TrackLoader.oList.addAll(tracks); + MockBukkit.unmock(); + Cooking.plugin = previousPlugin; + } + + @Test void configurationAndCopiesPreserveFoodMetadataWithoutSharingMutableState() { + MemoryConfiguration config = config(); + config.set("valuable", true); config.set("mashable", true); + config.set("cooking-options.pot.time", 10); + config.set("age.freshness", 2); + config.set("tag-labels.freshness.fresh", "Garden fresh"); + config.set("tag-labels.invalid", "scalar"); + config.createSection("tag-labels.empty"); + config.set("overrides.carrot.name", "Carrot stew"); + config.set("overrides.invalid", "scalar"); + config.set("model", "bread"); + FoodModel model = new FoodModel(new ItemStack(Material.BREAD)); + ModelLoader.models.add(model); + config.set("model", model.getId()); + FoodItem original = new FoodItem("stew", config); + original.setCategory("soup"); original.setOrigin("Carrot"); original.setAmount(3); + original.setQualityRange(2, 4); original.setBaseFood(7); original.setBaseNutrition(9); + original.setCatchSizeCm(28); original.setSeafoodCutType("fillet"); + original.setCustomFishingId("lake_trout"); + original.setCarveState("roast", 1, 2); + original.setLineage(IngredientLineage.ofMain("Carrot").withExtra("Salt")); + original.addIngredient("Carrot"); original.addIngredient("Carrot"); + original.setAgeRemainder("freshness", .25); + original.addTagTrack("freshness"); + FoodItem sauce = food("sauce"); original.setSauce(sauce); original.setSauceName("Gravy"); + FoodItem copy = new FoodItem(original); + assertEquals("stew", copy.getId()); assertEquals("Food", copy.getName()); + assertTrue(copy.isValuable()); assertTrue(copy.isMashable()); assertTrue(copy.isEdible()); + assertEquals("soup", copy.getCategory()); assertEquals("Carrot", copy.getOrigin()); + assertEquals(3, copy.getAmount()); assertEquals(2, copy.getQualityMin()); assertEquals(4, copy.getQualityMax()); + assertEquals(7, copy.getBaseFood()); assertEquals(9, copy.getBaseNutrition()); assertTrue(copy.hasBaseOverride()); + assertEquals(28, copy.getCatchSizeCm()); assertEquals("fillet", copy.getSeafoodCutType()); + assertEquals("lake_trout", copy.getCustomFishingId()); + assertEquals("roast", copy.getCarveSequencePdc()); assertEquals(1, copy.getCarveNextIndex()); + assertEquals(2, copy.getCarveRemaining()); assertEquals(original.getLineage(), copy.getLineage()); + assertEquals(List.of("Carrot"), copy.getIngredients()); assertEquals(.25, copy.getAgeRemainder("freshness")); + assertEquals("Garden fresh", copy.getTagLabel("freshness", "fresh")); + assertNotSame(original.getModel(), copy.getModel()); assertNotSame(sauce, copy.getSauce()); + assertEquals("Gravy", copy.getSauceName()); assertTrue(copy.hasSauceName()); + copy.getIngredients().add("Onion"); copy.getTagTrack("freshness").setValue(10); + assertEquals(List.of("Carrot"), original.getIngredients()); + assertEquals(0, original.getTagTrack("freshness").getValue()); + assertTrue(copy.getLastUpdate() > 0); assertTrue(copy.hasTagTrack("cooked")); + assertNull(copy.getTagLabel(null, "fresh")); assertNull(copy.getTagLabel("freshness", null)); + assertNull(copy.getTagLabel("unknown", "fresh")); + copy.setMashable(false); copy.setEdible(false); assertFalse(copy.isMashable()); assertFalse(copy.isEdible()); + copy.setSeafoodCutType(" "); copy.setCustomFishingId(null); + assertNull(copy.getSeafoodCutType()); assertNull(copy.getCustomFishingId()); + copy.setLineage(null); assertTrue(copy.getLineage().isEmpty()); + copy.setCarveNextIndex(2); copy.setCarveRemaining(1); + assertEquals(2, copy.getCarveNextIndex()); assertEquals(1, copy.getCarveRemaining()); + } + + @Test void copiedCookingOptionsAreIndependentOfTheirTemplate() { + MemoryConfiguration config = config(); config.set("cooking-options.pot.time", 10); + FoodItem original = new FoodItem("stew", config); + FoodItem copy = new FoodItem(original); + copy.getCookData().getParameters().clear(); + assertTrue(original.getCookData().hasMethod(Method.POT)); + assertFalse(copy.canBeCooked()); + } + + @Test void originOverridesAreIndependentOfTheServersDefaultLocale() { + MemoryConfiguration config = config(); + config.set("age.freshness", 3); config.set("carve-sequence", "default"); + config.set("overrides.fish.age.freshness", 2); + config.set("overrides.fish.carve-sequence", "fish_cuts"); + FoodItem food = new FoodItem("fish", config); + food.setOrigin("fish"); + Locale previous = Locale.getDefault(); + try { + Locale.setDefault(Locale.forLanguageTag("tr-TR")); + assertEquals(2, food.resolveAgeMultiplier("FRESHNESS")); + assertEquals("fish_cuts", food.getCarveSequenceId()); + } finally { Locale.setDefault(previous); } + } + + @Test void ageConfigurationUsesOriginOverrideThenTypeThenDefault() { + MemoryConfiguration config = config(); config.set("age.freshness", 3); + config.set("carve-sequence", "default"); + config.set("overrides.carrot.age.freshness", 2); + config.set("overrides.carrot.carve-sequence", "carrot_cuts"); + FoodItem food = new FoodItem("stew", config); + assertEquals(3, food.resolveAgeMultiplier("FRESHNESS")); + assertEquals(1, food.resolveAgeMultiplier(null)); assertEquals(1, food.resolveAgeMultiplier("warmth")); + assertEquals("default", food.getCarveSequenceId()); + food.setOrigin("unknown"); assertEquals(3, food.resolveAgeMultiplier("freshness")); + assertEquals("default", food.getCarveSequenceId()); + food.setOrigin("carrot"); assertEquals(2, food.resolveAgeMultiplier("freshness")); + assertEquals(1, food.resolveAgeMultiplier("warmth")); assertEquals("carrot_cuts", food.getCarveSequenceId()); + food.getOverrides().put("ONION", new OverrideData(null, null, null)); food.setOrigin("onion"); + assertEquals("default", food.getCarveSequenceId()); + } + + @Test void ageRemaindersRoundTripAndIgnoreMalformedEntries() { + FoodItem food = food("bread"); assertFalse(food.hasAgeRemainder()); assertNull(food.encodeAgeRemainder()); + food.setAgeRemainder(null, .2); assertEquals(0, food.getAgeRemainder(null)); + food.setAgeRemainder("FRESHNESS", .25); food.setAgeRemainder("warmth", .5); + FoodItem copy = food("copy"); copy.decodeAgeRemainder(food.encodeAgeRemainder()); + assertEquals(.25, copy.getAgeRemainder("freshness")); assertEquals(.5, copy.getAgeRemainder("WARMTH")); + copy.decodeAgeRemainder("bad;:2;empty:;negative:-1;zero:0;nan:NaN;text:bad;freshness:0.75"); + assertEquals(.75, copy.getAgeRemainder("freshness")); assertEquals(0, copy.getAgeRemainder("negative")); + assertEquals(0, copy.getAgeRemainder("warmth")); + copy.setAgeRemainder("freshness", 0); assertFalse(copy.hasAgeRemainder()); + copy.decodeAgeRemainder(null); copy.decodeAgeRemainder(" "); + food.clearAgeRemainders(); assertFalse(food.hasAgeRemainder()); + } + + @ParameterizedTest @ValueSource(doubles={Double.NaN, Double.POSITIVE_INFINITY, Double.NEGATIVE_INFINITY}) + void corruptAgeRemaindersCannotPoisonFutureAging(double value) { + FoodItem food = food("bread"); + food.setAgeRemainder("freshness", value); assertFalse(food.hasAgeRemainder()); + food.decodeAgeRemainder("freshness:" + value); assertFalse(food.hasAgeRemainder()); + } + + @Test void ageUpdateOnlyCountsWholeSecondsAndExpiresRoomTemperature() { + FoodItem food = food("bread"); + food.addTagTrack("freshness"); food.addTagTrack("warmth"); food.addTagTrack("cooked"); + food.setLastUpdate(System.currentTimeMillis()+60000); food.updateAge(); + assertEquals(0, food.getTagTrack("freshness").getValue()); + food.setLastUpdate(System.currentTimeMillis()); food.updateAge(); + assertEquals(0, food.getTagTrack("freshness").getValue()); + food.setLastUpdate(System.currentTimeMillis()-5000); food.updateAge(); + assertTrue(food.getTagTrack("freshness").getValue() >= 5); + assertEquals(0, food.getTagTrack("cooked").getValue()); + assertTrue(food.hasTagTrack("warmth")); + food.setLastUpdate(System.currentTimeMillis()-20000); food.updateAge(); + assertFalse(food.hasTagTrack("warmth")); + ItemStack stack = new ItemStack(Material.BREAD); food.updateAge(stack); + assertTrue(stack.getItemMeta().getPersistentDataContainer().has(Keys.LAST_UPDATE)); + FoodItem frozen = new FoodItem("salt", "Salt", false); + frozen.setAgeRemainder("freshness", .4); + edit(stack, m -> m.getPersistentDataContainer().set(Keys.AGE_REMAINDER, PersistentDataType.STRING, "freshness:0.4")); + assertSame(stack, frozen.updateAge(stack)); + assertFalse(stack.getItemMeta().getPersistentDataContainer().has(Keys.LAST_UPDATE)); + assertFalse(stack.getItemMeta().getPersistentDataContainer().has(Keys.AGE_REMAINDER)); + assertSame(Material.AIR, frozen.updateAge(new ItemStack(Material.AIR)).getType()); + } + + @Test void tagsAreSortedAndComparedByCurrentStageAndDriveEdibilityAndNutrition() { + FoodItem food = food("bread"); food.setQualityRange(1,1); + food.setBaseFood(10); food.setBaseNutrition(10); + food.addOrModifyTrack(null); food.removeTrack(null); + food.addTagTrack("freshness"); food.addTagTrack("warmth"); food.addTagTrack("cooked"); + food.addOrModifyTrack(track("equal", false, 1, "custom")); + assertEquals("cooked", food.getTagTracks().getFirst().getId()); + assertEquals("freshness", food.getTagTracks().getLast().getId()); + assertTrue(food.hasTag(Tag.RAW)); assertFalse(food.hasTag(Tag.PROCESSED)); + food.setEdibleWhenCooked(true); assertFalse(food.isEdible()); + food.getTagTrack("cooked").setValue(10); assertTrue(food.isEdible()); + FoodItem copy = new FoodItem(food); assertTrue(food.sameTags(copy)); + copy.getTagTrack("freshness").setValue(10); assertFalse(food.sameTags(copy)); + copy.removeTrack("freshness"); assertFalse(food.sameTags(copy)); + assertEquals(10, food.getFinalFood()); assertEquals(2.8, food.getFinalNutrition()); + FoodItem sauce = food("sauce"); sauce.setQualityRange(1,1); sauce.setBaseFood(2); sauce.setBaseNutrition(5); + food.setSauce(sauce); assertEquals(12, food.getFinalFood()); assertEquals(4.2, food.getFinalNutrition(), .0001); + food.setSauceName(""); assertFalse(food.hasSauceName()); + } + + @Test void modelLookupUsesTheOriginAndFallsBackWhenOverrideIsMissing() { + FoodItem food = food("bread"); FoodModel base = food.getModel(); + FoodModel alternate = new FoodModel("fish_model", modelConfig()); ModelLoader.models.add(alternate); + food.getOverrides().put("FISH", new OverrideData(null, "fish_model", null)); + food.getOverrides().put("NULL", null); + food.setOrigin("onion"); assertSame(base, food.getModel()); + food.setOrigin("fish"); assertSame(alternate, food.getModel()); + assertNotNull(food.getModelData()); + ModelLoader.models.clear(); assertSame(base, food.getModel()); + food.setCarveState("unregistered", 1, 2); + assertNotNull(food.getModelData()); + assertEquals(food.getBaseFood(), food.getFinalFood()); + assertEquals(.6, food.getFinalNutrition()); + } + + @Test void stampedFoodRestoresIdentityTagsIngredientsSauceAndCookingProgress() { + FoodItem base = food("bread"); base.cookData.getParameters().put(Method.POT, new net.tfminecraft.cooking.item.data.CookParameter(1,10,30)); + base.setCategory("bread"); FoodLoader.oList.add(base); + FoodItem sauce = food("sauce"); FoodLoader.oList.add(sauce); + FoodItem item = new FoodItem(base); + item.setOrigin("Wheat"); item.setQualityRange(3,3); item.setBaseFood(6); item.setBaseNutrition(4); + item.addTagTrack("freshness"); item.addTagTrack("warmth"); + item.setAgeRemainder("freshness", .2); item.setLastUpdate(12345L); + item.setSauce(sauce); item.setSauceName("Gravy"); item.addIngredient("Wheat"); item.addIngredient("Salt"); + item.setLineage(IngredientLineage.ofMain("Wheat").withExtra("Salt")); + item.setCatchSizeCm(20); item.setSeafoodCutType("fillet"); item.setCustomFishingId("river"); + item.getCookData().restore(Method.POT, 7); + ItemStack stack = ItemBuilder.stamp(new ItemStack(Material.BREAD), item, "Dinner"); + edit(stack, m -> { var p=m.getPersistentDataContainer(); p.set(Keys.CARVE_SEQUENCE,PersistentDataType.STRING,"roast"); + p.set(Keys.CARVE_NEXT_INDEX,PersistentDataType.INTEGER,1); p.set(Keys.CARVE_REMAINING,PersistentDataType.INTEGER,2); }); + FoodItem restored = FoodItem.fromItem(stack); + assertNotNull(restored); assertEquals("bread",restored.getId()); assertEquals("bread",restored.getCategory()); + assertEquals("Wheat",restored.getOrigin()); assertEquals(3,restored.getQualityMin()); + assertEquals(6,restored.getBaseFood()); assertEquals(4,restored.getBaseNutrition()); + assertEquals(.2,restored.getAgeRemainder("freshness")); assertEquals(12345,restored.getLastUpdate()); + assertEquals("sauce",restored.getSauce().getId()); assertEquals("Gravy",restored.getSauceName()); + assertEquals(List.of("Wheat","Salt"),restored.getIngredients()); assertEquals(item.getLineage(),restored.getLineage()); + assertEquals(20,restored.getCatchSizeCm()); assertEquals("fillet",restored.getSeafoodCutType()); + assertEquals("river",restored.getCustomFishingId()); assertEquals(Method.POT,restored.getCookData().getCurrentMethod()); + assertEquals(7,restored.getCookData().getCurrentTime()); assertEquals(1,restored.getCarveNextIndex()); + assertEquals(2,restored.getCarveRemaining()); + } + + @Test void unknownOrDamagedItemMetadataIsIgnoredWithoutLosingRecognizedFood() { + assertNull(FoodItem.fromItem(null)); assertNull(FoodItem.fromItem(new ItemStack(Material.AIR))); + ItemStack stack = new ItemStack(Material.BREAD); + edit(stack,m -> m.setDisplayName("Plain bread")); assertNull(FoodItem.fromItem(stack)); + edit(stack,m -> m.getPersistentDataContainer().set(Keys.FOOD_ID,PersistentDataType.STRING,"unknown")); + assertNull(FoodItem.fromItem(stack)); + FoodItem base = new FoodItem("unknown", config()); FoodLoader.oList.add(base); + edit(stack,m -> { var p=m.getPersistentDataContainer(); + p.set(Keys.TAGS,PersistentDataType.STRING,"bad;freshness.bad;freshness.1.2;missing.1;freshness.10;warmth.1"); + p.set(Keys.COOK_METHOD,PersistentDataType.STRING,"invalid"); + p.set(Keys.CARVE_SEQUENCE,PersistentDataType.STRING,"removed"); + p.set(Keys.INGREDIENTS,PersistentDataType.STRING,"Wheat: :Salt"); + p.set(Keys.SAUCE,PersistentDataType.STRING,"not_a_food"); }); + long before=System.currentTimeMillis(); FoodItem restored=FoodItem.fromItem(stack); + assertNotNull(restored); assertNotNull(restored.getModel()); assertEquals(10,restored.getTagTrack("freshness").getValue()); + assertEquals(1,restored.getTagTrack("warmth").getValue()); assertFalse(restored.getCookData().isBeingCooked()); + assertEquals(0,restored.getCarveRemaining()); assertEquals(0,restored.getCarveNextIndex()); + assertTrue(restored.getLastUpdate()>=before); assertNull(restored.getSauce()); + assertEquals(List.of("Wheat","Salt"),restored.getIngredients()); + edit(stack,m -> m.getPersistentDataContainer().set(Keys.COOK_METHOD,PersistentDataType.STRING,"POT")); + assertEquals(0,FoodItem.fromItem(stack).getCookData().getCurrentTime()); + FoodLoader.oList.clear(); FoodLoader.oList.add(new FoodItem("unknown","Salt",false)); + assertEquals(0,FoodItem.fromItem(stack).getLastUpdate()); + } + + static MemoryConfiguration config() { + MemoryConfiguration config=new MemoryConfiguration(); config.set("name","Food"); + config.set("food",2); config.set("nutrition",2); return config; + } + static MemoryConfiguration modelConfig() { + MemoryConfiguration config=new MemoryConfiguration(); config.set("default.gui.item","v.bread"); return config; + } + static FoodItem food(String id) { + FoodItem food=new FoodItem(id,config()); food.setModel(new FoodModel(new ItemStack(Material.BREAD))); return food; + } + static TagTrack track(String id,boolean ageable,int index,String... steps) { + MemoryConfiguration config=new MemoryConfiguration(); config.set("ageable",ageable); + for(int i=0;i edit) { + ItemMeta meta=stack.getItemMeta(); edit.accept(meta); stack.setItemMeta(meta); + } +} diff --git a/src/test/java/net/tfminecraft/cooking/item/IngredientRuntimeCoverageTest.java b/src/test/java/net/tfminecraft/cooking/item/IngredientRuntimeCoverageTest.java new file mode 100644 index 0000000..7d21e9a --- /dev/null +++ b/src/test/java/net/tfminecraft/cooking/item/IngredientRuntimeCoverageTest.java @@ -0,0 +1,110 @@ +package net.tfminecraft.cooking.item; + +import static org.junit.jupiter.api.Assertions.*; +import static org.mockito.ArgumentMatchers.*; +import static org.mockito.Mockito.*; + +import java.util.Arrays; +import java.util.List; +import java.util.Map; +import net.tfminecraft.cooking.enums.Method; +import net.tfminecraft.cooking.events.DishCookedEvent; +import net.tfminecraft.cooking.item.data.CookData; +import net.tfminecraft.cooking.item.data.OverrideData; +import net.tfminecraft.cooking.item.tag.AgeScale; +import net.tfminecraft.cooking.item.tag.TagQuality; +import net.tfminecraft.cooking.item.tag.TagTrack; +import net.tfminecraft.cooking.loader.ConversionLoader; +import net.tfminecraft.cooking.quality.CompositionContext; +import net.tfminecraft.cooking.sausagemaker.SausageMakerCoverageTest.Workshop; +import net.tfminecraft.cooking.utils.IngredientConverter; +import net.tfminecraft.cooking.utils.ItemBuilder; +import org.bukkit.Material; +import org.bukkit.configuration.MemoryConfiguration; +import org.bukkit.inventory.ItemStack; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; + +class IngredientRuntimeCoverageTest { + Workshop env; + @BeforeEach void setup() { env=new Workshop(); } + @AfterEach void cleanup() { env.close(); } + + @Test void pathsBuildOneItemAndMatchExplicitQualityAndTagFilters() { + FoodItem food=env.food("fish","Tuna",3); food.setCategory("seafood"); env.templates.put("fish",food); + CookingPathHandler handler=CookingPathHandler.INSTANCE; + assertNull(handler.create(null)); assertNull(handler.create("c.")); assertNull(handler.create("seafood")); + assertNull(handler.create("seafood(type=missing)")); assertNull(handler.create("seafood(type=fish")); + String path="seafood(type=fish)", exact="seafood(type=fish;quality=3)"; + assertNull(handler.create(path)); + ItemStack plain=new ItemStack(Material.COD,8), quality=new ItemStack(Material.COD,5); + env.builder.when(()->ItemBuilder.buildSingleString(path,null)).thenReturn(plain); + env.builder.when(()->ItemBuilder.buildSingle(any(FoodItem.class),isNull())).thenReturn(quality); + assertSame(plain,handler.create("c."+path)); assertEquals(1,plain.getAmount()); + assertSame(quality,handler.create("c."+exact)); assertEquals(1,quality.getAmount()); + assertTrue(handler.matches(env.stack(food,Material.COD,1),"seafood")); + assertFalse(CookingPathHandler.matches((FoodItem)null,"seafood")); assertFalse(CookingPathHandler.matches(food,null)); + assertFalse(CookingPathHandler.matches(food,"c. ")); assertFalse(CookingPathHandler.matches(food,"(type=fish)")); + assertTrue(CookingPathHandler.matches(food,"seafood(quality=4-2)")); assertFalse(CookingPathHandler.matches(food,"seafood(quality=nope)")); + assertTrue(CookingPathHandler.matches(food,"seafood(type= ;origin= )")); + assertTrue(CookingPathHandler.matches(food,"seafood(tags= , ignored)")); + assertFalse(CookingPathHandler.matches(food,"seafood(tags=freshness.nope)")); + TagTrack fresh=new TagTrack("freshness",false,List.of()); fresh.forceSetValue(4); food.addOrModifyTrack(fresh); + assertTrue(CookingPathHandler.matches(food,"seafood(tags=FRESHNESS.4)")); + TagTrack special=new TagTrack("Special",false,List.of()); special.forceSetValue(2); food.addOrModifyTrack(special); + assertTrue(CookingPathHandler.matches(food,"seafood(tags=SPECIAL.2)")); + assertNull(CookingPathHandler.toShortPath(null)); food.setOrigin(null); food.setCategory(null); + assertEquals("c.(type=fish;origin=)",CookingPathHandler.toShortPath(food)); assertFalse(CookingPathHandler.matches(food,"seafood")); + } + + @Test void ingredientConversionRetainsInputsWhenThereIsNoValidReplacement() { + ItemStack wheat=new ItemStack(Material.WHEAT,9), air=new ItemStack(Material.AIR); + assertNull(IngredientConverter.convertIfNeeded(null,null)); assertSame(air,IngredientConverter.convertIfNeeded(null,air)); + ItemStack already=env.stack(env.food("wheat","Wheat",2),Material.WHEAT,3); + assertSame(already,IngredientConverter.convertIfNeeded(null,already)); + try(var conversions=mockStatic(ConversionLoader.class)) { + assertSame(wheat,IngredientConverter.convertIfNeeded(null,wheat)); + conversions.when(()->ConversionLoader.getByItem(wheat)).thenReturn("invalid"); + assertSame(wheat,IngredientConverter.convertIfNeeded(null,wheat)); + conversions.when(()->ConversionLoader.getByItem(wheat)).thenReturn("grain(type=wheat;origin=Wheat)"); + env.builder.when(()->ItemBuilder.buildSingleWithQuality(any(FoodItem.class),same(wheat),anyInt())).thenReturn(null); + assertSame(wheat,IngredientConverter.convertIfNeeded(env.player,wheat)); + env.builder.when(()->ItemBuilder.buildSingleWithQuality(any(FoodItem.class),same(wheat),anyInt())).thenReturn(air); + assertSame(wheat,IngredientConverter.convertIfNeeded(env.player,wheat)); + ItemStack output=env.stack(env.food("wheat","Wheat",3),Material.WHEAT,1); + env.builder.when(()->ItemBuilder.buildSingleWithQuality(any(FoodItem.class),same(wheat),anyInt())).thenReturn(output); + assertSame(output,IngredientConverter.convertHarvestDrop(env.player,wheat,100)); assertEquals(9,output.getAmount()); + conversions.when(ConversionLoader::get).thenReturn(Map.of("v.wheat","grain(type=wheat;origin=Wheat)")); + assertSame(output,IngredientConverter.convertHarvestDrop(env.player,wheat,0)); + env.builder.verify(()->ItemBuilder.buildSingleWithQuality(any(FoodItem.class),same(wheat),eq(1))); + } + } + + @Test void lineageAndTagMetadataRetainTheirPublicBoundarySemantics() { + assertEquals(0,TagQuality.stars(null)); assertEquals(0,TagQuality.stars(Arrays.asList(null,new TagTrack("empty",false,List.of())))); + assertNull(AgeScale.migrateTrackId(null)); assertEquals(new AgeScale.Scaled(3,0.5),AgeScale.apply(3,0,2,0.5)); + IngredientLineage base=IngredientLineage.ofMain("Tuna").withExtra("Salt"); + assertTrue(IngredientLineage.from(null,CompositionContext.BAKING).isEmpty()); + assertSame(base, base.merge(IngredientLineage.empty())); + assertEquals(List.of("Salt","Pepper"),base.merge(IngredientLineage.ofExtra("Pepper")).extras()); + assertFalse(base.equals("Tuna")); assertEquals(base.hashCode(),IngredientLineage.ofMain("Tuna").withExtra("Salt").hashCode()); + assertTrue(IngredientLineage.forEat(null).isEmpty()); + FoodItem food=env.food("fish","Tuna",2); food.setLineage(base); + assertEquals(base,IngredientLineage.from(Arrays.asList(null,food),CompositionContext.BAKING)); + String origin="A%|;,=)(B"; assertEquals(origin,IngredientLineageCodec.decode(IngredientLineageCodec.encode(IngredientLineage.ofMain(origin))).mains().getFirst()); + assertEquals("%z0%0z",IngredientLineageCodec.unescape("%z0%0z")); + assertNull(new OverrideData(null,null,null,Map.of("freshness",2.0)).getAge(null)); + DishCookedEvent event=new DishCookedEvent(env.player,new ItemStack(Material.COD),"bake"); assertEquals("bake",event.getMethod()); + assertSame(DishCookedEvent.getHandlerList(),event.getHandlers()); + } + + @Test void cookingTimersResetAndIgnoreInactiveOrUnavailableTracks() { + MemoryConfiguration config=new MemoryConfiguration(); config.set("invalid.time",2); config.set("pot.time",2); + CookData settings=new CookData("test",config); FoodItem food=env.food("test",null,2); CookData data=new CookData(food,settings); + assertSame(food,data.getFoodItem()); data.restore(Method.POT,5); data.reset(); + assertNull(data.getCurrentMethod()); assertEquals(0,data.getCurrentTime()); + assertFalse(data.check()); assertDoesNotThrow(()->data.setCurrentTime(2)); + data.start(Method.POT); assertDoesNotThrow(()->data.setCurrentTime(2)); + } +} diff --git a/src/test/java/net/tfminecraft/cooking/item/NamingRulesCoverageTest.java b/src/test/java/net/tfminecraft/cooking/item/NamingRulesCoverageTest.java new file mode 100644 index 0000000..463df3c --- /dev/null +++ b/src/test/java/net/tfminecraft/cooking/item/NamingRulesCoverageTest.java @@ -0,0 +1,144 @@ +package net.tfminecraft.cooking.item; + +import net.tfminecraft.cooking.utils.*; + +import static org.junit.jupiter.api.Assertions.*; +import static org.mockito.Mockito.*; + +import java.util.ArrayList; +import java.util.Arrays; +import java.util.HashMap; +import java.util.List; +import java.util.Locale; +import java.util.Map; +import net.tfminecraft.cooking.Cooking; +import net.tfminecraft.cooking.cache.NamingConfig; +import net.tfminecraft.cooking.item.FoodItem; +import net.tfminecraft.cooking.item.tag.TagStep; +import net.tfminecraft.cooking.item.tag.TagTrack; +import net.tfminecraft.cooking.loader.TrackLoader; +import org.bukkit.configuration.MemoryConfiguration; +import org.bukkit.entity.Player; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.mockbukkit.mockbukkit.MockBukkit; + +class NamingRulesCoverageTest { + Cooking plugin; + List tracks; + @BeforeEach void setup() { + MockBukkit.mock(); plugin=Cooking.plugin; Cooking.plugin=mock(Cooking.class); + when(Cooking.plugin.getName()).thenReturn("Cooking"); when(Cooking.plugin.namespace()).thenReturn("cooking"); + tracks=new ArrayList<>(TrackLoader.oList); TrackLoader.oList.clear(); + NamingConfig.apply(List.of("sweet","empty","freshness","colour","unknown"),3,2,2,null, + Map.of("food",Map.of("sweet",Map.of("sweet","Sugar-coated"))),Map.of("apple","Apple")); + } + @AfterEach void cleanup() { + TrackLoader.oList.clear(); TrackLoader.oList.addAll(tracks); NamingConfig.apply(List.of("sweet"),3,2,2,null,null,null); + MockBukkit.unmock(); Cooking.plugin=plugin; + } + + @Test void namesApplyIngredientAndTagLabelsWithoutDuplicatingFillers() { + FoodItem food=food("meal","food","bread"); + TagTrack sweet=track("sweet","sweet","Sweet"); food.addOrModifyTrack(sweet); + assertEquals("Sugar-coated",TagDisplayNames.resolve(food,sweet,sweet.getCurrentStep())); + assertEquals("Unknown",TagDisplayNames.resolve(food,sweet,null)); assertEquals("Sweet",TagDisplayNames.resolve(null,sweet,sweet.getCurrentStep())); + food.setCategory(null); assertEquals("Sweet",TagDisplayNames.resolve(food,sweet,sweet.getCurrentStep())); + food.setCategory(""); assertEquals("Sweet",TagDisplayNames.resolve(food,sweet,sweet.getCurrentStep())); food.setCategory("food"); + MemoryConfiguration settings=new MemoryConfiguration(); settings.set("name","{colour}{prefixes}{ingredients} Pie"); settings.set("tag-labels.sweet.sweet","Sugary"); + FoodItem named=new FoodItem("pie",settings); named.setCategory("food"); named.addIngredient("apple"); named.addOrModifyTrack(sweet); + assertEquals("Sugary",TagDisplayNames.resolve(named,sweet,sweet.getCurrentStep())); + assertTrue(NameComposer.compose(named,Map.of("colour","#ffffff")).endsWith("Sugary Apple Pie")); + settings.set("name", "{ingredients}Pie"); + FoodItem emptyPie = new FoodItem("empty_pie", settings); + assertEquals("Mixed Pie", NameComposer.compose(emptyPie, null)); + emptyPie.addIngredient("apple"); + assertEquals("Apple Pie", NameComposer.compose(emptyPie, null)); + settings.set("name", "{ingredients}{colour}"); + assertEquals("", NameComposer.compose(new FoodItem("empty", settings), null)); + assertEquals("",NameComposer.compose(null,null)); assertEquals("",NameComposer.formatPrefixes(null)); + named.addOrModifyTrack(new TagTrack("empty",false,List.of())); named.addOrModifyTrack(track("freshness","fresh","")); + named.addOrModifyTrack(track("colour","red","#ff0000Red")); + assertTrue(NameComposer.formatPrefixes(named).endsWith("Red ")); + named.addOrModifyTrack(track("colour","red","#zzzzzzRed")); assertTrue(NameComposer.formatPrefixes(named).endsWith("#zzzzzzRed ")); + Map extras=new HashMap<>(); extras.put("colour",null); assertNotNull(NameComposer.compose(named,extras)); + FoodItem unnamed=new FoodItem("blank",null,true); assertEquals("",NameComposer.compose(unnamed,null)); + assertEquals("",NameComposer.formatFillerPhrase(null)); assertEquals("Mixed Soup",NameComposer.formatFillerPhrase(List.of(),"Soup")); + assertEquals("Apple ",NameComposer.formatFillerPhrase(Arrays.asList(null," ","apple","apple"))); + assertEquals("A and B ",NameComposer.formatFillerPhrase(List.of("A","B"))); + assertEquals("A and B Soup",NameComposer.formatFillerPhrase(List.of("A","B"),"Soup")); + assertEquals("A, B and C ",NameComposer.formatFillerPhrase(List.of("A","B","C","D"))); + assertEquals("A, B and C Soup",NameComposer.formatFillerPhrase(List.of("A","B","C"),"Soup")); + } + + @Test void addonRulesRejectDuplicatesAndLimitsAndClassifyDistinctFlavours() { + Player player=mock(Player.class); FoodItem garnish=food("garnish","garnish","Basil"), spice=food("spice","spice","Pepper"), sugar=food("sugar","sweetener",null); + Map slots=new HashMap<>(); slots.put("empty",null); + assertFalse(StationAddonRules.hasDuplicateOrigin(slots, garnish)); + assertFalse(StationAddonRules.canAcceptAddon(slots,null,player)); assertFalse(StationAddonRules.canAcceptAddon(slots,sugar,player)); + assertTrue(StationAddonRules.canAcceptAddon(slots,garnish,player)); assertEquals(StationAddonRules.AddonProfile.NONE,StationAddonRules.classifyAddons(slots)); + slots.put("first",garnish); assertFalse(StationAddonRules.canAcceptAddon(slots,garnish,player)); verify(player).sendMessage("§cThat ingredient is already in there."); + assertEquals(StationAddonRules.AddonProfile.SINGLE,StationAddonRules.classifyAddons(slots)); + slots.put("second",spice); assertEquals(StationAddonRules.AddonProfile.ROUNDED,StationAddonRules.classifyAddons(slots)); + assertFalse(StationAddonRules.canAcceptAddon(slots,food("herb","garnish","Parsley"),player)); verify(player).sendMessage("§cYou can't add more garnish or spice."); + slots.put("second",food("herb","garnish",null)); assertEquals(StationAddonRules.AddonProfile.AROMATIC,StationAddonRules.classifyAddons(slots)); + assertFalse(StationAddonRules.hasDuplicateOrigin(slots,sugar)); sugar.setOrigin(" "); assertFalse(StationAddonRules.hasDuplicateOrigin(slots,sugar)); + assertTrue(StationAddonRules.canAcceptSweetener(slots,sugar,player)); slots.put("sugar",sugar); + assertFalse(StationAddonRules.canAcceptSweetener(slots,sugar,player)); verify(player).sendMessage("§cSugar is already in the pan."); + assertFalse(StationAddonRules.canAcceptSweetener(slots,null,player)); assertFalse(StationAddonRules.canAcceptSweetener(slots,garnish,player)); + assertFalse(StationAddonRules.hasValuable(null)); assertFalse(StationAddonRules.hasValuable(slots)); + MemoryConfiguration valuable=new MemoryConfiguration(); valuable.set("valuable",true); slots.put("valuable",new FoodItem("valuable",valuable)); assertTrue(StationAddonRules.hasValuable(slots)); + } + + @Test void configuredTagsApplyToSweetSeasonedValuableAndMixedDishes() { + for(String id:List.of("sweet","seasoning","aromatic","rounded","flavourful","fruity","richly_fruity")) TrackLoader.oList.add(track(id,id,id)); + FoodItem product=food("product","food",null), sugar=food("sugar","sweetener",null), salt=food("salt","salt",null); + Map slots=new HashMap<>(); slots.put("empty",null); + StationAddonRules.applySweetTag(product,slots); StationAddonRules.applySeasoningTag(product,slots); StationAddonRules.applyAddonTags(product,slots); + StationAddonRules.applyFlavourfulTag(null,slots); StationAddonRules.applyFlavourfulTag(product,slots); assertFalse(product.hasTagTrack("sweet")); + slots.put("sugar",sugar); StationAddonRules.applySweetTag(product,slots); assertTrue(product.hasTagTrack("sweet")); + slots.put("salt",salt); StationAddonRules.applySeasoningTag(product,slots); assertEquals(0,product.getTagTrack("seasoning").getValue()); + slots.put("pepper",food("pepper","pepper",null)); StationAddonRules.applySeasoningTag(product,slots); assertEquals(1,product.getTagTrack("seasoning").getValue()); + slots.put("one",food("herb","garnish","Basil")); slots.put("two",food("herb2","garnish","Parsley")); + StationAddonRules.applyAddonTags(product,slots); assertTrue(product.hasTagTrack("aromatic")); + slots.put("two",food("spice","spice","Cumin")); StationAddonRules.applyAddonTags(product,slots); assertTrue(product.hasTagTrack("rounded")); + MemoryConfiguration valuable=new MemoryConfiguration(); valuable.set("valuable",true); slots.put("valuable",new FoodItem("valuable",valuable)); + StationAddonRules.applyFlavourfulTag(product,slots); assertTrue(product.hasTagTrack("flavourful")); + DoughMixinRules.applyDoughTags(product,true,List.of("Apple")); assertTrue(product.hasTagTrack("fruity")); + DoughMixinRules.applyDoughTags(product,false,List.of("Apple","Pear")); assertTrue(product.hasTagTrack("richly_fruity")); + DoughMixinRules.applyDoughTags(product,false,null); + } + + @Test void missingOptionalTagTemplatesDoNotCrashFoodPreparation() { + FoodItem product=food("product","food",null); MemoryConfiguration valuable=new MemoryConfiguration(); valuable.set("valuable",true); + Map slots=new HashMap<>(); slots.put("sugar",food("sugar","sweetener",null)); slots.put("salt",food("salt","salt",null)); + slots.put("valuable",new FoodItem("valuable",valuable)); slots.put("one",food("garnish","garnish","Basil")); slots.put("two",food("garnish2","garnish","Parsley")); + assertDoesNotThrow(()->StationAddonRules.applySweetTag(product,slots)); assertDoesNotThrow(()->StationAddonRules.applySeasoningTag(product,slots)); + assertDoesNotThrow(()->StationAddonRules.applyFlavourfulTag(product,slots)); assertDoesNotThrow(()->StationAddonRules.applyAddonTags(product,slots)); + slots.put("two",food("spice","spice","Cumin")); assertDoesNotThrow(()->StationAddonRules.applyAddonTags(product,slots)); + assertDoesNotThrow(()->DoughMixinRules.applyDoughTags(product,true,List.of("Apple"))); + assertDoesNotThrow(()->DoughMixinRules.applyDoughTags(product,true,List.of("Apple","Pear"))); + assertTrue(product.getTagTracks().isEmpty()); + } + + @Test void addonClassificationIsIndependentOfTheServerLocale() { + Locale old=Locale.getDefault(); + try { Locale.setDefault(Locale.forLanguageTag("tr-TR")); + assertEquals(StationAddonRules.AddonProfile.ROUNDED,StationAddonRules.classifyAddons(Map.of( + "one",food("garnish","GARNISH","Basil"),"two",food("spice","SPICE","Cumin")))); + } finally { Locale.setDefault(old); } + } + + @Test void doughIngredientLimitsGiveUsefulFeedback() { + Player player=mock(Player.class); FoodItem fruit=food("apple","fruit","Apple"), sugar=food("sugar","sweetener",null); + assertTrue(DoughMixinRules.canAcceptSugar(false,sugar,player)); assertFalse(DoughMixinRules.canAcceptSugar(true,sugar,player)); + assertFalse(DoughMixinRules.canAcceptSugar(false,null,player)); assertFalse(DoughMixinRules.canAcceptSugar(false,fruit,player)); + assertTrue(DoughMixinRules.canAcceptFruit(List.of(),fruit,player)); assertFalse(DoughMixinRules.canAcceptFruit(List.of("Apple"),fruit,player)); + assertFalse(DoughMixinRules.canAcceptFruit(List.of("Pear","Plum"),fruit,player)); + assertFalse(DoughMixinRules.canAcceptFruit(List.of(),null,player)); assertFalse(DoughMixinRules.canAcceptFruit(List.of(),sugar,player)); + } + + private FoodItem food(String id,String category,String origin) { FoodItem item=new FoodItem(id,new MemoryConfiguration()); item.setCategory(category); item.setOrigin(origin); return item; } + private TagTrack track(String id,String step,String name) { return new TagTrack(id,false,List.of(new TagStep(step,name,0,1,1))); } +} diff --git a/src/test/java/net/tfminecraft/cooking/item/model/FoodModelsCoverageTest.java b/src/test/java/net/tfminecraft/cooking/item/model/FoodModelsCoverageTest.java new file mode 100644 index 0000000..60db8ca --- /dev/null +++ b/src/test/java/net/tfminecraft/cooking/item/model/FoodModelsCoverageTest.java @@ -0,0 +1,126 @@ +package net.tfminecraft.cooking.item.model; + +import static org.junit.jupiter.api.Assertions.*; +import static org.mockito.Mockito.*; +import static org.mockito.ArgumentMatchers.*; +import java.util.List; +import java.util.Locale; +import java.util.Map; +import org.bukkit.Material; +import org.bukkit.NamespacedKey; +import org.bukkit.configuration.MemoryConfiguration; +import org.bukkit.inventory.ItemStack; +import org.bukkit.persistence.PersistentDataType; +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.mockito.MockedStatic; +import net.tfminecraft.cooking.item.FoodItem; +import net.tfminecraft.cooking.item.tag.TagStep; +import net.tfminecraft.cooking.item.tag.TagTrack; +import net.tfminecraft.cooking.utils.ItemRef; +import net.tfminecraft.interactiblefurniture.furniture.data.DisplayData; +import net.tfminecraft.tlibs.TLibs; +import net.tfminecraft.tlibs.objects.api.ItemAPI; + +class FoodModelsCoverageTest { + MockedStatic tlibs; + @BeforeEach void setup() { + MockBukkit.mock(); tlibs=mockStatic(TLibs.class); + ItemAPI api=mock(ItemAPI.class,RETURNS_DEEP_STUBS); tlibs.when(TLibs::getItemAPI).thenReturn(api); + when(api.getCreator().getItemFromPath(anyString())).thenAnswer(a->new ItemStack(Material.valueOf(a.getArgument(0).substring(2).toUpperCase(Locale.ROOT)))); + } + @AfterEach void cleanup() { tlibs.close(); MockBukkit.unmock(); } + + @Test void configuredModelsSelectMostSpecificMatchingStateAndExcludeCarveStages() { + MemoryConfiguration config=new MemoryConfiguration(); config.set("colour","ff0000"); config.set("invalid","scalar"); + state(config,"default",0,-1,List.of(),"v.bread"); + state(config,"cooked",5,-1,List.of("cooked"),"v.cooked_beef"); + state(config,"rotten",10,-1,List.of("rotten"),"v.rotten_flesh"); + state(config,"stage",20,1,List.of("cooked"),"v.bone"); + state(config,"later",20,2,List.of("cooked"),"v.beef"); + config.set("display-data-furniture.oven.position.y",.5); + FoodModel model=new FoodModel("roast",config); + assertEquals("roast",model.getId()); assertEquals(5,model.getStates().size()); + FoodItem food=new FoodItem("roast",new MemoryConfiguration()); + assertEquals(Material.BREAD,model.getModel(food).apply(null,null).getType()); + food.addOrModifyTrack(track("cooked","cooked")); + assertEquals(Material.COOKED_BEEF,model.getModel(food).apply(null,null).getType()); + assertEquals(Material.BONE,model.getModelByStageAndTag(1,"cooked").apply(null,null).getType()); + assertEquals(Material.COOKED_BEEF,model.getModelByStageAndTag(8,"cooked").apply(null,null).getType()); + assertNotNull(model.getModelByStageAndTag(8,"unknown")); + food.addOrModifyTrack(track("freshness","rotten")); + assertEquals(Material.ROTTEN_FLESH,model.getModel(food).apply(null,null).getType()); + MemoryConfiguration unmatched=new MemoryConfiguration(); state(unmatched,"only",0,-1,List.of("rotten"),"v.rotten_flesh"); + assertNotNull(new FoodModel("fallback",unmatched).getModel(new FoodItem("plain",new MemoryConfiguration()))); + } + + @Test void modelPosesAndItemOverridesInheritFromTheirRoot() { + MemoryConfiguration config=new MemoryConfiguration(); config.set("gui.item","v.bread"); config.set("display.item","v.paper"); + config.set("display.furniture",List.of("oven v.bone","broken")); config.set("tags",List.of("cooked")); + config.set("display-data.position.x",.25); config.set("display-data-furniture.oven.position.y",.5); + config.set("display-data-furniture.invalid","scalar"); + DisplayData parent=new DisplayData(); parent.setzPos(.75f); + ModelData model=new ModelData(config,Map.of("table",parent)); + assertEquals(Material.BREAD,model.apply(null,null).getType()); + assertEquals(Material.BONE,model.apply("oven",null).getType()); + assertEquals(Material.PAPER,model.apply("table",null).getType()); + assertEquals(.25f,model.getDisplayData().getxPos()); + assertEquals(.5f,model.getDisplayData("OVEN").getyPos()); + assertEquals(.75f,model.getDisplayData("table").getzPos()); + assertSame(model.getDisplayData(),model.getDisplayData(null)); + assertSame(model.getDisplayData(),model.getDisplayData("unknown")); + assertEquals(Material.AIR,new ModelData(new MemoryConfiguration()).apply(null,null).getType()); + assertNotNull(new ModelData(config,null).getDisplayData()); + } + + @Test void copiedModelPosesCannotChangeTheTemplate() { + MemoryConfiguration config=new MemoryConfiguration(); state(config,"default",0,-1,List.of(),"v.bread"); + config.set("default.display-data.position.x",.25); config.set("default.display-data-furniture.oven.position.y",.5); + FoodModel original=new FoodModel("bread",config); FoodModel copied=new FoodModel(original); + ModelData originalData=original.getStates().get("default"),copy=copied.getStates().get("default"); + copy.getDisplayData().setxPos(7); copy.getDisplayData("oven").setyPos(8); + assertEquals(.25f,originalData.getDisplayData().getxPos()); + assertEquals(.5f,originalData.getDisplayData("oven").getyPos()); + } + + @Test void directModelsCloneItemStacksAndPreserveSourceMetadata() { + ItemStack template=new ItemStack(Material.BREAD); + ModelData original=new ModelData(template); ModelData model=new ModelData(original); + template.setType(Material.STONE); + ItemStack source=new ItemStack(Material.PAPER,3); var meta=source.getItemMeta(); + meta.setDisplayName("Dinner"); meta.setLore(List.of("Fresh")); var p=meta.getPersistentDataContainer(); + p.set(key("text"),PersistentDataType.STRING,"value"); p.set(key("number"),PersistentDataType.INTEGER,2); + p.set(key("time"),PersistentDataType.LONG,3L); p.set(key("fraction"),PersistentDataType.DOUBLE,4.5); + source.setItemMeta(meta); + ItemStack result=model.apply(null,source); + assertEquals(Material.BREAD,result.getType()); assertEquals(3,result.getAmount()); + assertEquals("Dinner",result.getItemMeta().getDisplayName()); assertEquals(List.of("Fresh"),result.getItemMeta().getLore()); + var out=result.getItemMeta().getPersistentDataContainer(); + assertEquals("value",out.get(key("text"),PersistentDataType.STRING)); assertEquals(2,out.get(key("number"),PersistentDataType.INTEGER)); + assertEquals(3L,out.get(key("time"),PersistentDataType.LONG)); assertEquals(4.5,out.get(key("fraction"),PersistentDataType.DOUBLE)); + assertEquals(90,model.getDisplayData().getxRot()); assertEquals(90,model.getDisplayData().getzRot()); + assertEquals(-.25f,model.getDisplayData().getyPos()); + result.setType(Material.STONE); assertEquals(Material.BREAD,model.apply(null,null).getType()); + ItemRef.mergeMeta(result,new ItemStack(Material.AIR)); + ItemRef.mergeMeta(new ItemStack(Material.AIR),source); + assertNotNull(new ItemRef()); + } + + @Test void modelChangesPreserveAllPersistentDataTypes() { + ItemStack source=new ItemStack(Material.PAPER); var meta=source.getItemMeta(); + meta.getPersistentDataContainer().set(key("flag"),PersistentDataType.BYTE,(byte)1); + meta.getPersistentDataContainer().set(key("bytes"),PersistentDataType.BYTE_ARRAY,new byte[]{1,2}); + source.setItemMeta(meta); + ItemStack result=new ModelData(new ItemStack(Material.BREAD)).apply(null,source); + assertEquals((byte)1,result.getItemMeta().getPersistentDataContainer().get(key("flag"),PersistentDataType.BYTE)); + assertArrayEquals(new byte[]{1,2},result.getItemMeta().getPersistentDataContainer().get(key("bytes"),PersistentDataType.BYTE_ARRAY)); + } + + private static NamespacedKey key(String key) { return new NamespacedKey("foodtest",key); } + private static TagTrack track(String id,String step) { return new TagTrack(id,false,List.of(new TagStep(step,step,0,1,1))); } + private static void state(MemoryConfiguration config,String id,int weight,int stage,List tags,String item) { + config.set(id+".weight",weight); config.set(id+".stage",stage); config.set(id+".tags",tags); config.set(id+".gui.item",item); + } +} diff --git a/src/test/java/net/tfminecraft/cooking/liquid/LiquidContainerCoverageTest.java b/src/test/java/net/tfminecraft/cooking/liquid/LiquidContainerCoverageTest.java new file mode 100644 index 0000000..551ceaf --- /dev/null +++ b/src/test/java/net/tfminecraft/cooking/liquid/LiquidContainerCoverageTest.java @@ -0,0 +1,275 @@ +package net.tfminecraft.cooking.liquid; + +import static org.junit.jupiter.api.Assertions.*; +import static org.mockito.ArgumentMatchers.*; +import static org.mockito.Mockito.*; + +import java.util.LinkedHashMap; +import java.util.Map; +import java.util.UUID; + +import org.bukkit.Chunk; +import org.bukkit.Location; +import org.bukkit.Material; +import org.bukkit.configuration.MemoryConfiguration; +import org.bukkit.event.world.ChunkLoadEvent; +import org.bukkit.inventory.ItemStack; +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.CsvSource; +import org.junit.jupiter.params.provider.ValueSource; +import org.mockito.MockedStatic; + +import net.tfminecraft.cooking.Cooking; +import net.tfminecraft.cooking.cache.FurnitureCache; +import net.tfminecraft.cooking.cache.ItemCache; +import net.tfminecraft.cooking.cup.CupItems; +import net.tfminecraft.cooking.cup.MilkBucketConverter; +import net.tfminecraft.cooking.item.FoodItem; +import net.tfminecraft.cooking.item.tag.TagTrack; +import net.tfminecraft.cooking.loader.TrackLoader; +import net.tfminecraft.cooking.sausagemaker.SausageMakerCoverageTest.Station; +import net.tfminecraft.cooking.sausagemaker.SausageMakerCoverageTest.Workshop; +import net.tfminecraft.cooking.utils.InventoryAdder; +import net.tfminecraft.interactiblefurniture.events.FurnitureBreakEvent; +import net.tfminecraft.interactiblefurniture.events.FurniturePlaceEvent; +import net.tfminecraft.interactiblefurniture.events.FurnitureSlotItemTakeEvent; +import net.tfminecraft.interactiblefurniture.furniture.Furniture; +import net.tfminecraft.interactiblefurniture.furniture.PlacedSlot; + +class LiquidContainerCoverageTest { + Workshop env; + Station barrel; + LiquidContainerHandler handler; + MockedStatic cups; + MockedStatic converter; + Map placed; + ItemStack waterCup, milkCup; + String oldContainer, oldWater, oldMilk; + int oldPerBucket, oldMaximum; + + @BeforeEach void open() { + oldContainer = FurnitureCache.liquidContainer; oldWater = ItemCache.liquidBlockWater; oldMilk = ItemCache.liquidBlockMilk; + oldPerBucket = ItemCache.blocksPerBucket; oldMaximum = ItemCache.maxBlocks; + env = new Workshop(); handler = new LiquidContainerHandler(); + FurnitureCache.liquidContainer = "barrel"; ItemCache.liquidBlockWater = "model.water"; ItemCache.liquidBlockMilk = "model.milk"; + ItemCache.blocksPerBucket = 3; ItemCache.maxBlocks = 6; + barrel = env.station("barrel"); for (String id : LiquidContainerSlots.all()) barrel.define(id); + placed = new LinkedHashMap<>(); placed.put(barrel.id, barrel.furniture); when(env.manager.getPlacedFurniture()).thenReturn(placed); + env.cache.when(() -> ItemCache.isMilkBucket(any())).thenAnswer(inv -> { + ItemStack item = inv.getArgument(0); return item != null && item.getType() == Material.MILK_BUCKET; + }); + env.cache.when(() -> ItemCache.isEmptyCup(any())).thenAnswer(inv -> { + ItemStack item = inv.getArgument(0); return item != null && item.getType() == Material.PAPER; + }); + converter = env.scoped(mockStatic(MilkBucketConverter.class)); + converter.when(() -> MilkBucketConverter.convertIfNeeded(any(), any())).thenAnswer(inv -> inv.getArgument(1)); + cups = env.scoped(mockStatic(CupItems.class)); + waterCup = env.stack(env.food("cup_of_water", null, 1), Material.POTION, 1); + milkCup = env.stack(env.food("cup_of_milk", "Goat", 4), Material.POTION, 1); + cups.when(CupItems::cupOfWater).thenAnswer(inv -> waterCup.clone()); + cups.when(() -> CupItems.cupOfMilk(any(), anyInt(), anyInt(), anyString())).thenAnswer(inv -> milkCup.clone()); + } + + @AfterEach void close() { + LiquidContainerAging.stopAll(); env.server.getScheduler().cancelTasks(Cooking.plugin); env.close(); + FurnitureCache.liquidContainer = oldContainer; ItemCache.liquidBlockWater = oldWater; ItemCache.liquidBlockMilk = oldMilk; + ItemCache.blocksPerBucket = oldPerBucket; ItemCache.maxBlocks = oldMaximum; + } + + void fill(String type, int count) { LiquidContainerState.setType(barrel.furniture, type); LiquidContainerState.setBlocks(barrel.furniture, count); } + void click() { handler.onInteract(barrel.interact()); } + ItemStack milk(int quality, int freshness, String origin) { + FoodItem food = env.food("milk_bucket", origin, quality); + TagTrack track = new TagTrack(TrackLoader.getByString("freshness")); track.setValue(freshness); food.addOrModifyTrack(track); + return env.stack(food, Material.MILK_BUCKET, 1); + } + + @Test + void stateDefaultsClampNegativeAmountsAndClearOnlyLiquidVariables() { + assertNull(LiquidContainerState.getType(barrel.furniture)); assertTrue(LiquidContainerState.isEmpty(barrel.furniture)); + assertEquals(1, LiquidContainerState.getMilkQuality(barrel.furniture)); assertEquals("Cow", LiquidContainerState.getMilkOrigin(barrel.furniture)); + assertEquals(0, LiquidContainerState.getDairyFreshness(barrel.furniture)); + assertTrue(Math.abs(System.currentTimeMillis() - LiquidContainerState.getLastUpdate(barrel.furniture)) < 1000); + barrel.variables.put(LiquidContainerState.VAR_TYPE, 3); barrel.variables.put(LiquidContainerState.VAR_BLOCKS, "bad"); + assertNull(LiquidContainerState.getType(barrel.furniture)); assertEquals(0, LiquidContainerState.getBlocks(barrel.furniture)); + fill("MILK", 1); assertTrue(LiquidContainerState.canAccept(barrel.furniture, "milk")); assertFalse(LiquidContainerState.canAccept(barrel.furniture, "water")); + LiquidContainerState.setBlocks(barrel.furniture, -5); assertTrue(LiquidContainerState.canAccept(barrel.furniture, "water")); + assertEquals(6, LiquidContainerState.addBlocks(barrel.furniture, 12, 6)); assertEquals(5, LiquidContainerState.removeBlock(barrel.furniture)); + LiquidContainerState.setMilkSnapshot(barrel.furniture, 4, -7, "Goat"); + assertEquals(4, LiquidContainerState.getMilkQuality(barrel.furniture)); assertEquals("Goat", LiquidContainerState.getMilkOrigin(barrel.furniture)); + assertEquals(0, LiquidContainerState.getDairyFreshness(barrel.furniture)); + LiquidContainerState.setType(barrel.furniture, null); assertNull(LiquidContainerState.getType(barrel.furniture)); + barrel.variables.put("unrelated", 8); LiquidContainerState.clear(barrel.furniture); assertEquals(Map.of("unrelated", 8), barrel.variables); + assertEquals(0, LiquidContainerState.removeBlock(barrel.furniture)); + } + + @Test + void milkAgingUsesElapsedWholeSecondsAndCarriesScaledFractions() { + MemoryConfiguration settings = new MemoryConfiguration(); settings.set("age.freshness", 4.0); + env.templates.put("milk_bucket", new FoodItem("milk_bucket", settings)); + fill("water", 2); LiquidContainerState.tickAge(barrel.furniture); assertFalse(barrel.variables.containsKey(LiquidContainerState.VAR_LAST_UPDATE)); + fill("milk", 0); LiquidContainerState.tickAge(barrel.furniture); assertFalse(barrel.variables.containsKey(LiquidContainerState.VAR_LAST_UPDATE)); + fill("milk", 2); LiquidContainerState.setMilkSnapshot(barrel.furniture, 3, 7, "Cow"); + barrel.variables.put(LiquidContainerState.VAR_LAST_UPDATE, System.currentTimeMillis() + 100_000L); + LiquidContainerState.tickAge(barrel.furniture); assertEquals(7, LiquidContainerState.getDairyFreshness(barrel.furniture)); + barrel.variables.put(LiquidContainerState.VAR_LAST_UPDATE, System.currentTimeMillis()); + LiquidContainerState.tickAge(barrel.furniture); assertEquals(7, LiquidContainerState.getDairyFreshness(barrel.furniture)); + barrel.variables.put(LiquidContainerState.VAR_LAST_UPDATE, System.currentTimeMillis() - 2_000L); + LiquidContainerState.tickAge(barrel.furniture); assertEquals(7, LiquidContainerState.getDairyFreshness(barrel.furniture)); + assertEquals(0.5, (Double) barrel.variables.get(LiquidContainerState.VAR_FRESHNESS_REMAINDER), 0.001); + barrel.variables.put(LiquidContainerState.VAR_LAST_UPDATE, System.currentTimeMillis() - 2_000L); + LiquidContainerState.tickAge(barrel.furniture); assertEquals(8, LiquidContainerState.getDairyFreshness(barrel.furniture)); + assertEquals(0.0, (Double) barrel.variables.get(LiquidContainerState.VAR_FRESHNESS_REMAINDER), 0.001); + } + + @Test + void liquidDisplaysShowIndependentLayersAndClearUnusedLevels() { + fill("water", 3); LiquidContainerDisplay.sync(barrel.furniture); + assertEquals(3, barrel.active.size()); + assertNotSame(barrel.active.get("liquid_1").getCurrentItem(), barrel.active.get("liquid_2").getCurrentItem()); + verify(env.api.getCreator()).getItemFromPath("model.water"); + fill("milk", 6); ItemCache.maxBlocks = 8; LiquidContainerDisplay.sync(barrel.furniture); + assertEquals(6, barrel.active.size()); verify(env.api.getCreator()).getItemFromPath("model.milk"); + for (int level = 1; level <= 6; level++) assertEquals("liquid_" + level, LiquidContainerSlots.slotForLevel(level)); + assertNull(LiquidContainerSlots.slotForLevel(0)); assertNull(LiquidContainerSlots.slotForLevel(7)); + LiquidContainerDisplay.clearAll(barrel.furniture); assertTrue(barrel.active.isEmpty()); + barrel.definitions.remove("liquid_1"); fill("water", 1); LiquidContainerDisplay.sync(barrel.furniture); assertTrue(barrel.active.isEmpty()); + when(barrel.furniture.getType()).thenReturn(null); LiquidContainerDisplay.sync(barrel.furniture); LiquidContainerDisplay.clearAll(barrel.furniture); + assertTrue(barrel.active.isEmpty()); + } + + @Test + void missingLiquidModelsDoNotReplaceExistingDisplays() { + PlacedSlot slot = barrel.place("liquid_1", new ItemStack(Material.STONE)); fill("water", 1); + when(env.api.getCreator().getItemFromPath("model.water")).thenReturn(null); + LiquidContainerDisplay.sync(barrel.furniture); verify(slot, never()).forceModel(any()); verify(slot, never()).clearModel(); + } + + @Test + void waterBucketsFillToCapacityAndRejectMixingWithMilk() { + env.hold(new ItemStack(Material.WATER_BUCKET)); click(); + assertEquals(3, LiquidContainerState.getBlocks(barrel.furniture)); assertEquals("water", LiquidContainerState.getType(barrel.furniture)); + assertEquals(Material.BUCKET, env.player.getInventory().getItemInMainHand().getType()); assertEquals(3, barrel.active.size()); + env.hold(new ItemStack(Material.WATER_BUCKET)); click(); assertEquals(6, LiquidContainerState.getBlocks(barrel.furniture)); + env.hold(new ItemStack(Material.WATER_BUCKET)); click(); assertEquals(Material.WATER_BUCKET, env.player.getInventory().getItemInMainHand().getType()); + assertTrue(env.messages.contains("The container is full.")); + fill("milk", 2); click(); assertTrue(env.messages.contains("This container already has milk.")); + assertEquals(2, LiquidContainerState.getBlocks(barrel.furniture)); verify(env.manager, times(2)).markDirty(barrel.furniture); + } + + @ParameterizedTest + @CsvSource({"WATER_BUCKET,0", "WATER_BUCKET,-2", "MILK_BUCKET,0", "MILK_BUCKET,-2"}) + void invalidBucketVolumesDoNotConsumeThePlayersBucket(Material bucket, int amount) { + ItemCache.blocksPerBucket = amount; env.hold(new ItemStack(bucket)); click(); + assertEquals(bucket, env.player.getInventory().getItemInMainHand().getType()); + assertTrue(LiquidContainerState.isEmpty(barrel.furniture)); assertTrue(barrel.active.isEmpty()); + converter.verify(() -> MilkBucketConverter.convertIfNeeded(any(), any()), never()); + verify(env.manager, never()).markDirty(barrel.furniture); + } + + @Test + void milkRetainsTheFirstBatchSnapshotAndRejectsFullOrWaterContainers() { + ItemStack converted = milk(4, 2, "Goat"); + converter.when(() -> MilkBucketConverter.convertIfNeeded(any(), any())).thenReturn(converted); + env.hold(new ItemStack(Material.MILK_BUCKET)); click(); + assertEquals(3, LiquidContainerState.getBlocks(barrel.furniture)); assertEquals("milk", LiquidContainerState.getType(barrel.furniture)); + assertEquals(4, LiquidContainerState.getMilkQuality(barrel.furniture)); assertEquals("Goat", LiquidContainerState.getMilkOrigin(barrel.furniture)); + assertEquals(2, LiquidContainerState.getDairyFreshness(barrel.furniture)); + converter.when(() -> MilkBucketConverter.convertIfNeeded(any(), any())).thenAnswer(inv -> inv.getArgument(1)); + env.hold(milk(2, 0, "Cow")); click(); assertEquals(6, LiquidContainerState.getBlocks(barrel.furniture)); + assertEquals("Goat", LiquidContainerState.getMilkOrigin(barrel.furniture)); assertEquals(4, LiquidContainerState.getMilkQuality(barrel.furniture)); + env.hold(milk(2, 0, "Cow")); click(); assertTrue(env.messages.contains("The container is full.")); + fill("water", 1); click(); assertTrue(env.messages.contains("This container already has water.")); + assertEquals(Material.MILK_BUCKET, env.player.getInventory().getItemInMainHand().getType()); + } + + @Test + void dispensingReplacesTheLastEmptyCupAndClearsTheLastLiquidBlock() { + fill("water", 1); env.hold(new ItemStack(Material.PAPER)); click(); + assertEquals(waterCup, env.player.getInventory().getItemInMainHand()); assertTrue(LiquidContainerState.isEmpty(barrel.furniture)); + assertNull(LiquidContainerState.getType(barrel.furniture)); assertTrue(barrel.active.isEmpty()); + fill("milk", 2); LiquidContainerState.setMilkSnapshot(barrel.furniture, 4, 2, "Goat"); + env.hold(new ItemStack(Material.PAPER)); click(); + assertEquals(milkCup, env.player.getInventory().getItemInMainHand()); assertEquals(1, LiquidContainerState.getBlocks(barrel.furniture)); + cups.verify(() -> CupItems.cupOfMilk(env.player, 4, 2, "Goat")); + } + + @Test + void dispensingFromAStackRequiresSpaceAndDropsDeliveryLeftovers() { + fill("water", 3); ItemStack held = env.hold(new ItemStack(Material.PAPER, 3)); click(); + assertEquals(2, held.getAmount()); assertEquals(2, LiquidContainerState.getBlocks(barrel.furniture)); + assertEquals(waterCup, env.player.getInventory().getItem(1)); + try (MockedStatic delivery = mockStatic(InventoryAdder.class)) { + delivery.when(() -> InventoryAdder.addItem(any(), any())).thenAnswer(inv -> inv.getArgument(1)); + click(); + } + assertEquals(1, held.getAmount()); assertEquals(1, env.dropped.size()); assertEquals(waterCup, env.dropped.getFirst()); + for (int slot = 0; slot < 36; slot++) env.player.getInventory().setItem(slot, new ItemStack(Material.STONE, 64)); + held = env.hold(new ItemStack(Material.PAPER, 2)); click(); + assertEquals(2, held.getAmount()); assertEquals(1, LiquidContainerState.getBlocks(barrel.furniture)); + assertTrue(env.messages.contains("You have no room to carry another cup.")); + env.hold(new ItemStack(Material.PAPER)); click(); assertTrue(LiquidContainerState.isEmpty(barrel.furniture)); + } + + @ParameterizedTest + @ValueSource(strings = {"null", "air"}) + void failedCupCreationPreservesTheHeldCupsAndStoredLiquid(String failure) { + fill("water", 2); ItemStack held = env.hold(new ItemStack(Material.PAPER, 3)); + ItemStack invalid = failure.equals("air") ? new ItemStack(Material.AIR) : null; + cups.when(CupItems::cupOfWater).thenReturn(invalid); click(); + assertEquals(3, held.getAmount()); assertEquals(2, LiquidContainerState.getBlocks(barrel.furniture)); + verify(env.manager, never()).markDirty(barrel.furniture); + } + + @Test + void unsupportedInteractionsAndDirectLayerTakingLeaveTheContentsAlone() { + click(); assertTrue(env.messages.contains("Hold a bucket or empty cup to use the container.")); + env.hold(new ItemStack(Material.STICK)); click(); assertTrue(env.messages.contains("Hold a water bucket, milk bucket, or empty cup.")); + env.hold(new ItemStack(Material.PAPER)); click(); assertTrue(env.messages.contains("The container is empty.")); + Station chair = env.station("chair"); var ignored = chair.interact(); handler.onInteract(ignored); assertFalse(ignored.isCancelled()); + var layer = new FurnitureSlotItemTakeEvent(env.player, barrel.furniture, barrel.define("liquid_1"), waterCup); + handler.onTake(layer); assertTrue(layer.isCancelled()); + var decoration = new FurnitureSlotItemTakeEvent(env.player, barrel.furniture, barrel.define("decoration"), waterCup); + handler.onTake(decoration); assertFalse(decoration.isCancelled()); + when(decoration.getSlot().getId()).thenReturn(null); handler.onTake(decoration); assertFalse(decoration.isCancelled()); + var other = new FurnitureSlotItemTakeEvent(env.player, chair.furniture, chair.define("liquid_1"), waterCup); + handler.onTake(other); assertFalse(other.isCancelled()); + } + + @Test + void agingTasksStartOnceStopWhenEmptyAndDoNotSurviveExplicitCleanup() { + LiquidContainerAging.start(null); LiquidContainerAging.stop(null); LiquidContainerAging.stop(barrel.furniture); + LiquidContainerAging.start(barrel.furniture); fill("milk", 0); LiquidContainerAging.start(barrel.furniture); + fill("milk", 2); LiquidContainerState.setMilkSnapshot(barrel.furniture, 4, 0, "Cow"); + LiquidContainerAging.start(barrel.furniture); LiquidContainerAging.start(barrel.furniture); + barrel.variables.put(LiquidContainerState.VAR_LAST_UPDATE, System.currentTimeMillis() - 2_000L); + env.server.getScheduler().performTicks(20); assertTrue(LiquidContainerState.getDairyFreshness(barrel.furniture) >= 2); + verify(env.manager).markDirty(barrel.furniture); + fill("water", 1); env.server.getScheduler().performTicks(40); verify(env.manager).markDirty(barrel.furniture); + fill("milk", 2); LiquidContainerAging.start(barrel.furniture); LiquidContainerState.setBlocks(barrel.furniture, 0); + env.server.getScheduler().performTicks(20); verify(env.manager).markDirty(barrel.furniture); + fill("milk", 2); LiquidContainerAging.start(barrel.furniture); LiquidContainerAging.stopAll(); + env.server.getScheduler().performTicks(20); verify(env.manager).markDirty(barrel.furniture); + } + + @Test + void placementResetsTheContainerAndChunkLoadResumesOnlyMatchingFurniture() { + Station chair = env.station("chair"); placed.put(chair.id, chair.furniture); + fill("milk", 3); barrel.place("liquid_1", new ItemStack(Material.STONE)); barrel.variables.put("unrelated", 4); + handler.onPlace(new FurniturePlaceEvent(chair.furniture, env.player)); + handler.onPlace(new FurniturePlaceEvent(barrel.furniture, env.player)); + assertEquals(Map.of("unrelated", 4), barrel.variables); assertTrue(barrel.active.isEmpty()); + fill("milk", 1); handler.resumeLoadedContainers(); assertEquals(1, barrel.active.size()); + clearInvocations(env.manager); handler.onBreak(new FurnitureBreakEvent(chair.furniture, env.player)); + handler.onBreak(new FurnitureBreakEvent(barrel.furniture, env.player)); env.server.getScheduler().performTicks(20); verifyNoInteractions(env.manager); + Chunk chunk = mock(Chunk.class), distant = mock(Chunk.class); + when(env.world.getChunkAt(any(Location.class))).thenReturn(chunk); + Location far = mock(Location.class); when(far.getChunk()).thenReturn(distant); when(chair.furniture.getLoc()).thenReturn(far); + handler.onChunkLoad(new ChunkLoadEvent(chunk, false)); env.server.getScheduler().performTicks(21); + verify(env.manager).markDirty(barrel.furniture); + fill("water", 2); handler.resumeLoadedContainers(); assertEquals(2, barrel.active.size()); + } +} diff --git a/src/test/java/net/tfminecraft/cooking/loader/ConfigurationLoadersCoverageTest.java b/src/test/java/net/tfminecraft/cooking/loader/ConfigurationLoadersCoverageTest.java new file mode 100644 index 0000000..195f5fb --- /dev/null +++ b/src/test/java/net/tfminecraft/cooking/loader/ConfigurationLoadersCoverageTest.java @@ -0,0 +1,250 @@ +package net.tfminecraft.cooking.loader; + +import static org.junit.jupiter.api.Assertions.*; +import static org.mockito.Mockito.*; +import java.io.ByteArrayOutputStream; +import java.io.File; +import java.io.PrintStream; +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.List; +import java.util.Locale; +import java.util.Map; +import java.util.Set; +import java.util.function.Consumer; +import java.util.logging.Logger; +import org.bukkit.configuration.file.YamlConfiguration; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.io.TempDir; +import org.mockbukkit.mockbukkit.MockBukkit; +import net.tfminecraft.cooking.Cooking; +import net.tfminecraft.cooking.cache.CategoryDictionary; +import net.tfminecraft.cooking.cache.FurnitureCache; +import net.tfminecraft.cooking.cache.ItemCache; +import net.tfminecraft.cooking.cache.NamingConfig; +import net.tfminecraft.cooking.heat.HeatLoader; +import net.tfminecraft.cooking.heat.HeatLookup; +import net.tfminecraft.cooking.heat.HeatSourceType; +import net.tfminecraft.cooking.heat.HeatSources; +import net.tfminecraft.cooking.nutrition.NutritionConfig; +import net.tfminecraft.cooking.nutrition.NutritionLog; +import net.tfminecraft.cooking.quality.CompositionConfig; +import net.tfminecraft.cooking.quality.CompositionContext; +import net.tfminecraft.cooking.quality.CompositionRole; +import net.tfminecraft.cooking.quality.PermissionEffectsConfig; +import net.tfminecraft.cooking.quality.QualityConfig; +import net.tfminecraft.interactiblefurniture.furniture.Furniture; + +class ConfigurationLoadersCoverageTest { + @TempDir Path directory; + Cooking previousPlugin; + @BeforeEach void setup() { + 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(Logger.getAnonymousLogger()); + } + @AfterEach void cleanup() throws Exception { + new ConfigLoader().loadConfig(save(new YamlConfiguration())); + QualityConfig.apply(1,5,null,null); + CompositionConfig.apply(true,null,null,null,null,true,.12,.15,true); + PermissionEffectsConfig.apply(null,null); + NamingConfig.apply(List.of("sweet"),3,2,2,null,null,null); + NutritionLog.configure(false,false,null); + MockBukkit.unmock(); Cooking.plugin=previousPlugin; + } + + @Test void mainConfigurationLoadsStationItemsAndMappingTablesAndReplacesStaleEntries() throws Exception { + YamlConfiguration config=new YamlConfiguration(); + config.set("frying_pan","pan"); config.set("saucepan","sauce"); config.set("pot","pot"); + config.set("butter-churn","churn"); config.set("mixing-bowl","mixing"); config.set("liquid-container","jug"); + config.set("pot-soup-scoops",0); config.set("pot-soup-height-divisor",0); config.set("trough-items-per-click",0); + config.set("water","v.water_bucket"); config.set("flour-item","v.wheat"); config.set("butter-churn-count",4); + config.set("mixing-stir-count",5); config.set("oven-fuel",List.of("v.coal","v.charcoal")); + config.set("mixing-ingredients.flour.input","v.wheat"); config.set("mixing-ingredients.flour.input-food","grain"); + config.set("mixing-ingredients.flour.model","v.paper"); config.set("mixing-ingredients.flour.output","v.bread"); + config.set("liquids",List.of("v.water_bucket v.paper","bad","too many fields")); + config.set("colours",List.of("v.carrot aa5500","bad")); + config.set("dictionary.grain","Grain"); config.set("dictionary.skipped","none"); + config.set("sauce-dict",List.of("aa5500 v.paper|v.bowl","bad")); + new ConfigLoader().loadConfig(save(config)); + assertEquals("pan",FurnitureCache.fryingPan); assertEquals("sauce",FurnitureCache.saucePan); + assertEquals("churn",FurnitureCache.butterChurn); assertEquals("mixing",FurnitureCache.mixingBowl); assertEquals("jug",FurnitureCache.liquidContainer); + assertEquals(1,ItemCache.potSoupScoops); assertEquals(1,ItemCache.potSoupHeightDivisor); assertEquals(1,ItemCache.troughItemsPerClick); + assertEquals(4,ItemCache.butterChurnCount); assertEquals(5,ItemCache.mixingStirCount); + assertEquals(List.of("v.coal","v.charcoal"),ItemCache.ovenFuel); + assertEquals(Map.of("v.water_bucket","v.paper"),ItemCache.liquidModels); + assertEquals(Map.of("v.carrot","aa5500"),ItemCache.colourMap); + assertEquals(Map.of("grain","Grain"),CategoryDictionary.dictionary); + assertEquals(Map.of("aa5500","v.paper|v.bowl"),CategoryDictionary.sauceDict); + var ingredient=ItemCache.mixingIngredients.get("flour"); + assertEquals("v.wheat",ingredient.getInput()); assertEquals("grain",ingredient.getInputFood()); + assertEquals("v.paper",ingredient.getModel()); assertEquals("v.bread",ingredient.getOutput()); + new ConfigLoader().loadConfig(save(new YamlConfiguration())); + assertTrue(ItemCache.liquidModels.isEmpty()); assertTrue(ItemCache.colourMap.isEmpty()); + assertTrue(ItemCache.mixingIngredients.isEmpty()); assertTrue(CategoryDictionary.dictionary.isEmpty()); + assertTrue(CategoryDictionary.sauceDict.isEmpty()); assertTrue(ItemCache.ovenFuel.isEmpty()); + assertEquals(3,ItemCache.potSoupScoops); assertEquals(4,ItemCache.potSoupHeightDivisor); + } + + @Test void heatConfigurationRecognizesSupportedTypesAndRejectsInvalidMappings() { + YamlConfiguration config=new YamlConfiguration(); + config.set("heat.sources.oven.type","oven"); config.set("heat.sources.fire.type","campfire"); + config.set("heat.sources.bad.type","unknown"); + config.set("heat.consumers.pan.source","fire"); config.set("heat.consumers.pan.lookup","block-below"); + config.set("heat.consumers.bad.lookup","block-below"); config.set("heat.consumers.unknown.source","fire"); + config.set("heat.consumers.unknown.lookup","unknown"); + HeatLoader.load(config); + assertTrue(HeatSources.isSource(furniture("OVEN"))); assertTrue(HeatSources.isSource(furniture("fire"))); + assertFalse(HeatSources.isSource(furniture("bad"))); assertTrue(HeatSources.isConsumer(furniture("PAN"))); + assertFalse(HeatSources.isConsumer(furniture("unknown"))); assertFalse(HeatSources.isConsumer(null)); + assertFalse(HeatSources.isSource(null)); assertFalse(HeatSources.hasHeat(null)); + assertFalse(HeatSources.hasHeat(furniture("unknown"))); assertTrue(HeatSources.findSource(null).isEmpty()); + assertTrue(HeatSources.findSource(furniture("unknown")).isEmpty()); assertFalse(HeatSources.stationHasHeat(null)); + assertFalse(HeatSources.stationHasHeat(furniture("unknown"))); + assertNull(HeatSourceType.fromConfig(null)); assertNull(HeatLookup.fromConfig(null)); assertNull(HeatLookup.fromConfig("other")); + config=new YamlConfiguration(); config.createSection("heat"); HeatLoader.load(config); + assertFalse(HeatSources.isSource(furniture("oven"))); + } + + @Test void qualityAndCompositionFilesLoadRolesPermissionsAndNumericSettings() throws Exception { + YamlConfiguration quality=new YamlConfiguration(); quality.set("pickup.min",2); quality.set("pickup.max",4); + quality.set("composition.exclude-categories",List.of("SALT"," ")); + quality.set("nutrition-from-quality.2",.65); quality.set("nutrition-from-quality.invalid",10); + new QualityConfigLoader().load(save(quality)); + assertEquals(2,QualityConfig.getPickupMin()); assertEquals(4,QualityConfig.getPickupMax()); + assertEquals(Set.of("salt"),QualityConfig.getExcludeCategories()); assertTrue(QualityConfig.isExcludedCategory("SALT")); + assertFalse(QualityConfig.isExcludedCategory(null)); assertEquals(.65,QualityConfig.nutritionMultiplier(2)); + assertEquals(1.8,QualityConfig.nutritionMultiplier(99)); + YamlConfiguration config=new YamlConfiguration(); config.set("legacy-mode",false); + config.set("roles.main",List.of("MEAT"," ")); config.set("roles.extra",List.of("SALT")); config.set("roles.neutral",List.of("WATER")); + config.set("modifiers.enabled",false); config.set("modifiers.gap-up-chance-per-star",.25); + config.set("modifiers.gap-down-chance-per-star",.35); config.set("modifiers.craft-quality-pct-bonus",false); + config.set("context-overrides.cutting_board.main",List.of("WATER"," ")); + config.set("context-overrides.cutting_board.extra",List.of("MEAT")); + config.set("context-overrides.cutting_board.neutral",List.of("SALT")); + config.set("context-overrides.baking","scalar"); config.set("context-overrides.invalid.main",List.of("meat")); + new CompositionConfigLoader().load(save(config)); + assertFalse(CompositionConfig.isLegacyMode()); assertFalse(CompositionConfig.isModifiersEnabled()); assertFalse(CompositionConfig.isCraftQualityPctBonus()); + assertEquals(.25,CompositionConfig.getGapUpChancePerStar()); assertEquals(.35,CompositionConfig.getGapDownChancePerStar()); + assertEquals(CompositionRole.MAIN,CompositionConfig.getRole(null)); assertEquals(CompositionRole.MAIN,CompositionConfig.getRole("MEAT")); + assertEquals(CompositionRole.EXTRA,CompositionConfig.getRole("SALT")); assertEquals(CompositionRole.NEUTRAL,CompositionConfig.getRole("water")); + assertEquals(CompositionRole.NEUTRAL,CompositionConfig.getRole("unknown")); + assertEquals(CompositionRole.MAIN,CompositionConfig.getRole(null,CompositionContext.CUTTING_BOARD)); + assertEquals(CompositionRole.EXTRA,CompositionConfig.getRole("meat",CompositionContext.CUTTING_BOARD)); + assertEquals(CompositionRole.NEUTRAL,CompositionConfig.getRole("salt",CompositionContext.CUTTING_BOARD)); + assertEquals(CompositionRole.MAIN,CompositionConfig.getRole("water",CompositionContext.CUTTING_BOARD)); + assertEquals(CompositionRole.NEUTRAL,CompositionConfig.getRole("unknown",CompositionContext.CUTTING_BOARD)); + assertEquals(CompositionRole.MAIN,CompositionConfig.getRole("meat",null)); + var emptyRoles=new CompositionConfig.RoleSets(null,null,null); + assertTrue(emptyRoles.getMains().isEmpty()); assertTrue(emptyRoles.getExtras().isEmpty()); assertTrue(emptyRoles.getNeutrals().isEmpty()); + for(CompositionContext context:CompositionContext.values()) assertTrue(context.filterExcludedCategories()); + new CompositionConfigLoader().load(save(new YamlConfiguration())); assertTrue(CompositionConfig.isLegacyMode()); + new QualityConfigLoader().load(save(new YamlConfiguration())); assertEquals(1,QualityConfig.getPickupMin()); + } + + @Test void namingFilesNormalizeAndReplaceNestedLabelsAndAdjectives() throws Exception { + YamlConfiguration config=new YamlConfiguration(); config.set("prefix-tracks",List.of("sweet","freshness")); + config.set("fillers.max",4); config.set("addons.max",3); config.set("dough-fillers.max",2); + config.set("categories.addon",List.of("SPICE"," ")); config.set("origin-adjectives.FISH","Fishy"); config.set("origin-adjectives.empty"," "); + config.set("tag-label-defaults.bread.freshness.fresh","Garden fresh"); config.set("tag-label-defaults.bread.freshness.blank"," "); + config.set("tag-label-defaults.bad","scalar"); config.set("tag-label-defaults.bread.bad","scalar"); + config.createSection("tag-label-defaults.empty"); config.createSection("tag-label-defaults.bread.empty"); + new NamingLoader().load(save(config)); + assertEquals(List.of("sweet","freshness"),NamingConfig.getPrefixTracks()); assertEquals(4,NamingConfig.getFillerMax()); + assertEquals(3,NamingConfig.getAddonMax()); assertEquals(2,NamingConfig.getDoughFillerMax()); + assertEquals(Set.of("spice"),NamingConfig.getCategories("addon")); assertTrue(NamingConfig.isCategory("addon","SPICE")); + assertFalse(NamingConfig.isCategory("addon",null)); assertTrue(NamingConfig.getCategories("unknown").isEmpty()); + assertEquals(Set.of("addon"),NamingConfig.allCategoryBuckets()); + assertEquals("Garden fresh",NamingConfig.getTagLabelDefault("BREAD","FRESHNESS","FRESH")); + assertNull(NamingConfig.getTagLabelDefault(null,"freshness","fresh")); assertNull(NamingConfig.getTagLabelDefault("bread",null,"fresh")); + assertNull(NamingConfig.getTagLabelDefault("bread","freshness",null)); + assertNull(NamingConfig.getTagLabelDefault("unknown","freshness","fresh")); assertNull(NamingConfig.getTagLabelDefault("bread","unknown","fresh")); + assertEquals("Fishy",NamingConfig.getOriginAdjective("fish")); assertNull(NamingConfig.getOriginAdjective(null)); + new NamingLoader().load(save(new YamlConfiguration())); assertNull(NamingConfig.getOriginAdjective("fish")); + NamingConfig.apply(null,0,0,0,null,null,null); assertEquals(1,NamingConfig.getFillerMax()); assertTrue(NamingConfig.getPrefixTracks().isEmpty()); + } + + @Test void permissionEffectsIgnoreInvalidRecordsAndExposeImmutableSnapshots() throws Exception { + YamlConfiguration config=new YamlConfiguration(); + for(String group:List.of("pickup","composition")) { + config.set(group+".scalar","bad"); config.set(group+".empty.permission"," "); + config.createSection(group+".missing"); config.set(group+".chef.permission","cooking.chef"); + } + config.set("pickup.chef.min-quality",3); config.set("pickup.chef.roll-bias",1); + config.set("composition.chef.chance",.5); config.set("composition.chef.boost",2); + new PermissionEffectsLoader().load(save(config)); + assertEquals(List.of(new PermissionEffectsConfig.PickupEffect("chef","cooking.chef",3,1)),PermissionEffectsConfig.getPickupEffects()); + assertEquals(List.of(new PermissionEffectsConfig.CompositionEffect("chef","cooking.chef",.5,2)),PermissionEffectsConfig.getCompositionEffects()); + assertThrows(UnsupportedOperationException.class,()->PermissionEffectsConfig.getPickupEffects().clear()); + new PermissionEffectsLoader().load(save(new YamlConfiguration())); assertTrue(PermissionEffectsConfig.getPickupEffects().isEmpty()); + assertTrue(PermissionEffectsConfig.getCompositionEffects().isEmpty()); + } + + @Test void nutritionSettingsValidateVarietyAndDietTiersAndKeepLegacyDrainUnits() { + YamlConfiguration config=new YamlConfiguration(); config.set("nutrition.max-food",100); config.set("nutrition.respawn-food",150); + config.set("nutrition.max-diet",20); config.set("nutrition.attribute-name","diet"); config.set("nutrition.drain-amount",2); + config.set("nutrition.drain-interval",0); config.set("nutrition.lerp-step-rate",.5); + config.set("nutrition.variety.enabled",false); config.set("nutrition.variety.history-meals",0); + config.set("nutrition.variety.target-ingredients",0); config.set("nutrition.variety.max-penalty-percent",100); + config.set("nutrition.variety.main-weight",-1); config.set("nutrition.variety.extra-weight",-1); + config.set("nutrition.diet-tiers",List.of(Map.of("id","good","min-percent","60","label","Good"),Map.of("id","bad","min-percent",10), + Map.of("id"," ","min-percent",0),Map.of("min-percent",0),Map.of("id","invalid","min-percent","bad"))); + NutritionConfig.load(config); + assertEquals(100,NutritionConfig.maxFood()); assertEquals(100,NutritionConfig.respawnFood()); assertEquals(20,NutritionConfig.maxDiet()); + assertEquals("diet",NutritionConfig.attributeName()); assertEquals(2,NutritionConfig.drainAmount()); assertEquals(1,NutritionConfig.drainIntervalSeconds()); + assertEquals(.5,NutritionConfig.lerpStepRate()); assertFalse(NutritionConfig.varietyEnabled()); + assertEquals(1,NutritionConfig.varietyHistoryMeals()); assertEquals(2,NutritionConfig.varietyTargetIngredients()); + assertEquals(99,NutritionConfig.varietyMaxPenaltyPercent()); assertEquals(0,NutritionConfig.varietyMainWeight()); assertEquals(0,NutritionConfig.varietyExtraWeight()); + assertEquals(2,NutritionConfig.dietTiers().size()); assertEquals("bad",NutritionConfig.resolveTierPercent(-1).getId()); + assertEquals("good",NutritionConfig.resolveTier(20).getId()); assertEquals("Good",NutritionConfig.resolveTierPercent(1000).getLabel()); + config.set("nutrition.drain-interval",null); config.set("nutrition.drain-interval-ticks",60); config.set("nutrition.variety",null); + config.set("nutrition.diet-tiers",List.of(Map.of("id","bad","min-percent","invalid"))); NutritionConfig.load(config); + assertEquals(3,NutritionConfig.drainIntervalSeconds()); assertEquals(6,NutritionConfig.dietTiers().size()); assertTrue(NutritionConfig.varietyEnabled()); + config.set("nutrition.drain-interval-ticks",null); config.set("nutrition.diet-tiers",List.of()); config.set("nutrition.max-diet",0); NutritionConfig.load(config); + assertEquals(120,NutritionConfig.drainIntervalSeconds()); assertEquals("terrible",NutritionConfig.resolveTier(10).getId()); + NutritionConfig.load(new YamlConfiguration()); assertEquals(200,NutritionConfig.maxFood()); assertEquals(80,NutritionConfig.respawnFood()); + } + + @Test void malformedAndMissingFilesDoNotPreventOtherConfigurationFromLoading() throws Exception { + File missing=directory.resolve("missing.yml").toFile(); Path invalid=directory.resolve("invalid.yml"); Files.writeString(invalid,"bad: [unterminated"); + List> loaders=List.of(new ConfigLoader()::loadConfig,new NamingLoader()::load,new QualityConfigLoader()::load, + new CompositionConfigLoader()::load,new PermissionEffectsLoader()::load); + PrintStream previous=System.err; ByteArrayOutputStream errors=new ByteArrayOutputStream(); + try(PrintStream capture=new PrintStream(errors)) { System.setErr(capture); + for(Consumer loader:loaders) { assertDoesNotThrow(()->loader.accept(missing)); assertDoesNotThrow(()->loader.accept(invalid.toFile())); } + } finally { System.setErr(previous); } + assertTrue(errors.toString().contains("FileNotFoundException")); assertTrue(errors.toString().contains("InvalidConfigurationException")); + assertTrue(NutritionConfig.maxFood()>0); assertEquals(1,QualityConfig.getPickupMin()); + } + + @Test void categoryAndContextNamesRemainStableInTurkishLocale() throws Exception { + YamlConfiguration config=new YamlConfiguration(); config.set("roles.main",List.of("FISH")); + config.set("context-overrides.mixing_bowl.extra",List.of("FISH")); + File composition=save(config); config=new YamlConfiguration(); config.set("categories.addon",List.of("SPICE")); + config.set("origin-adjectives.FISH","Fishy"); File naming=save(config); + Locale previous=Locale.getDefault(); + try { Locale.setDefault(Locale.forLanguageTag("tr-TR")); + new CompositionConfigLoader().load(composition); new NamingLoader().load(naming); + assertEquals(CompositionRole.MAIN,CompositionConfig.getRole("fish")); + assertEquals(CompositionRole.EXTRA,CompositionConfig.getRole("fish",CompositionContext.MIXING_BOWL)); + assertTrue(NamingConfig.isCategory("addon","spice")); assertEquals("Fishy",NamingConfig.getOriginAdjective("fish")); + } finally { Locale.setDefault(previous); } + } + + @Test void qualityCategoriesAndHeatTypesIgnoreTheServerLocale() throws Exception { + YamlConfiguration config=new YamlConfiguration(); config.set("composition.exclude-categories",List.of("FISH")); + File quality=save(config); Locale previous=Locale.getDefault(); + try { Locale.setDefault(Locale.forLanguageTag("tr-TR")); + new QualityConfigLoader().load(quality); + assertAll(()->assertTrue(QualityConfig.isExcludedCategory("fish")), + ()->assertEquals(HeatSourceType.CAMPFIRE,HeatSourceType.fromConfig("CAMPFIRE"))); + } finally { Locale.setDefault(previous); } + } + + private File save(YamlConfiguration config) throws Exception { + config.set("logging",false); File file=Files.createTempFile(directory,"config-",".yml").toFile(); config.save(file); return file; + } + private static Furniture furniture(String id) { Furniture furniture=mock(Furniture.class); when(furniture.getId()).thenReturn(id); return furniture; } +} diff --git a/src/test/java/net/tfminecraft/cooking/loader/ResourceLoadersCoverageTest.java b/src/test/java/net/tfminecraft/cooking/loader/ResourceLoadersCoverageTest.java new file mode 100644 index 0000000..f9eb79f --- /dev/null +++ b/src/test/java/net/tfminecraft/cooking/loader/ResourceLoadersCoverageTest.java @@ -0,0 +1,155 @@ +package net.tfminecraft.cooking.loader; + +import static org.junit.jupiter.api.Assertions.*; +import static org.mockito.Mockito.*; + +import java.io.ByteArrayOutputStream; +import java.io.File; +import java.io.PrintStream; +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.ArrayList; +import java.util.HashMap; +import java.util.List; +import java.util.Map; +import java.util.function.Consumer; +import org.bukkit.Material; +import org.bukkit.configuration.file.YamlConfiguration; +import org.bukkit.inventory.ItemStack; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.io.TempDir; +import org.mockbukkit.mockbukkit.MockBukkit; +import org.mockito.MockedStatic; +import net.tfminecraft.cooking.item.FoodItem; +import net.tfminecraft.cooking.item.model.FoodModel; +import net.tfminecraft.cooking.item.tag.TagTrack; +import net.tfminecraft.tlibs.TLibs; +import net.tfminecraft.tlibs.objects.api.ItemAPI; + +class ResourceLoadersCoverageTest { + @TempDir Path directory; + private List foods; + private List models; + private List tracks; + private Map conversions; + + @BeforeEach void setup() { + MockBukkit.mock(); + foods = new ArrayList<>(FoodLoader.oList); + models = new ArrayList<>(ModelLoader.models); + tracks = new ArrayList<>(TrackLoader.oList); + conversions = new HashMap<>(ConversionLoader.conversions); + FoodLoader.oList.clear(); ModelLoader.models.clear(); TrackLoader.oList.clear(); + ConversionLoader.conversions.clear(); + } + + @AfterEach void cleanup() { + FoodLoader.oList.clear(); FoodLoader.oList.addAll(foods); + ModelLoader.models.clear(); ModelLoader.models.addAll(models); + TrackLoader.oList.clear(); TrackLoader.oList.addAll(tracks); + ConversionLoader.conversions.clear(); ConversionLoader.conversions.putAll(conversions); + MockBukkit.unmock(); + } + + @Test void filesLoadInDependencyOrderAndReloadReplacesRemovedEntries() throws Exception { + YamlConfiguration model = new YamlConfiguration(); + model.set("bread.default.gui.item", "v.bread"); model.set("bread.default.weight", 7); + new ModelLoader().load(save(model)); + assertEquals(1, ModelLoader.get().size()); + assertEquals(7, ModelLoader.getById("BREAD").getStates().get("default").getWeight()); + assertNull(ModelLoader.getById("missing")); + + YamlConfiguration track = new YamlConfiguration(); + track.set("freshness.ageable", true); + track.set("freshness.fresh.name", "Fresh"); track.set("freshness.fresh.value", 0); + track.set("freshness.stale.name", "Stale"); track.set("freshness.stale.value", 10); + track.set("warmth.hot.name", "Hot"); + new TrackLoader().load(save(track)); + assertEquals(2, TrackLoader.get().size()); + TagTrack freshness = TrackLoader.getByString("FRESHNESS"); + assertTrue(freshness.isAgeable()); assertEquals(0, freshness.getIndex()); + assertEquals(1, TrackLoader.getByString("warmth").getIndex()); + assertEquals("fresh", freshness.getCurrentStep().getId()); + freshness.setValue(10); assertEquals("stale", freshness.getCurrentStep().getId()); + assertNull(TrackLoader.getByString("missing")); + + YamlConfiguration food = new YamlConfiguration(); + food.set("loaf.name", "Bread"); food.set("loaf.model", "bread"); + food.set("loaf.food", 5); food.set("loaf.nutrition", 3); + new FoodLoader().load(save(food)); + assertEquals(1, FoodLoader.get().size()); + FoodItem loaf = FoodLoader.getByString("LOAF"); + assertEquals("Bread", loaf.getName()); assertEquals(5, loaf.getBaseFood()); + assertEquals(3, loaf.getBaseNutrition()); assertNotNull(loaf.getModel()); + assertNull(FoodLoader.getByString("missing")); + + File empty = save(new YamlConfiguration()); + new FoodLoader().load(empty); new TrackLoader().load(empty); new ModelLoader().load(empty); + assertTrue(FoodLoader.get().isEmpty()); assertTrue(TrackLoader.get().isEmpty()); + assertTrue(ModelLoader.get().isEmpty()); + } + + @Test void scalarEntriesDoNotPreventValidFoodsModelsAndTracksFromLoading() throws Exception { + YamlConfiguration food = new YamlConfiguration(); + food.set("invalid", "scalar"); food.set("loaf.name", "Bread"); + YamlConfiguration track = new YamlConfiguration(); + track.set("invalid", "scalar"); track.set("freshness.fresh.value", 0); + track.set("freshness.invalid", "scalar"); track.set("freshness.stale.value", 10); + YamlConfiguration model = new YamlConfiguration(); + model.set("invalid", "scalar"); model.set("bread.default.gui.item", "v.bread"); + File foodFile = save(food), trackFile = save(track), modelFile = save(model); + assertAll(() -> assertDoesNotThrow(() -> new FoodLoader().load(foodFile)), + () -> assertDoesNotThrow(() -> new TrackLoader().load(trackFile)), + () -> assertDoesNotThrow(() -> new ModelLoader().load(modelFile))); + assertEquals(List.of("loaf"), FoodLoader.get().stream().map(FoodItem::getId).toList()); + assertEquals(List.of("bread"), ModelLoader.get().stream().map(FoodModel::getId).toList()); + assertEquals(1, TrackLoader.get().size()); + assertEquals(2, TrackLoader.getByString("freshness").getSteps().size()); + } + + @Test void conversionStringsKeepStructuredFoodExpressionsAndIgnoreIncompleteRecords() throws Exception { + YamlConfiguration config = new YamlConfiguration(); + config.set("conversions", List.of(" ", "missing", " v.wheat food(type=loaf, amount=2) ")); + new ConversionLoader().load(save(config)); + assertEquals(Map.of("v.wheat", "food(type=loaf, amount=2)"), ConversionLoader.get()); + assertEquals("food(type=loaf, amount=2)", ConversionLoader.getByString("v.wheat")); + assertNull(ConversionLoader.getByString("v.stone")); + ItemStack wheat = new ItemStack(Material.WHEAT); + try (MockedStatic tlibs = mockStatic(TLibs.class)) { + ItemAPI api = mock(ItemAPI.class, RETURNS_DEEP_STUBS); + tlibs.when(TLibs::getItemAPI).thenReturn(api); + when(api.getChecker().checkItemWithPath(wheat, "v.wheat")).thenReturn(true); + assertEquals("food(type=loaf, amount=2)", ConversionLoader.getByItem(wheat)); + assertNull(ConversionLoader.getByItem(new ItemStack(Material.STONE))); + } + new ConversionLoader().load(save(new YamlConfiguration())); + assertTrue(ConversionLoader.get().isEmpty()); + } + + @Test void missingAndMalformedFilesReportErrorsWithoutCrashingInitialLoading() throws Exception { + File missing = directory.resolve("missing.yml").toFile(); + Path invalid = directory.resolve("invalid.yml"); Files.writeString(invalid, "bad: [unterminated"); + List> loaders = List.of(new FoodLoader()::load, new TrackLoader()::load, + new ModelLoader()::load, new ConversionLoader()::load); + PrintStream previous = System.err; + ByteArrayOutputStream errors = new ByteArrayOutputStream(); + try (PrintStream capture = new PrintStream(errors)) { + System.setErr(capture); + for (Consumer loader : loaders) { + assertDoesNotThrow(() -> loader.accept(missing)); + assertDoesNotThrow(() -> loader.accept(invalid.toFile())); + } + } finally { System.setErr(previous); } + assertTrue(errors.toString().contains("FileNotFoundException")); + assertTrue(errors.toString().contains("InvalidConfigurationException")); + assertTrue(FoodLoader.get().isEmpty()); assertTrue(TrackLoader.get().isEmpty()); + assertTrue(ModelLoader.get().isEmpty()); assertTrue(ConversionLoader.get().isEmpty()); + } + + private File save(YamlConfiguration config) throws Exception { + File file = Files.createTempFile(directory, "resource-", ".yml").toFile(); + config.save(file); return file; + } +} diff --git a/src/test/java/net/tfminecraft/cooking/manager/CommandManagerCoverageTest.java b/src/test/java/net/tfminecraft/cooking/manager/CommandManagerCoverageTest.java new file mode 100644 index 0000000..e0bd422 --- /dev/null +++ b/src/test/java/net/tfminecraft/cooking/manager/CommandManagerCoverageTest.java @@ -0,0 +1,521 @@ +package net.tfminecraft.cooking.manager; + +import static org.junit.jupiter.api.Assertions.*; +import static org.mockito.ArgumentMatchers.*; +import static org.mockito.Mockito.*; + +import java.util.ArrayList; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Locale; +import java.util.Map; +import java.util.Optional; +import java.util.UUID; +import java.util.concurrent.atomic.AtomicInteger; + +import net.tfminecraft.cooking.Cooking; +import net.tfminecraft.cooking.baking.*; +import net.tfminecraft.cooking.cache.FurnitureCache; +import net.tfminecraft.cooking.heat.HeatSources; +import net.tfminecraft.cooking.husbandry.*; +import net.tfminecraft.cooking.item.FoodItem; +import net.tfminecraft.cooking.item.IngredientLineage; +import net.tfminecraft.cooking.item.model.FoodModel; +import net.tfminecraft.cooking.item.tag.TagTrack; +import net.tfminecraft.cooking.loader.FoodLoader; +import net.tfminecraft.cooking.loader.ModelLoader; +import net.tfminecraft.cooking.mixing.MixingBowlDisplay; +import net.tfminecraft.cooking.nutrition.NutritionConfig; +import net.tfminecraft.cooking.nutrition.NutritionDisplayService; +import net.tfminecraft.cooking.nutrition.NutritionLog; +import net.tfminecraft.cooking.quality.*; +import net.tfminecraft.cooking.utils.FoodParser; +import net.tfminecraft.cooking.utils.ItemBuilder; +import net.tfminecraft.cooking.utils.NameComposer; +import net.tfminecraft.interactiblefurniture.InteractibleFurniture; +import net.tfminecraft.interactiblefurniture.furniture.*; +import net.tfminecraft.interactiblefurniture.manager.FurnitureManager; +import net.tfminecraft.rpcharacters.RPCharacters; +import net.tfminecraft.rpcharacters.managers.PlayerManager; +import net.tfminecraft.rpcharacters.objects.RPCharacter; +import org.bukkit.Location; +import org.bukkit.Material; +import org.bukkit.command.Command; +import org.bukkit.command.CommandSender; +import org.bukkit.entity.EntityType; +import org.bukkit.entity.LivingEntity; +import org.bukkit.inventory.ItemStack; +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; +import org.mockito.MockedStatic; + +@SuppressWarnings("deprecation") +class CommandManagerCoverageTest { + private final CommandManager commands = new CommandManager(); + private final Command command = mock(Command.class); + private final List> statics = new ArrayList<>(); + private final Map furniture = new LinkedHashMap<>(); + private ServerMock server; + private PlayerMock player; + private Cooking previousPlugin; + private Cooking plugin; + + @BeforeEach void setUp() { + server = MockBukkit.mock(); + player = server.addPlayer("Admin"); + player.setOp(true); + previousPlugin = Cooking.plugin; + plugin = mock(Cooking.class); + Cooking.plugin = plugin; + InteractibleFurniture integration = mock(InteractibleFurniture.class); + FurnitureManager manager = mock(FurnitureManager.class); + when(integration.getFurnitureManager()).thenReturn(manager); + when(manager.getPlacedFurniture()).thenReturn(furniture); + staticMock(InteractibleFurniture.class).when(InteractibleFurniture::getInstance).thenReturn(integration); + } + + @AfterEach void tearDown() { + MockBukkit.unmock(); + for (int i = statics.size() - 1; i >= 0; i--) statics.get(i).close(); + Cooking.plugin = previousPlugin; + } + + @Test void helpUnknownCommandsAndConsoleOnlyPathsExplainWhatToDo() { + run(); + assertTrue(messages().contains("§e/cooking reload")); + run("unknown"); + assertTrue(messages().stream().anyMatch(s -> s.contains("Contexts:"))); + CommandSender console = mock(CommandSender.class); + run(console, "heat"); + verify(console).sendMessage("§cOnly players can use this command."); + } + + @Test void reloadReportsUnavailablePluginOrReloadedModelCounts() { + Cooking.plugin = null; + run("reload"); + assertEquals(List.of("§cPlugin not ready."), messages()); + Cooking.plugin = plugin; + staticMock(ModelLoader.class).when(ModelLoader::get).thenReturn(List.of(mock(FoodModel.class))); + List foods = List.of(food("carrot"), food("beef")); + staticMock(FoodLoader.class).when(FoodLoader::get).thenReturn(foods); + run("RELOAD"); + verify(plugin).reloadAll(); + assertEquals(List.of("§aCooking reloaded (1 models, 2 food types)."), messages()); + } + + @Test void completionsRespectPermissionAndFilterCommandsPlayersAndSpecies() { + player.setOp(false); + assertTrue(tabs("").isEmpty()); + player.setOp(true); + assertEquals(List.of("preview"), tabs("pr")); + assertTrue(tabs("zz").isEmpty()); + server.addPlayer("zoe"); + server.addPlayer("Alice"); + server.addPlayer("Aaron"); + assertEquals(List.of("Aaron", "Admin", "Alice"), tabs("food", "a")); + assertEquals(List.of("spawn", "save"), tabs("husbandry", "s")); + assertEquals(List.of("save"), tabs("husbandry", "sa")); + MockedStatic config = staticMock(HusbandryConfig.class); + config.when(HusbandryConfig::species).thenReturn(Map.of(EntityType.COW, mock(HusbandrySpecies.class), EntityType.PIG, mock(HusbandrySpecies.class))); + assertEquals(List.of("COW"), tabs("husbandry", "spawn", "c")); + assertEquals(List.of("0", "20", "1000"), tabs("husbandry", "spawn", "cow", "")); + assertEquals(List.of("0", "100", "200"), tabs("husbandry", "spawn", "cow", "1", "")); + assertTrue(tabs().isEmpty()); + assertTrue(tabs("other", "").isEmpty()); + assertTrue(tabs("other", "spawn", "").isEmpty()); + assertTrue(tabs("husbandry", "save", "").isEmpty()); + assertTrue(tabs("other", "spawn", "cow", "").isEmpty()); + assertTrue(tabs("husbandry", "save", "cow", "").isEmpty()); + assertTrue(tabs("other", "spawn", "cow", "1", "").isEmpty()); + assertTrue(tabs("husbandry", "save", "cow", "1", "").isEmpty()); + assertTrue(tabs("husbandry", "spawn", "cow", "1", "2", "").isEmpty()); + } + + @Test void foodCommandValidatesArityTargetCharacterAndFoodRange() { + MockedStatic characters = staticMock(RPCharacters.class); + run("food"); + assertTrue(messages().getFirst().contains("Usage:")); + run("food", "offline", "2"); + assertEquals(List.of("§cThat player is not online."), messages()); + run("food", "Admin", "2"); + assertEquals(List.of("§cThat player has no active character."), messages()); + RPCharacter character = mock(RPCharacter.class); + characters.when(() -> RPCharacters.getActiveCharacter(player)).thenReturn(character); + run("food", "Admin", "NaN"); + assertEquals(List.of("§cFood must be a whole number."), messages()); + for (String value : List.of("-1", Integer.toString(NutritionConfig.maxFood() + 1))) { + run("food", "Admin", value); + assertTrue(messages().getFirst().contains("Food must be between")); + } + verify(character, never()).setFoodValue(anyInt()); + } + + @Test void foodUpdatesTheActiveCharacterDisplayPersistenceAndBothNotifications() { + PlayerMock target = server.addPlayer("Target"); + target.setOp(true); + RPCharacter character = mock(RPCharacter.class); + 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()); + PlayerManager manager = mock(PlayerManager.class); + MockedStatic characters = staticMock(RPCharacters.class); + characters.when(() -> RPCharacters.getActiveCharacter(target)).thenReturn(character); + characters.when(RPCharacters::getPlayerManager).thenReturn(manager); + MockedStatic display = staticMock(NutritionDisplayService.class); + staticMock(NutritionLog.class); + + run("food", "Target", "100"); + + assertEquals(100, food.get()); + verify(manager).savePlayer(target); + display.verify(() -> NutritionDisplayService.sync(target, character, "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"); + assertEquals(0, food.get()); + assertEquals("§aSet Target's food from 100 to 0.", target.nextMessage()); + assertNull(target.nextMessage()); + } + + @Test void husbandryRejectsUnauthorizedSendersAndMalformedSpawnArguments() { + player.setOp(false); + run("husbandry"); + assertEquals(List.of("§cNo permission."), messages()); + player.setOp(true); + run("husbandry"); + assertEquals(2, messages().size()); + run("husbandry", "unsupported"); + assertEquals(2, messages().size()); + CommandSender console = mock(CommandSender.class); + when(console.hasPermission("cooking.admin")).thenReturn(true); + run(console, "husbandry", "spawn", "cow"); + verify(console).sendMessage("§cOnly players can spawn animals."); + run("husbandry", "spawn"); + assertTrue(messages().getFirst().contains("Usage:")); + run("husbandry", "spawn", "not-an-entity"); + assertEquals(List.of("§cUnknown entity type."), messages()); + MockedStatic config = staticMock(HusbandryConfig.class); + run("husbandry", "spawn", "COW"); + assertEquals(List.of("§cThat type is not a husbandry species."), messages()); + config.when(() -> HusbandryConfig.species(EntityType.COW)).thenReturn(mock(HusbandrySpecies.class)); + run("husbandry", "spawn", "cow", "bad"); + assertEquals(List.of("§cGenetics must be a number."), messages()); + run("husbandry", "spawn", "cow", "2", "bad"); + assertEquals(List.of("§cCare must be a number."), messages()); + } + + @Test void husbandrySpawnUsesDefaultsAndReportsClampedGeneticsAndCare() { + MockedStatic config = staticMock(HusbandryConfig.class); + config.when(() -> HusbandryConfig.species(EntityType.COW)).thenReturn(mock(HusbandrySpecies.class)); + config.when(HusbandryConfig::initialGeneticMax).thenReturn(20); + config.when(HusbandryConfig::maxGenetics).thenReturn(1000); + config.when(HusbandryConfig::careMax).thenReturn(200); + MockedStatic spawner = staticMock(HusbandrySpawner.class); + run("husbandry", "spawn", "cow"); + spawner.verify(() -> HusbandrySpawner.spawn(player, EntityType.COW, 20, 0)); + assertEquals(List.of("§cFailed to spawn animal."), messages()); + LivingEntity cow = mock(LivingEntity.class); + when(cow.getName()).thenReturn("Bess"); + spawner.when(() -> HusbandrySpawner.spawn(player, EntityType.COW, 2000, -2)).thenReturn(cow); + run("husbandry", "spawn", "cow", "2000", "-2"); + assertEquals(List.of("§aSpawned Bess genetics 1000 care 0."), messages()); + } + + @Test void husbandrySaveRequiresRepositoryAndFlushesOnlyLoadedAnimals() { + Cooking.plugin = null; + run("husbandry", "save"); + assertEquals(List.of("§cHusbandry database is not open."), messages()); + Cooking.plugin = plugin; + run("husbandry", "save"); + assertEquals(List.of("§cHusbandry database is not open."), messages()); + HusbandryRepository repository = mock(HusbandryRepository.class); + when(plugin.getHusbandryRepository()).thenReturn(repository); + MockedStatic entities = staticMock(HusbandryEntities.class); + entities.when(HusbandryEntities::snapshotLoaded).thenReturn(List.of()); + run("husbandry", "save"); + verify(repository, never()).upsertAnimals(anyCollection()); + assertEquals(List.of("§aSaved 0 loaded animals."), messages()); + List loaded = List.of(mock(HusbandryAnimal.class)); + entities.when(HusbandryEntities::snapshotLoaded).thenReturn(loaded); + run("husbandry", "save"); + verify(repository).upsertAnimals(loaded); + verify(repository, times(2)).checkpointWal(false); + assertEquals(List.of("§aSaved 1 loaded animals."), messages()); + } + + @Test void buildingItemsPreservesTheCompleteArgumentString() { + MockedStatic builder = staticMock(ItemBuilder.class); + run("builditem"); + assertTrue(messages().getFirst().contains("Usage:")); + builder.verifyNoInteractions(); + run("builditem", "carrot", "quality=3"); + builder.verify(() -> ItemBuilder.buildFromString(player, "carrot quality=3", null)); + assertEquals(List.of("§aGenerated item(s) from string!"), messages()); + } + + @Test void nameTestsValidateParsingAndPassOnlyTheOptionalColourAsAnExtra() { + MockedStatic parser = staticMock(FoodParser.class); + MockedStatic names = staticMock(NameComposer.class); + run("nametest"); + assertTrue(messages().getFirst().contains("Usage:")); + run("nametest", "missing"); + assertEquals(List.of("§cFailed to parse food string."), messages()); + parser.when(() -> FoodParser.parse("empty")).thenReturn(new FoodParser.Result()); + run("nametest", "empty"); + assertEquals(List.of("§cFailed to parse food string."), messages()); + FoodItem food = food("soup"); + parser.when(() -> FoodParser.parse("soup origin=Carrot")).thenReturn(parsed(food)); + names.when(() -> NameComposer.compose(food, Map.of("colour", "#aabbcc"))).thenReturn("Carrot Soup"); + run("nametest", "soup", "origin=Carrot", "#aabbcc"); + assertEquals(List.of("§aComposed name: §fCarrot Soup"), messages()); + parser.when(() -> FoodParser.parse("soup")).thenReturn(parsed(food)); + names.when(() -> NameComposer.compose(food, Map.of())).thenReturn("Soup"); + run("nametest", "soup"); + assertEquals(List.of("§aComposed name: §fSoup"), messages()); + } + + @Test void qualityTestsValidateInputsAndInvokeTheRequestedResolver() { + MockedStatic parser = staticMock(FoodParser.class); + run("qualitytest"); + assertEquals(2, messages().size()); + run("qualitytest", "pickup", "missing"); + assertEquals(List.of("§cFailed to parse food string."), messages()); + parser.when(() -> FoodParser.parse("empty")).thenReturn(new FoodParser.Result()); + run("qualitytest", "pickup", "empty"); + assertEquals(List.of("§cFailed to parse food string."), messages()); + FoodItem food = food("carrot"); + parser.when(() -> FoodParser.parse("carrot extra")).thenReturn(parsed(food)); + parser.when(() -> FoodParser.parse("carrot")).thenReturn(parsed(food)); + MockedStatic origins = staticMock(OriginQualityResolver.class); + origins.when(() -> OriginQualityResolver.resolve(player, food)).thenReturn(4); + run("qualitytest", "pickup", "carrot", "extra"); + assertEquals(List.of("§aPickup quality: §f4"), messages()); + MockedStatic compositions = staticMock(CompositionQualityResolver.class); + compositions.when(() -> CompositionQualityResolver.resolve(player, List.of(food), CompositionContext.CHURN)).thenReturn(3); + compositions.when(() -> CompositionQualityResolver.resolve(player, List.of(food), CompositionContext.CUTTING_BOARD)).thenReturn(2); + run("qualitytest", "compose", "carrot", "churn"); + assertEquals(List.of("§aComposition quality (CHURN): §f3"), messages()); + run("qualitytest", "compose", "carrot", "extra"); + compositions.verify(() -> CompositionQualityResolver.resolve(player, List.of(food), CompositionContext.CUTTING_BOARD)); + messages(); + run("qualitytest", "compose", "carrot"); + compositions.verify(() -> CompositionQualityResolver.resolve(player, List.of(food), CompositionContext.CUTTING_BOARD), times(2)); + assertEquals(List.of("§aComposition quality (CUTTING_BOARD): §f2"), messages()); + run("qualitytest", "unknown", "carrot"); + assertTrue(messages().getFirst().contains("Usage:")); + } + + @Test void compositionContextsRemainUsableUnderATurkishServerLocale() { + FoodItem food = food("carrot"); + MockedStatic parser = staticMock(FoodParser.class); + parser.when(() -> FoodParser.parse("carrot")).thenReturn(parsed(food)); + MockedStatic compositions = staticMock(CompositionQualityResolver.class); + compositions.when(() -> CompositionQualityResolver.compose(player, List.of(food), CompositionContext.MIXING_BOWL)) + .thenReturn(new CompositionResult(2, 3, Map.of(), List.of(food), List.of(), List.of(), IngredientLineage.empty())); + Locale previous = Locale.getDefault(); + try { + Locale.setDefault(Locale.forLanguageTag("tr-TR")); + run("qualitytest", "compose", "carrot", "mixing_bowl"); + compositions.verify(() -> CompositionQualityResolver.resolve(player, List.of(food), CompositionContext.MIXING_BOWL)); + run("qualitytest", "compose2", "carrot", "mixing_bowl"); + compositions.verify(() -> CompositionQualityResolver.compose(player, List.of(food), CompositionContext.MIXING_BOWL)); + } finally { Locale.setDefault(previous); } + } + + @Test void multiFoodCompositionRejectsEmptyOrUnparseableInputs() { + MockedStatic parser = staticMock(FoodParser.class); + run("qualitytest", "compose2", "|"); + assertTrue(messages().getFirst().contains("Usage:")); + run("qualitytest", "compose2", "missing"); + assertEquals(List.of("§cFailed to parse food string: missing"), messages()); + parser.when(() -> FoodParser.parse("empty")).thenReturn(new FoodParser.Result()); + run("qualitytest", "compose2", "empty"); + assertEquals(List.of("§cFailed to parse food string: empty"), messages()); + } + + @Test void multiFoodCompositionDisplaysRolesLineageAndFreshness() { + FoodItem carrot = food("carrot"); + FoodItem beef = food("beef"); + FoodItem salt = food("salt"); + MockedStatic parser = staticMock(FoodParser.class); + parser.when(() -> FoodParser.parse("carrot")).thenReturn(parsed(carrot)); + parser.when(() -> FoodParser.parse("beef")).thenReturn(parsed(beef)); + parser.when(() -> FoodParser.parse("salt")).thenReturn(parsed(salt)); + IngredientLineage lineage = IngredientLineage.ofMain("Carrot").withMain("Beef").withExtra("Salt"); + CompositionResult result = new CompositionResult(3, 4, Map.of("freshness", 8, "cooked", 2), + List.of(carrot, beef), List.of(salt), List.of(), lineage); + MockedStatic compositions = staticMock(CompositionQualityResolver.class); + compositions.when(() -> CompositionQualityResolver.compose(player, List.of(carrot, beef, salt), CompositionContext.BAKING)).thenReturn(result); + run("qualitytest", "compose2", "carrot|", "|beef|salt", "baking"); + List output = messages(); + assertTrue(output.contains("§7Baseline: §f3 §7→ Final: §f4")); + assertTrue(output.contains("§7Mains (2): §fcarrot(q=3, cat=ingredient), beef(q=3, cat=ingredient)")); + assertTrue(output.contains("§7Neutral (0): §f-")); + assertTrue(output.contains("§7Lineage mains: §fCarrot, Beef")); + assertTrue(output.stream().anyMatch(s -> s.contains("freshness=8") && s.contains("cooked=2"))); + parser.when(() -> FoodParser.parse("carrot suffix")).thenReturn(parsed(carrot)); + compositions.when(() -> CompositionQualityResolver.compose(player, List.of(carrot), CompositionContext.CUTTING_BOARD)) + .thenReturn(new CompositionResult(2, 2, Map.of(), List.of(carrot), List.of(), List.of(), IngredientLineage.empty())); + run("qualitytest", "compose2", "carrot", "suffix"); + assertTrue(messages().contains("§7Lineage mains: §f-")); + } + + @Test void previewValidatesTargetArgumentsAndNearbyBowl() { + run("preview"); + assertEquals(2, messages().size()); + run("preview", "unsupported"); + assertTrue(messages().getFirst().contains("Unknown preview target")); + run("preview", "mixing_bowl"); + assertTrue(messages().getFirst().contains("Usage:")); + run("preview", "mixing_bowl", "all"); + assertTrue(messages().getFirst().contains("No mixing bowl")); + } + + @Test void previewSelectsTheNearestUsableBowlAndDrivesOnlyTheRequestedLayers() { + MockedStatic cache = staticMock(FurnitureCache.class); + Furniture far = furniture("far", 5); + Furniture carried = furniture("carried", 0.25); when(carried.isCarried()).thenReturn(true); + Furniture ordinary = furniture("table", 0.5); + Furniture missingType = furniture("missing", 0.7); when(missingType.getType()).thenReturn(null); + Furniture bowl = furniture("bowl", 1); + for (Furniture f : List.of(far, carried, missingType, bowl)) cache.when(() -> FurnitureCache.isMixingBowl(f)).thenReturn(true); + MockedStatic display = staticMock(MixingBowlDisplay.class); + run("preview", "mixing_bowl", "unknown"); + assertTrue(messages().getFirst().contains("Unknown slot")); + run("preview", "mixing_bowl", "flour"); + assertTrue(messages().getFirst().contains("No display model")); + for (String slot : List.of("flour", "water", "yeast", "dough")) { + display.when(() -> MixingBowlDisplay.showLayer(bowl, slot)).thenReturn(true); + run("preview", "mixing_bowl", slot); + assertTrue(messages().getFirst().contains("Previewing")); + } + display.clearInvocations(); + run("preview", "mixing_bowl", "all"); + for (String slot : List.of("flour", "water", "yeast")) display.verify(() -> MixingBowlDisplay.showLayer(bowl, slot)); + messages(); + run("preview", "mixing_bowl", "clear"); + for (String slot : List.of("flour", "water", "yeast", "dough")) display.verify(() -> MixingBowlDisplay.clearLayer(bowl, slot)); + assertTrue(messages().getFirst().contains("Cleared mixing bowl")); + verify(ordinary, never()).getLoc(); + } + + @Test void furnitureInOtherWorldsDoesNotBreakHeatOrPreviewSelection() { + Furniture other = furniture("other-world", 0); + when(other.getLoc()).thenReturn(new Location(server.addSimpleWorld("other"), 0, 0, 0)); + Furniture near = furniture("near", 1); + MockedStatic heat = staticMock(HeatSources.class); + assertDoesNotThrow(() -> run("heat")); + assertTrue(messages().contains("§7Furniture: §fnear")); + MockedStatic cache = staticMock(FurnitureCache.class); + cache.when(() -> FurnitureCache.isMixingBowl(other)).thenReturn(true); + cache.when(() -> FurnitureCache.isMixingBowl(near)).thenReturn(true); + MockedStatic display = staticMock(MixingBowlDisplay.class); + assertDoesNotThrow(() -> run("preview", "mixing_bowl", "clear")); + display.verify(() -> MixingBowlDisplay.clearLayer(near, "flour")); + heat.verify(() -> HeatSources.isSource(near), times(2)); + } + + @Test void heatExplainsMissingFurnitureAndSourceConsumerStatus() { + MockedStatic heat = staticMock(HeatSources.class); + run("heat"); + assertTrue(messages().getFirst().contains("No furniture")); + Furniture far = furniture("far", 5); + Furniture carried = furniture("carried", 0.2); when(carried.isCarried()).thenReturn(true); + Furniture attached = furniture("attached", 0.3); when(attached.isAttached()).thenReturn(true); + Furniture stove = furniture("stove", 1); + heat.when(() -> HeatSources.isSource(stove)).thenReturn(true); + heat.when(() -> HeatSources.hasHeat(stove)).thenReturn(true); + heat.when(() -> HeatSources.isConsumer(stove)).thenReturn(true); + heat.when(() -> HeatSources.findSource(stove)).thenReturn(Optional.empty()); + run("heat"); + List output = messages(); + assertTrue(output.contains("§7hasHeat: §ftrue")); + assertTrue(output.contains("§7findSource: §fnone")); + heat.when(() -> HeatSources.findSource(stove)).thenReturn(Optional.of(far)); + run("heat"); + assertTrue(messages().stream().anyMatch(s -> s.contains("findSource: §ffar ("))); + } + + @Test void heatReportsNestedBakeTimersAndSkipsEmptyOrNonFoodSlots() { + Furniture oven = furniture("oven", 1); + Furniture tray = mock(Furniture.class); when(tray.getId()).thenReturn("bread_tray"); + PlacedFurnitureSlot empty = mock(PlacedFurnitureSlot.class); + PlacedFurnitureSlot decoration = mock(PlacedFurnitureSlot.class); when(decoration.getNested()).thenReturn(mock(Furniture.class)); + PlacedFurnitureSlot nested = mock(PlacedFurnitureSlot.class); when(nested.getNested()).thenReturn(tray); + Map nestedSlots = new LinkedHashMap<>(); + nestedSlots.put("empty", empty); nestedSlots.put("decoration", decoration); nestedSlots.put("tray", nested); + when(oven.getActiveFurnitureSlots()).thenReturn(nestedSlots); + MockedStatic heat = staticMock(HeatSources.class); + heat.when(() -> HeatSources.isConsumer(oven)).thenReturn(true); + heat.when(() -> HeatSources.findSource(oven)).thenReturn(Optional.empty()); + List slots = List.of("missing", "unavailable", "empty", "air", "stone", "raw", "cooked"); + BakingTrayRecipe recipe = new BakingTrayRecipe("bread", "bread_tray", Map.of("loaves", slots), null, new BakingTrayBake(60, 120, true)); + MockedStatic registry = staticMock(BakingTrayRegistry.class); + registry.when(() -> BakingTrayRegistry.isTray(tray)).thenReturn(true); + registry.when(() -> BakingTrayRegistry.getByFurniture(tray)).thenReturn(recipe); + MockedStatic foodItems = staticMock(FoodItem.class); + MockedStatic state = staticMock(BakingTrayState.class); + for (String id : slots.subList(1, slots.size())) when(tray.hasActiveSlot(id)).thenReturn(true); + when(tray.getActiveSlot("unavailable")).thenReturn(Optional.empty()); + place(tray, "empty", null); + place(tray, "air", new ItemStack(Material.AIR)); + place(tray, "stone", new ItemStack(Material.STONE)); + ItemStack raw = new ItemStack(Material.WHEAT); place(tray, "raw", raw); + ItemStack cooked = new ItemStack(Material.BREAD); place(tray, "cooked", cooked); + FoodItem dough = food("dough"); + foodItems.when(() -> FoodItem.fromItem(raw)).thenReturn(dough); + FoodItem baked = food("bread"); + TagTrack track = mock(TagTrack.class); when(track.getValue()).thenReturn(2); when(baked.getTagTrack("cooked")).thenReturn(track); + foodItems.when(() -> FoodItem.fromItem(cooked)).thenReturn(baked); + state.when(() -> BakingTrayState.getSlotElapsed(tray, "raw")).thenReturn(20); + run("heat"); + List output = messages(); + assertTrue(output.contains("§7recipe: §fbread")); + assertTrue(output.contains("§7cook-seconds: §f60")); + assertTrue(output.contains("§7burn-seconds: §f120")); + assertTrue(output.contains("§7raw: §felapsed=20 cooked=-1")); + assertTrue(output.contains("§7cooked: §felapsed=0 cooked=2")); + } + + private void place(Furniture tray, String id, ItemStack item) { + PlacedSlot slot = mock(PlacedSlot.class); + when(slot.getCurrentItem()).thenReturn(item); + when(tray.getActiveSlot(id)).thenReturn(Optional.of(slot)); + } + private Furniture furniture(String id, double distance) { + Furniture value = mock(Furniture.class); + UUID uuid = UUID.randomUUID(); + when(value.getId()).thenReturn(id); + when(value.getEntityId()).thenReturn(uuid); + when(value.getLoc()).thenReturn(player.getLocation().clone().add(distance, 0, 0)); + when(value.getType()).thenReturn(mock(FurnitureType.class)); + when(value.getActiveFurnitureSlots()).thenReturn(Map.of()); + furniture.put(uuid, value); + return value; + } + private FoodItem food(String id) { + FoodItem value = mock(FoodItem.class); + when(value.getId()).thenReturn(id); + when(value.getQualityMin()).thenReturn(3); + when(value.getCategory()).thenReturn("ingredient"); + return value; + } + private FoodParser.Result parsed(FoodItem item) { + FoodParser.Result result = new FoodParser.Result(); result.template = item; return result; + } + private MockedStatic staticMock(Class type) { + MockedStatic value = mockStatic(type); statics.add(value); return value; + } + private void run(String... args) { run(player, args); } + private void run(CommandSender sender, String... args) { assertTrue(commands.onCommand(sender, command, "cooking", args)); } + private List tabs(String... args) { return commands.onTabComplete(player, command, "cooking", args); } + private List messages() { + List out = new ArrayList<>(); String value; + while ((value = player.nextMessage()) != null) out.add(value); + return out; + } +} diff --git a/src/test/java/net/tfminecraft/cooking/manager/CookingAndTagManagersCoverageTest.java b/src/test/java/net/tfminecraft/cooking/manager/CookingAndTagManagersCoverageTest.java new file mode 100644 index 0000000..14cb50c --- /dev/null +++ b/src/test/java/net/tfminecraft/cooking/manager/CookingAndTagManagersCoverageTest.java @@ -0,0 +1,234 @@ +package net.tfminecraft.cooking.manager; + +import static org.junit.jupiter.api.Assertions.*; +import static org.mockito.ArgumentMatchers.*; +import static org.mockito.Mockito.*; + +import java.util.List; +import java.util.UUID; + +import org.bukkit.Chunk; +import org.bukkit.Location; +import org.bukkit.Material; +import org.bukkit.block.Block; +import org.bukkit.block.BlockFace; +import org.bukkit.configuration.MemoryConfiguration; +import org.bukkit.event.block.Action; +import org.bukkit.event.player.PlayerInteractEvent; +import org.bukkit.event.world.ChunkLoadEvent; +import org.bukkit.event.world.ChunkUnloadEvent; +import org.bukkit.inventory.Inventory; +import org.bukkit.inventory.ItemStack; +import org.bukkit.persistence.PersistentDataType; +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 net.tfminecraft.cooking.cache.FurnitureCache; +import net.tfminecraft.cooking.cache.ItemCache; +import net.tfminecraft.cooking.enums.Method; +import net.tfminecraft.cooking.heat.HeatSources; +import net.tfminecraft.cooking.item.FoodItem; +import net.tfminecraft.cooking.item.data.CookParameter; +import net.tfminecraft.cooking.loader.TrackLoader; +import net.tfminecraft.cooking.manager.MealManagersCoverageTest.Kitchen; +import net.tfminecraft.cooking.sausagemaker.SausageMakerCoverageTest.Station; +import net.tfminecraft.cooking.sausagemaker.SausageMakerCoverageTest.Workshop; +import net.tfminecraft.cooking.utils.IngredientConverter; +import net.tfminecraft.cooking.utils.ItemUpdater; +import net.tfminecraft.cooking.utils.Keys; +import net.tfminecraft.interactiblefurniture.events.FurnitureBreakEvent; +import net.tfminecraft.interactiblefurniture.events.FurnitureSlotItemTakeEvent; + +class CookingAndTagManagersCoverageTest { + Kitchen kitchen; + Workshop env; + CookingManager cooking; + TagManager tags; + String oldPan, oldSaucepan, oldPot; + boolean heated; + + @BeforeEach void open() { + kitchen = new Kitchen(); env = kitchen.env; cooking = new CookingManager(); tags = new TagManager(); heated = true; + oldPan = FurnitureCache.fryingPan; oldSaucepan = FurnitureCache.saucePan; oldPot = FurnitureCache.pot; + FurnitureCache.fryingPan = "pan"; FurnitureCache.saucePan = "saucepan"; FurnitureCache.pot = "pot"; + env.scoped(mockStatic(HeatSources.class)).when(() -> HeatSources.stationHasHeat(any())).thenAnswer(inv -> heated); + env.cache.when(() -> ItemCache.isButter(any())).thenAnswer(inv -> { + ItemStack item = inv.getArgument(0); return item != null && item.getType() == Material.GOLD_NUGGET; + }); + env.cache.when(() -> ItemCache.getColour(any())).thenReturn("aa5500"); + env.scoped(mockStatic(IngredientConverter.class)).when(() -> IngredientConverter.convertIfNeeded(any(), any())).thenAnswer(inv -> inv.getArgument(1)); + kitchen.updater.when(() -> ItemUpdater.updateItem(any(), any(), any(), anyBoolean())).thenAnswer(inv -> kitchen.updated(inv.getArgument(0), inv.getArgument(1))); + } + + @AfterEach void close() { + kitchen.close(); FurnitureCache.fryingPan = oldPan; FurnitureCache.saucePan = oldSaucepan; FurnitureCache.pot = oldPot; + } + + Station station(String kind) { + Station station = kitchen.station(kind); + for (String slot : List.of("input_1", "input_2", "butter", "liquid")) station.define(slot); + return station; + } + FoodItem raw(Method method) { + FoodItem food = kitchen.food("steak", "meat"); food.addOrModifyTrack(TrackLoader.getByString("cooked")); + food.getCookData().getParameters().put(method, new CookParameter(1, 2, 20)); return new FoodItem(food); + } + Chunk chunk(int x) { + Chunk chunk = mock(Chunk.class); when(chunk.getWorld()).thenReturn(env.world); when(chunk.getX()).thenReturn(x); when(chunk.getZ()).thenReturn(0); return chunk; + } + PlayerInteractEvent cauldron(Action action, Material type) { + Block block = type == null ? null : mock(Block.class); if (block != null) when(block.getType()).thenReturn(type); + return new PlayerInteractEvent(env.player, action, env.player.getInventory().getItemInMainHand(), block, BlockFace.UP); + } + + @ParameterizedTest + @ValueSource(strings = {"pan", "saucepan", "pot"}) + void restoredStationsSelectTheRightMethodAndContinueCookingOnTheTimer(String kind) { + Method method = switch (kind) { case "pan" -> Method.FRYING_PAN; case "saucepan" -> Method.SAUCEPAN; default -> Method.POT; }; + Station station = station(kind); FoodItem food = raw(method); var slot = station.place("input_2", env.stack(food, Material.BEEF, 1)); + cooking.resumeLoadedStations(); assertEquals(method, food.getCookData().getCurrentMethod()); + cooking.start(); env.server.getScheduler().performTicks(40); + assertEquals(2, food.getCookData().getCurrentTime()); assertEquals(1, food.getTagTrack("cooked").getValue()); verify(slot).forceModel(any()); + heated = false; env.server.getScheduler().performTicks(20); assertEquals(2, food.getCookData().getCurrentTime()); + } + + @Test + void resumingSkipsMissingTypesUnknownFurnitureAndEmptyKnownStations() { + kitchen.placed.put(UUID.randomUUID(), null); Station broken = station("pan"); when(broken.furniture.getType()).thenReturn(null); + Station chair = station("chair"); chair.place("input_1", env.stack(raw(Method.FRYING_PAN), Material.BEEF, 1)); + Station empty = station("pot"); assertDoesNotThrow(() -> cooking.resumeLoadedStations()); + verify(empty.furniture, never()).getActiveSlot(anyString()); + var ignored = chair.add("input_1", new ItemStack(Material.STONE)); cooking.addItem(ignored); assertFalse(ignored.isCancelled()); + var take = new FurnitureSlotItemTakeEvent(env.player, chair.furniture, chair.define("input_1"), new ItemStack(Material.STONE)); + cooking.takeItem(take); assertFalse(take.isCancelled()); cooking.interact(chair.interact()); cooking.breakEvent(new FurnitureBreakEvent(chair.furniture, env.player)); + } + + @Test + void addAndTakeEventsReuseTheReferenceAndPreservePendingIngredients() { + Station pan = station("pan"); FoodItem food = raw(Method.FRYING_PAN); ItemStack item = env.stack(food, Material.BEEF, 1); + var add = pan.add("input_1", item); cooking.addItem(add); assertFalse(add.isCancelled()); assertNotNull(add.getDisplayData()); + cooking.interact(pan.interact()); cooking.start(); env.server.getScheduler().performTicks(20); + assertEquals(1, food.getCookData().getCurrentTime(), "Reusing the reference must keep a just-added ingredient before furniture commits the display"); + var take = new FurnitureSlotItemTakeEvent(env.player, pan.furniture, pan.define("input_1"), item); + cooking.takeItem(take); assertFalse(take.isCancelled()); assertEquals("steak", env.resolve(take.getItem()).getId()); + env.server.getScheduler().performTicks(20); assertEquals(1, food.getCookData().getCurrentTime()); + } + + @Test + void interactionsReachTheFryingReferenceAndConsumeExactlyOneButter() { + Station pan = station("pan"); ItemStack butter = env.hold(env.stack(kitchen.food("butter", "butter"), Material.GOLD_NUGGET, 3)); + var event = pan.interact(); cooking.interact(event); assertTrue(event.isCancelled()); assertEquals(2, butter.getAmount()); + assertEquals(1, pan.active.get("butter").getCurrentItem().getAmount()); + cooking.interact(pan.interact()); assertEquals(2, butter.getAmount(), "An existing butter secondary rejects another serving"); + } + + @Test + void replacingFurnitureUnderTheSameUuidRebuildsTheReference() { + Station old = station("pan"); FoodItem first = raw(Method.FRYING_PAN); old.place("input_1", env.stack(first, Material.BEEF, 1)); + cooking.resumeLoadedStations(); cooking.start(); env.server.getScheduler().performTicks(20); assertEquals(1, first.getCookData().getCurrentTime()); + Station replacement = station("pan"); when(replacement.furniture.getEntityId()).thenReturn(old.id); + kitchen.placed.remove(replacement.id); kitchen.placed.put(old.id, replacement.furniture); + FoodItem second = raw(Method.FRYING_PAN); replacement.place("input_2", env.stack(second, Material.BEEF, 1)); + cooking.interact(replacement.interact()); env.server.getScheduler().performTicks(20); + assertEquals(1, first.getCookData().getCurrentTime()); assertEquals(1, second.getCookData().getCurrentTime()); + } + + @Test + void unloadingStopsOnlyThatChunksTimersAndLoadingResumesOnTheNextTick() { + Chunk near = chunk(0), far = chunk(2); + when(env.world.getChunkAt(any(Location.class))).thenAnswer(inv -> ((Location) inv.getArgument(0)).getBlockX() < 16 ? near : far); + Station pan = station("pan"), other = station("pan"); when(other.furniture.getLoc()).thenReturn(new Location(env.world, 33, 64, 1)); + FoodItem first = raw(Method.FRYING_PAN), second = raw(Method.FRYING_PAN); + pan.place("input_1", env.stack(first, Material.BEEF, 1)); other.place("input_1", env.stack(second, Material.BEEF, 1)); + cooking.resumeLoadedStations(); cooking.start(); cooking.onChunkUnload(new ChunkUnloadEvent(near)); env.server.getScheduler().performTicks(20); + assertEquals(0, first.getCookData().getCurrentTime()); assertEquals(1, second.getCookData().getCurrentTime()); + cooking.onChunkLoad(new ChunkLoadEvent(near, false)); env.server.getScheduler().performTicks(1); + env.server.getScheduler().performTicks(19); assertEquals(1, first.getCookData().getCurrentTime()); assertEquals(2, second.getCookData().getCurrentTime()); + } + + @ParameterizedTest + @ValueSource(strings = {"pan", "saucepan", "pot"}) + void breakingTheStationClearsItsManagedStateAndStopsCooking(String kind) { + Method method = switch (kind) { case "pan" -> Method.FRYING_PAN; case "saucepan" -> Method.SAUCEPAN; default -> Method.POT; }; + Station station = station(kind); FoodItem food = raw(method); station.place("input_1", env.stack(food, Material.BEEF, 1)); + if (kind.equals("pan")) station.place("butter", env.stack(kitchen.food("butter", "butter"), Material.GOLD_NUGGET, 1)); + cooking.resumeLoadedStations(); cooking.start(); cooking.breakEvent(new FurnitureBreakEvent(station.furniture, env.player)); + env.server.getScheduler().performTicks(20); assertEquals(0, food.getCookData().getCurrentTime()); assertFalse(station.active.containsKey("butter")); + if (kind.equals("saucepan")) assertFalse(station.active.containsKey("input_1")); + else assertTrue(station.active.containsKey("input_1"), "Frying and raw pot ingredients are left for furniture's normal item drop"); + cooking.breakEvent(new FurnitureBreakEvent(station.furniture, env.player)); + } + + @ParameterizedTest + @ValueSource(strings = {"sauce", "soup"}) + void cauldronsEmptyOnlyFilledFoodLadles(String category) { + env.hold(env.stack(kitchen.food(category, category), Material.PAPER, 1)); + var wrongAction = cauldron(Action.LEFT_CLICK_BLOCK, Material.CAULDRON); cooking.empty(wrongAction); assertFalse(wrongAction.isCancelled()); + var wrongBlock = cauldron(Action.RIGHT_CLICK_BLOCK, Material.STONE); cooking.empty(wrongBlock); assertFalse(wrongBlock.isCancelled()); + var valid = cauldron(Action.RIGHT_CLICK_BLOCK, Material.CAULDRON); cooking.empty(valid); assertTrue(valid.isCancelled()); + assertEquals(Material.STICK, env.player.getInventory().getItemInMainHand().getType()); + cooking.empty(cauldron(Action.RIGHT_CLICK_BLOCK, Material.CAULDRON)); + env.hold(env.stack(kitchen.food("steak", "meat"), Material.BEEF, 1)); var meat = cauldron(Action.RIGHT_CLICK_BLOCK, Material.CAULDRON); + cooking.empty(meat); assertFalse(meat.isCancelled()); assertEquals(Material.BEEF, env.player.getInventory().getItemInMainHand().getType()); + } + + @Test + void anInteractionWithoutAClickedBlockDoesNotDiscardFoodOrThrow() { + ItemStack full = env.hold(env.stack(kitchen.food("soup", "soup"), Material.PAPER, 1)); + assertDoesNotThrow(() -> cooking.empty(cauldron(Action.RIGHT_CLICK_BLOCK, null))); + assertEquals(full, env.player.getInventory().getItemInMainHand()); + } + + @ParameterizedTest + @ValueSource(strings = {"null", "air"}) + void unavailableEmptyLadlesLeaveTheFilledLadleIntact(String failure) { + ItemStack full = env.hold(env.stack(kitchen.food("soup", "soup"), Material.PAPER, 1)); + when(env.api.getCreator().getItemFromPath("tool:ladle")).thenReturn(failure.equals("null") ? null : new ItemStack(Material.AIR)); + cooking.empty(cauldron(Action.RIGHT_CLICK_BLOCK, Material.CAULDRON)); assertEquals(full, env.player.getInventory().getItemInMainHand()); + } + + @Test + void scanMatchingRequiresFoodMetadataAndRecognizesTheOwningPlayersHeldSlots() { + assertFalse(tags.matches(null)); assertFalse(tags.matches(new ItemStack(Material.AIR))); assertFalse(tags.matches(new ItemStack(Material.STONE))); + ItemStack food = env.stack(kitchen.food("steak", "meat"), Material.BEEF, 1); assertFalse(tags.matches(food)); + var meta = food.getItemMeta(); meta.getPersistentDataContainer().set(Keys.FOOD_ID, PersistentDataType.STRING, "steak"); food.setItemMeta(meta); assertTrue(tags.matches(food)); + var inventory = env.player.getInventory(); inventory.setHeldItemSlot(3); + assertTrue(TagManager.isHeldSlot(env.player, inventory, 3)); assertTrue(TagManager.isHeldSlot(env.player, inventory, 40)); + assertFalse(TagManager.isHeldSlot(env.player, inventory, 4)); assertFalse(TagManager.isHeldSlot(null, inventory, 3)); + assertFalse(TagManager.isHeldSlot(env.player, null, 3)); assertFalse(TagManager.isHeldSlot(env.player, env.server.createInventory(null, 9), 3)); + assertFalse(TagManager.isHeldSlot(env.player, env.server.addPlayer().getInventory(), 3)); + } + + @Test + void unchangedFoodAndNonFoodAreNotRewrittenButMissingLoreIsRebuilt() { + Inventory inventory = env.server.createInventory(null, 9); ItemStack item = env.stack(kitchen.food("steak", "meat"), Material.BEEF, 2); + inventory.setItem(0, item); tags.update(env.player, inventory, 0, new ItemStack(Material.STONE)); tags.update(env.player, inventory, 0, item); + kitchen.updater.verify(() -> ItemUpdater.updateItem(any(), any(), any(), anyBoolean()), never()); assertEquals(item, inventory.getItem(0)); + kitchen.updater.when(() -> ItemUpdater.needsLoreRebuild(any(), anyBoolean())).thenReturn(true); + tags.update(env.player, inventory, 0, item); kitchen.updater.verify(() -> ItemUpdater.updateItem(item, env.resolve(item), null, false)); + assertEquals(2, inventory.getItem(0).getAmount()); assertNotEquals(item, inventory.getItem(0)); + } + + @Test + void changingFoodUsesHeldContextOnlyForThePlayersSelectedOrOffhandSlot() { + MemoryConfiguration config = new MemoryConfiguration(); config.set("name", "Steak"); config.set("update", true); + FoodItem food = new FoodItem("steak", config); ItemStack stack = env.stack(food, Material.BEEF, 2); var inventory = env.player.getInventory(); + inventory.setItem(0, stack); tags.update(env.player, inventory, 0, stack); + kitchen.updater.verify(() -> ItemUpdater.updateItem(stack, food, null, true)); assertEquals(2, inventory.getItem(0).getAmount()); + tags.update(env.player, inventory, 1, stack); kitchen.updater.verify(() -> ItemUpdater.updateItem(stack, food, null, false)); + tags.update(env.player, inventory, 40, stack); assertEquals(2, inventory.getItemInOffHand().getAmount()); + tags.update(env.player, null, 0, stack); tags.update(env.player, inventory, -1, stack); + } + + @ParameterizedTest + @ValueSource(strings = {"null", "air"}) + void failedTagUpdatesNeverDeleteTheExistingFood(String failure) { + ItemStack source = env.stack(kitchen.food("steak", "meat"), Material.BEEF, 2); Inventory inventory = env.server.createInventory(null, 9); inventory.setItem(0, source); + kitchen.updater.when(() -> ItemUpdater.needsLoreRebuild(any(), anyBoolean())).thenReturn(true); + kitchen.updater.when(() -> ItemUpdater.updateItem(any(), any(), any(), anyBoolean())).thenReturn(failure.equals("null") ? null : new ItemStack(Material.AIR)); + tags.update(env.player, inventory, 0, source); assertEquals(source, inventory.getItem(0)); + } +} diff --git a/src/test/java/net/tfminecraft/cooking/manager/CraftingManagerCoverageTest.java b/src/test/java/net/tfminecraft/cooking/manager/CraftingManagerCoverageTest.java new file mode 100644 index 0000000..e263532 --- /dev/null +++ b/src/test/java/net/tfminecraft/cooking/manager/CraftingManagerCoverageTest.java @@ -0,0 +1,387 @@ +package net.tfminecraft.cooking.manager; + +import static org.junit.jupiter.api.Assertions.*; +import static org.mockito.ArgumentMatchers.*; +import static org.mockito.Mockito.*; + +import java.util.Optional; +import java.util.UUID; + +import org.bukkit.Chunk; +import org.bukkit.Location; +import org.bukkit.Material; +import org.bukkit.entity.Item; +import org.bukkit.entity.ItemDisplay; +import org.bukkit.event.world.ChunkLoadEvent; +import org.bukkit.event.world.ChunkUnloadEvent; +import org.bukkit.inventory.ItemStack; +import org.bukkit.util.Transformation; +import org.joml.Quaternionf; +import org.joml.Vector3f; +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 net.tfminecraft.cooking.cache.ItemCache; +import net.tfminecraft.cooking.carve.CarvableRoastUtils; +import net.tfminecraft.cooking.crafting.CraftingStation; +import net.tfminecraft.cooking.crafting.CraftingStationCoverageTest.Environment; +import net.tfminecraft.cooking.enums.Method; +import net.tfminecraft.cooking.item.FoodItem; +import net.tfminecraft.cooking.loader.CraftingStationLoader; +import net.tfminecraft.cooking.utils.ItemUpdater; +import net.tfminecraft.interactiblefurniture.events.FurnitureBreakEvent; +import net.tfminecraft.interactiblefurniture.events.FurnitureInteractEvent; +import net.tfminecraft.interactiblefurniture.events.FurniturePlaceEvent; +import net.tfminecraft.interactiblefurniture.furniture.Furniture; +import net.tfminecraft.interactiblefurniture.furniture.FurnitureType; +import net.tfminecraft.interactiblefurniture.furniture.PlacedSlot; + +class CraftingManagerCoverageTest { + Environment env; + CraftingManager manager; + @BeforeEach void open() { env = new Environment(); manager = new CraftingManager(); } + @AfterEach void close() { env.close(); } + + void registerBoard() { + CraftingStationLoader.get().add(new CraftingStation("board", env.config(1, false, "tool:knife"))); + } + + void firePit() { + env.firePit = true; + env.define("content"); env.define("turner"); + when(env.type.getId()).thenReturn("fire_pit"); + } + + FoodItem roast() { return env.food("roast", "Beef", Method.FIRE_PIT); } + + @Test + void stationCacheReusesLiveFurnitureAndRebuildsAReplacementWithTheSameUuid() { + assertNull(manager.getOrCreateStation(env.furniture)); + registerBoard(); + env.place("input_1", env.raw(1)); + CraftingStation original = manager.getOrCreateStation(env.furniture); + assertNotNull(original); + assertSame(original, manager.getOrCreateStation(env.furniture)); + assertEquals(1, original.getSlots().size()); + Furniture replacement = mock(Furniture.class); + UUID stationId = env.furniture.getEntityId(); + when(replacement.getEntityId()).thenReturn(stationId); + when(replacement.getType()).thenReturn(env.type); + when(replacement.getActiveSlot(anyString())).thenReturn(Optional.empty()); + CraftingStation rebuilt = manager.getOrCreateStation(replacement); + assertNotSame(original, rebuilt); + assertSame(replacement, rebuilt.getFurniture()); + assertTrue(rebuilt.getSlots().isEmpty()); + assertEquals(1, manager.stations.size()); + Furniture noType = mock(Furniture.class); + assertNull(manager.getOrCreateStation(noType)); + } + + @Test + void rebuildingDiscardsStaleStationsAndIgnoresNonStations() { + registerBoard(); + Furniture noType = mock(Furniture.class); + Furniture unknown = mock(Furniture.class); + FurnitureType type = mock(FurnitureType.class); + when(unknown.getType()).thenReturn(type); + when(type.getId()).thenReturn("chair"); + assertNull(manager.getOrCreateStation(unknown)); + env.placed.put(UUID.randomUUID(), noType); + env.placed.put(UUID.randomUUID(), unknown); + manager.stations.put(UUID.randomUUID(), env.station(1, false)); + manager.rebuildStations(); + assertEquals(1, manager.stations.size()); + assertTrue(manager.stations.containsKey(env.furniture.getEntityId())); + CraftingStation station = manager.stations.get(env.furniture.getEntityId()); + manager.resumeLoadedStations(); + assertSame(station, manager.stations.get(env.furniture.getEntityId())); + } + + @Test + void chunkUnloadRemovesOnlyThatChunkAndReloadWaitsOneTickForFurniture() { + registerBoard(); + manager.resumeLoadedStations(); + Chunk elsewhere = mock(Chunk.class); + when(elsewhere.getWorld()).thenReturn(env.world); + when(elsewhere.getX()).thenReturn(5); + when(elsewhere.getZ()).thenReturn(5); + manager.onChunkUnload(new ChunkUnloadEvent(elsewhere)); + assertEquals(1, manager.stations.size()); + manager.onChunkUnload(new ChunkUnloadEvent(env.chunk)); + assertTrue(manager.stations.isEmpty()); + Furniture foreign = mock(Furniture.class); + Location foreignLocation = mock(Location.class); + when(foreign.getLoc()).thenReturn(foreignLocation); + when(foreignLocation.getChunk()).thenReturn(elsewhere); + env.placed.put(UUID.randomUUID(), foreign); + manager.onChunkLoad(new ChunkLoadEvent(env.chunk, false)); + assertTrue(manager.stations.isEmpty()); + env.place("input_2", env.raw(1)); + env.server.getScheduler().performOneTick(); + assertEquals(1, manager.stations.size()); + assertTrue(manager.stations.get(env.furniture.getEntityId()).getSlots().containsKey("input_2")); + } + + @Test + void stationEventsForwardAddTakeAndCraftAndRetainCancelledBreaks() { + registerBoard(); + var add = env.addEvent("input_1", env.raw(1)); + manager.furnitureInteract(add); + CraftingStation station = manager.stations.get(env.furniture.getEntityId()); + assertSame(add.getItem(), station.getSlots().get("input_1")); + manager.furnitureInteract(new FurnitureInteractEvent(env.player, env.furniture)); + manager.furnitureTakeInteract(env.takeEvent("input_1", add.getItem())); + assertTrue(station.getSlots().isEmpty()); + env.add(station, "input_1", env.raw(1)); + env.player.getInventory().setItemInMainHand(new ItemStack(Material.IRON_SWORD)); + var craft = new FurnitureBreakEvent(env.furniture, env.player); + manager.furnitureBreak(craft); + assertTrue(craft.isCancelled()); + assertSame(station, manager.stations.get(env.furniture.getEntityId())); + env.player.getInventory().setItemInMainHand(new ItemStack(Material.AIR)); + var remove = new FurnitureBreakEvent(env.furniture, env.player); + manager.furnitureBreak(remove); + assertFalse(remove.isCancelled()); + assertTrue(manager.stations.isEmpty()); + when(env.furniture.getType()).thenReturn(null); + manager.furnitureBreak(new FurnitureBreakEvent(env.furniture, null)); + } + + @Test + void unregisteredFurnitureEventsLeaveTheItemAndCacheUntouched() { + var add = env.addEvent("input_1", env.raw(1)); + manager.furnitureInteract(add); + manager.furnitureAddInteract(add); + manager.furnitureTakeInteract(env.takeEvent("input_1", add.getItem())); + manager.furnitureInteract(new FurnitureInteractEvent(env.player, env.furniture)); + manager.furniturePlace(new FurniturePlaceEvent(env.furniture, env.player)); + manager.furnitureBreak(new FurnitureBreakEvent(env.furniture, env.player)); + assertFalse(add.isCancelled()); + assertTrue(manager.stations.isEmpty()); + } + + @Test + void firePitAdmissionRequiresHeatAndFoodWithAFirePitCookingMethod() { + firePit(); + env.heated = false; + var cold = env.addEvent("content", env.stack(roast(), Material.BEEF, 1)); + manager.furnitureInteract(cold); + assertTrue(cold.isCancelled()); + verify(env.player).sendMessage("§cLight the campfire under the fire pit first."); + env.heated = true; + var plain = env.addEvent("content", new ItemStack(Material.STONE)); + manager.furnitureInteract(plain); + assertTrue(plain.isCancelled()); + var unsupported = env.addEvent("content", env.raw(1)); + manager.furnitureInteract(unsupported); + assertTrue(unsupported.isCancelled()); + verify(env.player, times(2)).sendMessage("§cOnly whole roasts can be cooked on a fire pit."); + var accepted = env.addEvent("content", env.stack(roast(), Material.BEEF, 1)); + manager.furnitureInteract(accepted); + assertFalse(accepted.isCancelled()); + var otherSlot = env.addEvent("turner", new ItemStack(Material.STICK)); + manager.furnitureInteract(otherSlot); + assertFalse(otherSlot.isCancelled()); + assertTrue(manager.stations.isEmpty()); + } + + @Test + void placingAFirePitCreatesTheTurnerAndOffsetsTheModelOnlyOnce() { + firePit(); + PlacedSlot turner = env.place("turner", null); + ItemDisplay display = env.display("turner", 2); + manager.furniturePlace(new FurniturePlaceEvent(env.furniture, env.player)); + assertEquals(Material.STICK, turner.getCurrentItem().getType()); + assertEquals(0.25f, display.getTransformation().getTranslation().y); + manager.furniturePlace(new FurniturePlaceEvent(env.furniture, env.player)); + assertEquals(0.25f, display.getTransformation().getTranslation().y); + verify(display, times(1)).setTransformation(any()); + env.definitions.remove("turner"); + manager.furniturePlace(new FurniturePlaceEvent(env.furniture, env.player)); + when(env.furniture.getType()).thenReturn(null); + manager.furniturePlace(new FurniturePlaceEvent(env.furniture, env.player)); + verify(turner, times(2)).forceModel(any()); + } + + @Test + void contentDisplayIsUpdatedAfterPlacementAndCarriesTheCarveStage() { + firePit(); + FoodItem roast = roast(); + roast.setCarveState("beef", 2, 3); + ItemStack stack = env.stack(roast, Material.BEEF, 1); + var add = env.addEvent("content", stack); + manager.furnitureAddInteract(add); + PlacedSlot content = env.place("content", stack); + ItemDisplay display = env.display("content", 1); + verify(content, never()).applyDisplayData(any()); + env.server.getScheduler().performOneTick(); + env.carve.verify(() -> CarvableRoastUtils.readCarveState(roast, stack)); + verify(content).applyDisplayData(any()); + assertEquals(0.25f, display.getTransformation().getTranslation().y); + manager.furnitureAddInteract(env.addEvent("turner", new ItemStack(Material.STICK))); + env.server.getScheduler().performOneTick(); + verify(content, times(1)).applyDisplayData(any()); + } + + @Test + void pendingContentDisplayUpdateToleratesRemovedEmptyAndNonFoodSlots() { + firePit(); + var add = env.addEvent("content", env.raw(1)); + manager.furnitureAddInteract(add); + env.server.getScheduler().performOneTick(); + PlacedSlot empty = env.place("content", null); + manager.furnitureAddInteract(add); + env.server.getScheduler().performOneTick(); + verify(empty, never()).applyDisplayData(any()); + PlacedSlot plain = env.place("content", new ItemStack(Material.STONE)); + manager.furnitureAddInteract(add); + env.server.getScheduler().performOneTick(); + verify(plain, never()).applyDisplayData(any()); + PlacedSlot food = env.place("content", env.stack(roast(), Material.BEEF, 1)); + manager.furnitureAddInteract(add); + env.server.getScheduler().performOneTick(); + verify(food).applyDisplayData(any()); + UUID wrongEntity = UUID.randomUUID(); + when(food.getDisplayStandId()).thenReturn(wrongEntity); + env.entities.put(wrongEntity, mock(Item.class)); + manager.furnitureAddInteract(add); + env.server.getScheduler().performOneTick(); + verify(food, times(2)).applyDisplayData(any()); + } + + @Test + void firePitControlsIgnoreMissesToolsEmptySpitsAndUnlitFires() { + firePit(); + var miss = env.interact("missing"); + manager.furnitureInteract(miss); + assertFalse(miss.isCancelled()); + var content = env.interact("content"); + manager.furnitureInteract(content); + assertFalse(content.isCancelled()); + var empty = env.interact("turner"); + manager.furnitureInteract(empty); + assertTrue(empty.isCancelled()); + FoodItem roast = roast(); + env.place("content", env.stack(roast, Material.BEEF, 1)); + env.player.getInventory().setItemInMainHand(new ItemStack(Material.STICK)); + var holding = env.interact("turner"); + manager.furnitureInteract(holding); + assertTrue(holding.isCancelled()); + assertEquals(0, roast.getCookData().getCurrentTime()); + env.player.getInventory().setItemInMainHand(new ItemStack(Material.AIR)); + env.heated = false; + manager.furnitureInteract(env.interact("turner")); + verify(env.player).sendMessage("§cLight the campfire under the fire pit first."); + assertEquals(0, roast.getCookData().getCurrentTime()); + env.updater.verifyNoInteractions(); + } + + @Test + void turningAdvancesCookingOnceAndBlocksTakingContentUntilThePitBreaks() { + firePit(); + FoodItem roast = roast(); + ItemStack original = env.stack(roast, Material.BEEF, 1); + PlacedSlot content = env.place("content", original); + manager.furnitureInteract(env.interact("turner")); + assertTrue(roast.getCookData().isBeingCooked()); + assertEquals(1, roast.getCookData().getCurrentTime()); + assertNotSame(original, content.getCurrentItem()); + env.carve.verify(() -> CarvableRoastUtils.writeCarveState(content.getCurrentItem(), roast)); + manager.furnitureInteract(env.interact("turner")); + assertEquals(1, roast.getCookData().getCurrentTime()); + var hitContent = env.interact("content"); + manager.furnitureInteract(hitContent); + assertTrue(hitContent.isCancelled()); + var take = env.takeEvent("content", content.getCurrentItem()); + manager.furnitureTakeInteract(take); + assertTrue(take.isCancelled()); + var other = env.takeEvent("turner", new ItemStack(Material.STICK)); + manager.furnitureTakeInteract(other); + assertFalse(other.isCancelled()); + manager.furnitureBreak(new FurnitureBreakEvent(env.furniture, env.player)); + var afterBreak = env.takeEvent("content", content.getCurrentItem()); + manager.furnitureTakeInteract(afterBreak); + assertFalse(afterBreak.isCancelled()); + manager.furnitureInteract(env.interact("turner")); + assertEquals(2, roast.getCookData().getCurrentTime()); + } + + @ParameterizedTest + @ValueSource(strings = {"null", "plain", "unsupported", "failed-update"}) + void turningUnavailableFoodDoesNotReplaceTheStoredStack(String kind) { + firePit(); + FoodItem food = kind.equals("unsupported") ? env.food("raw", "Beef") : roast(); + ItemStack stack = switch (kind) { + case "null" -> null; + case "plain" -> new ItemStack(Material.STONE); + default -> env.stack(food, Material.BEEF, 1); + }; + PlacedSlot content = env.place("content", stack); + if (kind.equals("failed-update")) env.updater.when(() -> ItemUpdater.applyItemUpdate(any(), any(), any())).thenReturn(null); + manager.furnitureInteract(env.interact("turner")); + assertSame(stack, content.getCurrentItem()); + verify(content, never()).setCurrentItem(any()); + verify(content, never()).applyDisplayData(any()); + } + + @ParameterizedTest + @ValueSource(strings = {"x", "y", "z"}) + void rigidSpitAnimationRotatesAroundConfiguredAxisAndRestoresExactTransforms(String axis) { + firePit(); + ItemCache.firePitSpinAxis = axis; + env.place("content", env.stack(roast(), Material.BEEF, 1)); + env.place("turner", new ItemStack(Material.STICK)); + ItemDisplay content = env.display("content", 1); + ItemDisplay turner = env.display("turner", 2); + Transformation parentTransform = new Transformation(new Vector3f(), new Quaternionf().rotateY(0.4f), new Vector3f(1), new Quaternionf()); + env.display(env.furniture.getEntityId(), env.furniture.getLoc(), parentTransform); + Transformation sourceTransform = new Transformation(new Vector3f(0.1f, 0.3f, -0.2f), new Quaternionf().rotateX(0.2f), + new Vector3f(0.7f, 0.8f, 0.9f), new Quaternionf().rotateZ(0.1f)); + content.setTransformation(sourceTransform); + Location contentStart = content.getLocation(); + Location turnerStart = turner.getLocation(); + manager.furnitureInteract(env.interact("turner")); + env.server.getScheduler().performTicks(11); + assertNotEquals(sourceTransform.getLeftRotation(), content.getTransformation().getLeftRotation()); + assertEquals(sourceTransform.getTranslation(), content.getTransformation().getTranslation()); + assertEquals(sourceTransform.getScale(), content.getTransformation().getScale()); + assertEquals(sourceTransform.getRightRotation(), content.getTransformation().getRightRotation()); + Location quarterTurn = switch (axis) { + case "x" -> new Location(env.world, 1 - Math.sin(0.4) / 2, 64.5, 1 - Math.cos(0.4) / 2); + case "z" -> new Location(env.world, 1 + Math.cos(0.4) / 2, 64.5, 1 - Math.sin(0.4) / 2); + default -> contentStart; + }; + assertEquals(0, quarterTurn.distance(content.getLocation()), 0.00001, "A quarter turn follows the parent-rotated axis"); + assertEquals(1.0, content.getLocation().distance(turner.getLocation()), 0.00001, "The spit and roast move as one rigid assembly"); + env.server.getScheduler().performTicks(35); + assertEquals(contentStart, content.getLocation()); + assertEquals(turnerStart, turner.getLocation()); + assertEquals(sourceTransform, content.getTransformation()); + assertEquals(0.25f, turner.getTransformation().getTranslation().y); + clearInvocations(content, turner); + env.server.getScheduler().performTicks(3); + verifyNoInteractions(content, turner); + } + + @ParameterizedTest + @ValueSource(booleans = {true, false}) + void disappearingDisplayStopsTheAnimationImmediately(boolean removeContent) { + firePit(); + env.place("content", env.stack(roast(), Material.BEEF, 1)); + env.place("turner", new ItemStack(Material.STICK)); + ItemDisplay content = env.display("content", 1); + ItemDisplay turner = env.display("turner", 2); + env.display(env.furniture.getEntityId(), env.furniture.getLoc(), env.identity()); + manager.furnitureInteract(env.interact("turner")); + when((removeContent ? content : turner).isDead()).thenReturn(true); + env.server.getScheduler().performTicks(2); + verify(content, never()).teleport(any(Location.class)); + verify(turner, never()).teleport(any(Location.class)); + clearInvocations(content, turner); + env.server.getScheduler().performTicks(3); + verifyNoInteractions(content, turner); + } +} diff --git a/src/test/java/net/tfminecraft/cooking/manager/MealManagersCoverageTest.java b/src/test/java/net/tfminecraft/cooking/manager/MealManagersCoverageTest.java new file mode 100644 index 0000000..408f973 --- /dev/null +++ b/src/test/java/net/tfminecraft/cooking/manager/MealManagersCoverageTest.java @@ -0,0 +1,429 @@ +package net.tfminecraft.cooking.manager; + +import static org.junit.jupiter.api.Assertions.*; +import static org.mockito.ArgumentMatchers.*; +import static org.mockito.Mockito.*; + +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; +import java.util.UUID; + +import org.bukkit.Material; +import org.bukkit.block.BlockState; +import org.bukkit.entity.Item; +import org.bukkit.entity.LivingEntity; +import org.bukkit.entity.Player; +import org.bukkit.event.entity.EntityPickupItemEvent; +import org.bukkit.event.inventory.InventoryOpenEvent; +import org.bukkit.inventory.Inventory; +import org.bukkit.inventory.InventoryHolder; +import org.bukkit.inventory.InventoryView; +import org.bukkit.inventory.ItemStack; +import org.bukkit.persistence.PersistentDataType; +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.mockito.MockedStatic; + +import net.tfminecraft.cooking.Cooking; +import net.tfminecraft.cooking.cache.CategoryDictionary; +import net.tfminecraft.cooking.cache.FurnitureCache; +import net.tfminecraft.cooking.cache.ItemCache; +import net.tfminecraft.cooking.fishing.CustomFishingCatalog; +import net.tfminecraft.cooking.fishing.LegacyFishConversion; +import net.tfminecraft.cooking.fishing.SeafoodWholeItems; +import net.tfminecraft.cooking.fishing.VanillaFish; +import net.tfminecraft.cooking.item.FoodItem; +import net.tfminecraft.cooking.loader.ConversionLoader; +import net.tfminecraft.cooking.sausagemaker.SausageMakerCoverageTest.Station; +import net.tfminecraft.cooking.sausagemaker.SausageMakerCoverageTest.Workshop; +import net.tfminecraft.cooking.util.LegacyModelData; +import net.tfminecraft.cooking.utils.FoodParser; +import net.tfminecraft.cooking.utils.InventoryAdder; +import net.tfminecraft.cooking.utils.ItemBuilder; +import net.tfminecraft.cooking.utils.ItemUpdater; +import net.tfminecraft.cooking.utils.Keys; +import net.tfminecraft.interactiblefurniture.events.FurnitureBreakEvent; +import net.tfminecraft.interactiblefurniture.events.FurnitureSlotItemTakeEvent; +import net.tfminecraft.interactiblefurniture.furniture.Furniture; +import net.tfminecraft.interactiblefurniture.furniture.PlacedSlot; + +public class MealManagersCoverageTest { + Kitchen kitchen; + Workshop env; + Station plate, bowl; + PlateManager plates; + ConversionManager conversions; + MockedStatic parser; + MockedStatic legacy; + MockedStatic seafood; + Map previousConversions; + ItemStack pickupOutput; + int builtQuality; + + @BeforeEach void open() { + kitchen = new Kitchen(); env = kitchen.env; plate = kitchen.station("plate"); bowl = kitchen.station("bowl"); + plates = new PlateManager(); conversions = new ConversionManager(); + for (String id : List.of("food_item", "food_2", "sauce", "display_1")) plate.define(id); + for (String id : List.of("food_item", "input_1", "input_2", "input_3", "input_4", "input_5", "liquid", "display_1")) bowl.define(id); + previousConversions = ConversionLoader.conversions; ConversionLoader.conversions = new LinkedHashMap<>(); + ConversionLoader.conversions.put("v.wheat", "grain(type=wheat)"); ConversionLoader.conversions.put("v.beef", "meat(type=steak)"); + when(env.api.getChecker().checkItemWithPath(any(), eq("v.beef"))).thenAnswer(inv -> { + ItemStack item = inv.getArgument(0); return item != null && item.getType() == Material.BEEF; + }); + parser = env.scoped(mockStatic(FoodParser.class)); + parser.when(() -> FoodParser.parse(anyString())).thenAnswer(inv -> { + FoodParser.Result parsed = new FoodParser.Result(); parsed.template = kitchen.food("converted", "grain"); return parsed; + }); + legacy = env.scoped(mockStatic(LegacyFishConversion.class)); + seafood = env.scoped(mockStatic(SeafoodWholeItems.class)); + env.scoped(mockStatic(CustomFishingCatalog.class)).when(() -> CustomFishingCatalog.findVanilla("COD")) + .thenReturn(new VanillaFish("COD", "Cod", "filet", 30)); + pickupOutput = env.stack(kitchen.food("converted", "grain"), Material.PAPER, 1); + env.builder.when(() -> ItemBuilder.buildSingleWithQuality(any(), any(ItemStack.class), anyInt())).thenAnswer(inv -> { + builtQuality = inv.getArgument(2); return pickupOutput.clone(); + }); + } + + @AfterEach void close() { ConversionLoader.conversions = previousConversions; kitchen.close(); } + + FoodItem sauce() { return kitchen.food("sauce", "sauce"); } + ItemStack ladle(FoodItem food) { + ItemStack stack = env.stack(food, Material.PAPER, 1); var meta = stack.getItemMeta(); + meta.setDisplayName("Basil Sauce"); meta.getPersistentDataContainer().set(Keys.SAUCE_COLOUR, PersistentDataType.STRING, "#185d15"); + stack.setItemMeta(meta); return stack; + } + ItemStack soup() { + ItemStack stack = ladle(kitchen.food("soup", "soup")); var meta = stack.getItemMeta(); + meta.getPersistentDataContainer().set(Keys.SLOT_DATA, PersistentDataType.STRING, + "input_1.CARROT.0:input_2.POTATO.2:input_3.BEEF.0:input_4.WHEAT.0:input_5.BEETROOT.0:liquid.GLASS.0:undeclared.STONE.0"); + stack.setItemMeta(meta); return stack; + } + EntityPickupItemEvent pickup(ItemStack stack) { + Item item = mock(Item.class); when(item.getItemStack()).thenReturn(stack); + return new EntityPickupItemEvent(env.player, item, 0); + } + InventoryOpenEvent opening(Inventory top) { + InventoryView view = mock(InventoryView.class); when(view.getTopInventory()).thenReturn(top); when(view.getPlayer()).thenReturn(env.player); + return new InventoryOpenEvent(view); + } + + @Test + void plateAgingRemovesOrphanSauceAndUpdatesOnlyFoodSlots() { + PlacedSlot visual = plate.place("display_1", new ItemStack(Material.STONE)); + plate.place("sauce", new ItemStack(Material.STONE)); plates.update(plate.furniture); + assertFalse(plate.active.containsKey("sauce")); verify(env.manager).persistFurniture(plate.furniture); verify(visual, never()).forceModel(any()); + PlacedSlot food = plate.place("food_item", env.stack(kitchen.food("steak", "meat"), Material.BEEF, 1)); + plate.place("food_2", null); plate.place("decoration", new ItemStack(Material.STONE)); + Station other = kitchen.station("chair"); PlacedSlot untouched = other.place("food_item", food.getCurrentItem()); + plates.start(); env.server.getScheduler().performTicks(20); verify(food).forceModel(any()); verify(untouched, never()).forceModel(any()); + verify(env.manager).markDirty(plate.furniture); + kitchen.updater.when(() -> ItemUpdater.updateItem(any(), any(), any())).thenReturn(null); + env.server.getScheduler().performTicks(20); verify(food).forceModel(any()); verify(env.manager).markDirty(plate.furniture); + } + + @Test + void airAgingResultsNeverRemovePlatedFood() { + ItemStack original = env.stack(kitchen.food("steak", "meat"), Material.BEEF, 1); PlacedSlot slot = plate.place("food_item", original); + kitchen.updater.when(() -> ItemUpdater.updateItem(any(), any(), any())).thenReturn(new ItemStack(Material.AIR)); + plates.update(plate.furniture); assertSame(original, slot.getCurrentItem()); verify(slot, never()).forceModel(any()); + } + + @Test + void breakingClearsPlateDecorationsAndAllBowlContentsButKeepsLoosePlateFood() { + PlacedSlot meat = plate.place("food_item", new ItemStack(Material.BEEF)); PlacedSlot display = plate.place("display_1", new ItemStack(Material.STONE)); + PlacedSlot soup = bowl.place("food_item", new ItemStack(Material.PAPER)); PlacedSlot ingredient = bowl.place("input_1", new ItemStack(Material.CARROT)); + Station chair = kitchen.station("chair"); PlacedSlot other = chair.place("display_1", new ItemStack(Material.STONE)); + plates.remove(new FurnitureBreakEvent(chair.furniture, env.player)); verify(other, never()).clearModel(); + plates.remove(new FurnitureBreakEvent(plate.furniture, env.player)); verify(display).clearModel(); verify(meat, never()).clearModel(); + plates.remove(new FurnitureBreakEvent(bowl.furniture, env.player)); verify(soup).clearModel(); verify(ingredient).clearModel(); assertTrue(bowl.active.isEmpty()); + } + + @Test + void saucesRecognizeFoodMetadataAndCleanUpAfterTheLastServingLeaves() { + plate.place("display_1", env.stack(sauce(), Material.PAPER, 1)); plate.place("empty", null); plate.place("unknown", new ItemStack(Material.STONE)); + assertFalse(plates.hasSauce(plate.furniture)); + plate.place("food_2", env.stack(sauce(), Material.PAPER, 1)); assertTrue(plates.hasSauce(plate.furniture)); plate.active.remove("food_2"); + plate.place("sauce", new ItemStack(Material.STONE)); plate.place("food_item", null); + var take = new FurnitureSlotItemTakeEvent(env.player, plate.furniture, plate.define("food_item"), new ItemStack(Material.BEEF)); + plates.takeItem(take); assertTrue(plate.active.containsKey("sauce"), "Other active food slots still preserve the sauce visual"); + plate.active.remove("empty"); plate.active.remove("unknown"); plates.takeItem(take); assertFalse(plate.active.containsKey("sauce")); + bowl.place("sauce", new ItemStack(Material.STONE)); plates.takeItem(new FurnitureSlotItemTakeEvent(env.player, bowl.furniture, bowl.define("food_item"), null)); + assertTrue(bowl.active.containsKey("sauce")); + assertEquals("#185d15", PlateManager.sauceColour("#185d15", "ignored")); assertNull(PlateManager.sauceColour(null, "plain")); + } + + @Test + void sauceAppliesToEveryFoodAndStoresItsVisualWithoutReplacingDecorations() { + PlacedSlot first = plate.place("food_item", env.stack(kitchen.food("steak", "meat"), Material.BEEF, 1)); + PlacedSlot second = plate.place("food_2", env.stack(kitchen.food("carrot", "vegetable"), Material.CARROT, 1)); + PlacedSlot decoration = plate.place("display_1", new ItemStack(Material.STONE)); plate.place("empty", null); plate.place("unknown", new ItemStack(Material.STONE)); + ItemStack full = env.hold(ladle(sauce())); var event = plate.interact(); plates.interact(event); + assertTrue(event.isCancelled()); assertEquals(Material.STICK, env.player.getInventory().getItemInMainHand().getType()); + assertTrue(env.resolve(first.getCurrentItem()).hasSauce()); assertTrue(env.resolve(second.getCurrentItem()).hasSauce()); + assertEquals(full.getItemMeta().getDisplayName(), env.resolve(first.getCurrentItem()).getSauceName()); + verify(decoration, never()).forceModel(any()); assertTrue(plate.active.containsKey("sauce")); verify(env.manager).markDirty(plate.furniture); + ItemStack duplicate = env.hold(ladle(sauce())); plates.interact(plate.interact()); assertEquals(duplicate, env.player.getInventory().getItemInMainHand()); + } + + @ParameterizedTest + @ValueSource(strings = {"null", "air"}) + void aFailedSauceUpdatePreservesBothFoodsAndTheFullLadle(String failure) { + ItemStack first = env.stack(kitchen.food("steak", "meat"), Material.BEEF, 1), second = env.stack(kitchen.food("carrot", "vegetable"), Material.CARROT, 1); + PlacedSlot a = plate.place("food_item", first), b = plate.place("food_2", second); ItemStack full = env.hold(ladle(sauce())); + ItemStack invalid = failure.equals("air") ? new ItemStack(Material.AIR) : null; + kitchen.updater.when(() -> ItemUpdater.applyItemUpdate(eq(second), any(), any())).thenReturn(invalid); + plates.interact(plate.interact()); + assertEquals(full, env.player.getInventory().getItemInMainHand()); assertSame(first, a.getCurrentItem()); assertSame(second, b.getCurrentItem()); + assertFalse(plate.active.containsKey("sauce")); verify(env.manager, never()).markDirty(plate.furniture); + } + + @Test + void anEmptySaucePlaceholderReceivesTheNewVisual() { + plate.place("sauce", null); plate.place("food_item", env.stack(kitchen.food("steak", "meat"), Material.BEEF, 1)); + env.hold(ladle(sauce())); plates.interact(plate.interact()); + assertNotNull(plate.active.get("sauce").getCurrentItem()); assertTrue(plates.hasSauce(plate.furniture)); + } + + @ParameterizedTest + @ValueSource(strings = {"plate", "bowl", "plate-air", "bowl-air"}) + void anUnavailableEmptyLadleNeverConsumesTheHeldServing(String target) { + boolean onPlate = target.startsWith("plate"); Station holder = onPlate ? plate : bowl; + plate.place("food_item", env.stack(kitchen.food("steak", "meat"), Material.BEEF, 1)); + ItemStack full = env.hold(onPlate ? ladle(sauce()) : soup()); + when(env.api.getCreator().getItemFromPath("tool:ladle")).thenReturn(target.endsWith("-air") ? new ItemStack(Material.AIR) : null); + plates.interact(holder.interact()); assertEquals(full, env.player.getInventory().getItemInMainHand()); + } + + @Test + void sauceCanUpdateFoodEvenWhenTheOptionalVisualSlotIsUnavailable() { + for (boolean missingType : List.of(false, true)) { + plate.active.clear(); PlacedSlot food = plate.place("food_item", env.stack(kitchen.food("steak", "meat"), Material.BEEF, 1)); + plate.definitions.remove("sauce"); if (missingType) when(plate.furniture.getType()).thenReturn(null); + env.hold(ladle(sauce())); plates.interact(plate.interact()); + assertTrue(env.resolve(food.getCurrentItem()).hasSauce()); assertFalse(plate.active.containsKey("sauce")); + } + } + + @ParameterizedTest + @ValueSource(strings = {"null", "air"}) + void unavailableOptionalSauceVisualDoesNotPreventApplyingSauceToFood(String failure) { + PlacedSlot food = plate.place("food_item", env.stack(kitchen.food("steak", "meat"), Material.BEEF, 1)); + when(env.api.getCreator().getItemFromPath("model.sauce")).thenReturn(failure.equals("null") ? null : new ItemStack(Material.AIR)); + env.hold(ladle(sauce())); plates.interact(plate.interact()); + assertTrue(env.resolve(food.getCurrentItem()).hasSauce()); assertFalse(plate.active.containsKey("sauce")); + assertEquals(Material.STICK, env.player.getInventory().getItemInMainHand().getType()); verify(env.manager).markDirty(plate.furniture); + ItemStack duplicate = env.hold(ladle(sauce())); plates.interact(plate.interact()); + assertEquals(duplicate, env.player.getInventory().getItemInMainHand(), "Sauced food still rejects a second ladle when its optional visual is unavailable"); + } + + @Test + void soupCopiesDeclaredIngredientsSpreadsThemAndKeepsExistingDecorations() { + PlacedSlot existing = bowl.place("input_2", new ItemStack(Material.POTATO)); ItemStack full = env.hold(soup()); + var event = bowl.interact(); plates.interact(event); assertTrue(event.isCancelled()); + assertEquals(Material.STICK, env.player.getInventory().getItemInMainHand().getType()); + assertEquals(full, bowl.active.get("food_item").getCurrentItem()); assertFalse(bowl.active.containsKey("undeclared")); + verify(existing, never()).forceModel(any()); assertEquals(Material.CARROT, bowl.active.get("input_1").getCurrentItem().getType()); + verify(bowl.active.get("input_1")).applyDisplayData(argThat(data -> Math.abs(data.getzPos() + 0.12f) < 0.001)); + verify(bowl.active.get("liquid"), never()).applyDisplayData(any()); verify(env.manager).markDirty(bowl.furniture); + } + + @ParameterizedTest + @ValueSource(strings = {"occupied", "missing-slot", "missing-type"}) + void soupCannotOverwriteAnExistingServingOrDisappearIntoAnUnusableBowl(String failure) { + ItemStack original = soup(); + if (failure.equals("occupied")) bowl.place("food_item", original); + if (failure.equals("missing-slot")) bowl.definitions.remove("food_item"); + if (failure.equals("missing-type")) when(bowl.furniture.getType()).thenReturn(null); + ItemStack full = env.hold(soup()); plates.interact(bowl.interact()); + assertEquals(full, env.player.getInventory().getItemInMainHand()); + if (failure.equals("occupied")) assertSame(original, bowl.active.get("food_item").getCurrentItem()); + else assertTrue(bowl.active.isEmpty()); + } + + @Test + void bowlIngredientPositionsAreDistinctAndUnsupportedSlotNamesHaveNoOffset() { + for (int index = 1; index <= 5; index++) { + var data = BowlIngredientLayout.offsetFor("input_" + index); + assertEquals(0.12, Math.hypot(data.getxPos(), data.getzPos()), 0.001); assertEquals(index - 1, BowlIngredientLayout.inputIndex("input_" + index)); + } + for (String id : new String[] {null, "food_item", "input_0", "input_6", "input_bad"}) assertNull(BowlIngredientLayout.offsetFor(id)); + } + + @Test + void furnitureInteractionIgnoresNonMealsAndAddsFoodDisplayPosesOnlyToCookingFurniture() { + plates.interact(plate.interact()); env.hold(new ItemStack(Material.STONE)); plates.interact(plate.interact()); plates.interact(bowl.interact()); + env.hold(env.stack(kitchen.food("steak", "meat"), Material.BEEF, 1)); + var plateEvent = plate.interact(); plates.interact(plateEvent); assertFalse(plateEvent.isCancelled()); + var bowlEvent = bowl.interact(); plates.interact(bowlEvent); assertFalse(bowlEvent.isCancelled()); + Station chair = kitchen.station("chair"); when(chair.type.getItemPath()).thenReturn("other:chair"); + var ignored = chair.add("food", env.player.getInventory().getItemInMainHand()); plates.addItem(ignored); assertNull(ignored.getDisplayData()); + var plain = plate.add("food_item", new ItemStack(Material.STONE)); plates.addItem(plain); assertNull(plain.getDisplayData()); + var food = plate.add("food_item", env.player.getInventory().getItemInMainHand()); plates.addItem(food); assertNotNull(food.getDisplayData()); + } + + @Test + void publicNullSlotEntriesDoNotCrashMealMaintenanceOrServing() { + plate.furniture.getActiveSlots().put("missing", null); + assertDoesNotThrow(() -> plates.update(plate.furniture)); assertDoesNotThrow(() -> plates.clear(plate.furniture)); + assertFalse(plates.hasSauce(plate.furniture)); + env.hold(ladle(sauce())); assertDoesNotThrow(() -> plates.interact(plate.interact())); + assertEquals(Material.PAPER, env.player.getInventory().getItemInMainHand().getType()); + } + + @Test + void aNullOptionalSauceSlotDoesNotCrashAfterApplyingSauce() { + plate.furniture.getActiveSlots().put("sauce", null); + PlacedSlot food = plate.place("food_item", env.stack(kitchen.food("steak", "meat"), Material.BEEF, 1)); env.hold(ladle(sauce())); + assertDoesNotThrow(() -> plates.interact(plate.interact())); assertTrue(env.resolve(food.getCurrentItem()).hasSauce()); + assertEquals(Material.STICK, env.player.getInventory().getItemInMainHand().getType()); + } + + @Test + void aNullBowlFoodSlotIsRecreatedWhenSoupIsServed() { + bowl.furniture.getActiveSlots().put("food_item", null); ItemStack full = env.hold(soup()); + assertDoesNotThrow(() -> plates.interact(bowl.interact())); + assertEquals(Material.STICK, env.player.getInventory().getItemInMainHand().getType()); + assertEquals(full, bowl.active.get("food_item").getCurrentItem()); assertEquals(Material.CARROT, bowl.active.get("input_1").getCurrentItem().getType()); + verify(env.manager).markDirty(bowl.furniture); + } + + @Test + void missingFoodModelLeavesTheFurnitureDefaultDisplayUnchanged() { + FoodItem unmodeled = kitchen.food("unmodeled", "meat"); unmodeled.setModel(null); + var event = plate.add("food_item", env.stack(unmodeled, Material.BEEF, 1)); plates.addItem(event); assertNull(event.getDisplayData()); + } + + @Test + void cancelledPickupsPreserveTheProtectedWorldItem() { + env.server.getPluginManager().registerEvents(conversions, org.mockbukkit.mockbukkit.MockBukkit.createMockPlugin("MealManagerEvents")); + var event = pickup(new ItemStack(Material.WHEAT, 3)); event.setCancelled(true); env.server.getPluginManager().callEvent(event); + verify(event.getItem(), never()).remove(); assertTrue(env.player.getInventory().isEmpty()); + } + + @Test + void ordinaryPickupConversionPreservesAmountAndUsesFarmOrOriginQuality() { + var farm = pickup(new ItemStack(Material.WHEAT, 4)); conversions.pickup(farm); + assertTrue(farm.isCancelled()); verify(farm.getItem()).remove(); assertEquals(1, builtQuality); assertEquals(4, env.player.getInventory().getItem(0).getAmount()); + env.player.getInventory().clear(); var other = pickup(new ItemStack(Material.BEEF, 2)); conversions.pickup(other); + assertTrue(other.isCancelled()); assertEquals(3, builtQuality); assertEquals(2, env.player.getInventory().getItem(0).getAmount()); + } + + @Test + void legacyAndVanillaFishConversionsTakePriorityAndDropInventoryLeftovers() { + ItemStack raw = new ItemStack(Material.COD, 3), legacyOutput = env.stack(kitchen.food("legacy_fish", "fish"), Material.SALMON, 1); + legacy.when(() -> LegacyFishConversion.convert(env.player, raw)).thenReturn(legacyOutput); + var old = pickup(raw); conversions.pickup(old); assertTrue(old.isCancelled()); assertEquals(Material.SALMON, env.player.getInventory().getItem(0).getType()); + env.player.getInventory().clear(); legacy.when(() -> LegacyFishConversion.convert(any(), any())).thenReturn(null); + seafood.when(() -> SeafoodWholeItems.build(any(), any(), anyInt())).thenReturn(pickupOutput.clone()); + try (MockedStatic delivery = mockStatic(InventoryAdder.class)) { + delivery.when(() -> InventoryAdder.addItem(any(), any())).thenAnswer(inv -> inv.getArgument(1)); + var vanilla = pickup(raw); conversions.pickup(vanilla); assertTrue(vanilla.isCancelled()); + } + assertEquals(1, env.dropped.size()); assertEquals(3, env.dropped.getFirst().getAmount()); + } + + @Test + void unsupportedPickupsMissingParsersAndCustomFishModelsStayInTheWorld() { + var known = pickup(pickupOutput); conversions.pickup(known); verify(known.getItem(), never()).remove(); + var nonplayer = new EntityPickupItemEvent(mock(LivingEntity.class), pickup(new ItemStack(Material.WHEAT)).getItem(), 0); + conversions.pickup(nonplayer); assertFalse(nonplayer.isCancelled()); + var unknown = pickup(new ItemStack(Material.STONE)); conversions.pickup(unknown); assertFalse(unknown.isCancelled()); + var unavailableFish = pickup(new ItemStack(Material.COD)); conversions.pickup(unavailableFish); assertFalse(unavailableFish.isCancelled()); + // Bukkit permits a vanilla stack without metadata; MockBukkit otherwise eagerly creates it. + ItemStack plainFish = spy(new ItemStack(Material.COD)); doReturn(false).when(plainFish).hasItemMeta(); + var noMetadata = pickup(plainFish); conversions.pickup(noMetadata); assertFalse(noMetadata.isCancelled()); + ItemStack custom = new ItemStack(Material.COD); var meta = custom.getItemMeta(); LegacyModelData.set(meta, 7); custom.setItemMeta(meta); + var customFish = pickup(custom); conversions.pickup(customFish); assertFalse(customFish.isCancelled()); + parser.when(() -> FoodParser.parse(anyString())).thenReturn(null); + var missing = pickup(new ItemStack(Material.WHEAT)); conversions.pickup(missing); assertFalse(missing.isCancelled()); + parser.when(() -> FoodParser.parse(anyString())).thenReturn(new FoodParser.Result()); + var empty = pickup(new ItemStack(Material.WHEAT)); conversions.pickup(empty); assertFalse(empty.isCancelled()); + } + + @ParameterizedTest + @ValueSource(strings = {"null", "air", "legacy-air", "fish-air"}) + void unusablePickupReplacementsKeepTheOriginalItemEntity(String failure) { + ItemStack source = new ItemStack(failure.equals("fish-air") ? Material.COD : Material.WHEAT, 3); + ItemStack invalid = failure.equals("null") ? null : new ItemStack(Material.AIR); + if (failure.equals("legacy-air")) legacy.when(() -> LegacyFishConversion.convert(any(), any())).thenReturn(invalid); + else if (failure.equals("fish-air")) seafood.when(() -> SeafoodWholeItems.build(any(), any(), anyInt())).thenReturn(invalid); + else env.builder.when(() -> ItemBuilder.buildSingleWithQuality(any(), any(ItemStack.class), anyInt())).thenReturn(invalid); + var event = pickup(source); assertDoesNotThrow(() -> conversions.pickup(event)); + assertFalse(event.isCancelled()); verify(event.getItem(), never()).remove(); assertTrue(env.player.getInventory().isEmpty()); + } + + @Test + void inventoryOpeningNormalizesOnlyAgingAndPreservesStackAmountsAndCarveState() { + Inventory top = env.server.createInventory(null, 9); ItemStack base = pickupOutput.clone(); + ItemStack older = age(base, 100L, "freshness.1"), newer = age(base, 900L, "freshness.4"); + older.setAmount(3); newer.setAmount(2); top.setItem(0, newer); env.player.getInventory().setItem(0, older); + ItemStack carved = base.clone(); var meta = carved.getItemMeta(); meta.getPersistentDataContainer().set(Keys.CARVE_REMAINING, PersistentDataType.INTEGER, 2); carved.setItemMeta(meta); + top.setItem(2, carved); top.setItem(3, new ItemStack(Material.STONE)); + conversions.onOpen(opening(top)); + assertEquals(3, env.player.getInventory().getItem(0).getAmount()); assertEquals(2, top.getItem(0).getAmount()); + assertEquals(900L, env.player.getInventory().getItem(0).getItemMeta().getPersistentDataContainer().get(Keys.LAST_UPDATE, PersistentDataType.LONG)); + assertEquals(carved, top.getItem(2)); + assertTrue(ConversionManager.sameApartFromAging(older, newer)); assertFalse(ConversionManager.sameApartFromAging(base, carved)); + assertTrue(ConversionManager.sameApartFromAging(new ItemStack(Material.AIR), new ItemStack(Material.AIR))); + } + + @Test + void virtualPluginInventoriesAreSkippedButBlockAndEntityHoldersAreSupported() { + Inventory custom = env.server.createInventory(mock(InventoryHolder.class), 9); custom.setItem(0, pickupOutput); + env.player.getInventory().setItem(0, age(pickupOutput, 500L, "freshness.1")); + ItemStack before = env.player.getInventory().getItem(0).clone(); conversions.onOpen(opening(custom)); assertEquals(before, env.player.getInventory().getItem(0)); + for (InventoryHolder holder : List.of((InventoryHolder) mock(BlockState.class, withSettings().extraInterfaces(InventoryHolder.class)), env.player)) { + Inventory inventory = env.server.createInventory(holder, 9); inventory.setItem(0, pickupOutput); conversions.onOpen(opening(inventory)); + assertEquals(pickupOutput.getType(), inventory.getItem(0).getType()); + } + } + + ItemStack age(ItemStack source, long timestamp, String tags) { + ItemStack result = source.clone(); var meta = result.getItemMeta(); + meta.getPersistentDataContainer().set(Keys.LAST_UPDATE, PersistentDataType.LONG, timestamp); + meta.getPersistentDataContainer().set(Keys.AGE_REMAINDER, PersistentDataType.STRING, "freshness=0.5"); + meta.getPersistentDataContainer().set(Keys.TAGS, PersistentDataType.STRING, tags); + meta.getPersistentDataContainer().set(Keys.LORE_INDEX_MAP, PersistentDataType.STRING, "aging=0"); + meta.setLore(List.of("Different aging lore " + timestamp)); result.setItemMeta(meta); return result; + } + + public static final class Kitchen implements AutoCloseable { + public final Workshop env = new Workshop(); + public final Map placed = new LinkedHashMap<>(); + public final MockedStatic updater; + final String oldPlate = FurnitureCache.plate, oldBowl = FurnitureCache.bowl, oldLadle = ItemCache.ladle; + public Kitchen() { + FurnitureCache.plate = "plate"; FurnitureCache.bowl = "bowl"; ItemCache.ladle = "tool:ladle"; + when(env.manager.getPlacedFurniture()).thenReturn(placed); + when(env.api.getCreator().getItemFromPath("tool:ladle")).thenAnswer(inv -> new ItemStack(Material.STICK)); + updater = env.scoped(mockStatic(ItemUpdater.class)); + updater.when(() -> ItemUpdater.updateItem(any(), any(), any())).thenAnswer(inv -> updated(inv.getArgument(0), inv.getArgument(1))); + updater.when(() -> ItemUpdater.applyItemUpdate(any(), any(), any())).thenAnswer(inv -> updated(inv.getArgument(0), inv.getArgument(1))); + var modelData = env.scoped(mockStatic(LegacyModelData.class)); + modelData.when(() -> LegacyModelData.has(any())).thenAnswer(inv -> ((org.bukkit.inventory.meta.ItemMeta) inv.getArgument(0)).hasCustomModelData()); + modelData.when(() -> LegacyModelData.get(any())).thenAnswer(inv -> ((org.bukkit.inventory.meta.ItemMeta) inv.getArgument(0)).getCustomModelData()); + modelData.when(() -> LegacyModelData.set(any(), anyInt())).thenAnswer(inv -> { + ((org.bukkit.inventory.meta.ItemMeta) inv.getArgument(0)).setCustomModelData(inv.getArgument(1)); return null; + }); + env.scoped(mockStatic(CategoryDictionary.class)).when(() -> CategoryDictionary.getSauceItemPath(any(), anyInt())).thenReturn("model.sauce"); + } + ItemStack updated(ItemStack source, FoodItem food) { return env.stack(new FoodItem(food), source.getType(), source.getAmount()); } + public FoodItem food(String id, String category) { FoodItem food = env.food(id, "Cow", 3); food.setCategory(category); return food; } + public Station station(String kind) { + Station station = env.station(kind); placed.put(station.id, station.furniture); when(station.type.getItemPath()).thenReturn("ia.tfmc_cooking:" + kind); + doAnswer(inv -> { + String id = inv.getArgument(0); PlacedSlot existing = station.active.get(id); + return existing != null ? existing : station.place(id, null); + }).when(station.furniture).getOrCreatePlacedSlot(anyString()); + return station; + } + @Override public void close() { + env.server.getScheduler().cancelTasks(Cooking.plugin); env.close(); + FurnitureCache.plate = oldPlate; FurnitureCache.bowl = oldBowl; ItemCache.ladle = oldLadle; + } + } +} diff --git a/src/test/java/net/tfminecraft/cooking/milling/MillingStoneCoverageTest.java b/src/test/java/net/tfminecraft/cooking/milling/MillingStoneCoverageTest.java new file mode 100644 index 0000000..f9a9ef8 --- /dev/null +++ b/src/test/java/net/tfminecraft/cooking/milling/MillingStoneCoverageTest.java @@ -0,0 +1,409 @@ +package net.tfminecraft.cooking.milling; + +import static org.junit.jupiter.api.Assertions.*; +import static org.mockito.ArgumentMatchers.*; +import static org.mockito.Mockito.*; + +import java.io.ByteArrayOutputStream; +import java.io.PrintStream; +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; +import java.util.concurrent.atomic.AtomicInteger; + +import org.bukkit.Material; +import org.bukkit.inventory.ItemStack; +import org.bukkit.util.Transformation; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.io.TempDir; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.CsvSource; +import org.junit.jupiter.params.provider.ValueSource; +import org.mockbukkit.mockbukkit.entity.ItemDisplayMock; +import org.mockito.MockedStatic; + +import net.tfminecraft.cooking.cache.ItemCache; +import net.tfminecraft.cooking.item.FoodItem; +import net.tfminecraft.cooking.item.IngredientLineage; +import net.tfminecraft.cooking.quality.OriginQualityResolver; +import net.tfminecraft.cooking.quality.QualityConfig; +import net.tfminecraft.cooking.sausagemaker.SausageMakerCoverageTest.Station; +import net.tfminecraft.cooking.sausagemaker.SausageMakerCoverageTest.Workshop; +import net.tfminecraft.cooking.utils.InventoryAdder; +import net.tfminecraft.cooking.utils.ItemBuilder; +import net.tfminecraft.interactiblefurniture.events.FurnitureBreakEvent; +import net.tfminecraft.interactiblefurniture.events.FurniturePlaceEvent; +import net.tfminecraft.interactiblefurniture.furniture.Furniture; +import net.tfminecraft.interactiblefurniture.furniture.PlacedSlot; + +class MillingStoneCoverageTest { + Workshop env; + Station station; + MillingStoneHandler handler; + Map previous; + @BeforeEach void open() { + previous = new LinkedHashMap<>(); + for (MillingRecipe recipe : MillingRecipeRegistry.getAll()) previous.put(recipe.getId(), recipe); + env = new Workshop(); station = env.station("milling_stone"); handler = new MillingStoneHandler(); + for (String slot : List.of("top", "flour", "wheat_1", "wheat_2", "wheat_3", "wheat_4")) station.define(slot); + recipe("wheat", "food:grain", "v.wheat"); + } + @AfterEach void close() { MillingRecipeRegistry.load(previous); env.close(); } + MillingRecipe recipe(String inputFood, String matcher, String fallback) { + MillingRecipe recipe = new MillingRecipe("flour", "milling_stone", 8, inputFood, matcher, fallback, "flour", 1, 8); + MillingRecipeRegistry.load(Map.of("flour", recipe)); + return recipe; + } + void loaded() { + env.hold(new ItemStack(Material.WHEAT, 8)); handler.onInteract(station.interact()); env.hold(null); + assertEquals(MillingStoneStage.LOADED, MillingStoneState.getStage(station.furniture)); + } + void ready() { loaded(); handler.onInteract(station.interact()); assertEquals(MillingStoneStage.READY, MillingStoneState.getStage(station.furniture)); } + + @Test + void recipeLoaderReadsOptionalSettingsDefaultsAndCaseInsensitiveFurniture(@TempDir Path directory) throws Exception { + Path config = directory.resolve("milling.yml"); + Files.writeString(config, """ + ignored: scalar + missing-furniture: + output-food: flour + missing-output: + furniture: mill + blank-furniture: + furniture: ' ' + output-food: flour + blank-output: + furniture: other + output-food: ' ' + defaults: + furniture: MILLING_STONE + output-food: flour + custom: + furniture: second_mill + input-count: 4 + input-food: barley + input: food:grain + vanilla-fallback: v.wheat + output-food: barley_flour + revolutions: 2 + duration-ticks: 10 + """); + new MillingRecipeLoader().load(config.toFile()); + assertEquals(2, MillingRecipeRegistry.getAll().size()); + MillingRecipe defaults = MillingRecipeRegistry.getByFurnitureId("milling_STONE"); + assertEquals("defaults", defaults.getId()); assertEquals("MILLING_STONE", defaults.getFurnitureId()); + assertEquals(8, defaults.getInputCount()); assertEquals(4, defaults.getRevolutions()); assertEquals(60, defaults.getDurationTicks()); + assertNull(defaults.getInputFood()); assertNull(defaults.getInputMatcher()); assertNull(defaults.getVanillaFallback()); + MillingRecipe custom = MillingRecipeRegistry.getByFurnitureId("SECOND_MILL"); + assertEquals(4, custom.getInputCount()); assertEquals("barley", custom.getInputFood()); + assertEquals("food:grain", custom.getInputMatcher()); assertEquals("v.wheat", custom.getVanillaFallback()); + assertEquals("barley_flour", custom.getOutputFood()); assertEquals(2, custom.getRevolutions()); assertEquals(10, custom.getDurationTicks()); + assertNull(MillingRecipeRegistry.getByFurnitureId(null)); assertNull(MillingRecipeRegistry.getByFurnitureId("missing")); + assertThrows(UnsupportedOperationException.class, () -> MillingRecipeRegistry.getAll().clear()); + } + + @Test + void unreadableRecipeFilesClearOldRecipesAndReportTheFilename(@TempDir Path directory) { + PrintStream original = System.err; + ByteArrayOutputStream diagnostics = new ByteArrayOutputStream(); + try (PrintStream capture = new PrintStream(diagnostics)) { + System.setErr(capture); + new MillingRecipeLoader().load(directory.resolve("missing.yml").toFile()); + } finally { System.setErr(original); } + assertTrue(MillingRecipeRegistry.getAll().isEmpty()); + assertTrue(diagnostics.toString().contains("missing.yml")); + } + + @ParameterizedTest + @CsvSource({"input-count,0", "input-count,-1", "duration-ticks,0", "duration-ticks,-1"}) + void invalidCountsAndDurationsCannotCreateFreeFlourOrInvalidTransforms(String field, int value, @TempDir Path directory) throws Exception { + Path config = directory.resolve("invalid.yml"); + Files.writeString(config, "invalid:\n furniture: milling_stone\n output-food: flour\n vanilla-fallback: v.wheat\n " + field + ": " + value + "\n"); + new MillingRecipeLoader().load(config.toFile()); + ItemStack held = env.hold(new ItemStack(Material.WHEAT, 8)); + handler.onInteract(station.interact()); + assertEquals(8, held.getAmount()); assertTrue(station.variables.isEmpty()); + assertTrue(MillingRecipeRegistry.getAll().isEmpty(), "Invalid recipe settings must be rejected before a player can consume or duplicate items"); + } + + @Test + void stateSnapshotsRoundTripAndLegacyVisualsProvideStageFallbacks() { + assertEquals(MillingStoneStage.EMPTY, MillingStoneState.getStage(station.furniture)); + assertEquals(QualityConfig.getPickupMin(), MillingStoneState.getOutputQuality(station.furniture)); + assertTrue(MillingStoneState.getLineage(station.furniture).isEmpty()); + station.variables.put(MillingStoneState.VAR_STAGE, "removed_stage"); + station.place("wheat_3", new ItemStack(Material.WHEAT)); + assertEquals(MillingStoneStage.LOADED, MillingStoneState.getStage(station.furniture)); + station.place("flour", new ItemStack(Material.SUGAR)); + assertEquals(MillingStoneStage.READY, MillingStoneStage.fromFurniture(station.furniture)); + station.variables.put(MillingStoneState.VAR_STAGE, 12); + assertEquals(MillingStoneStage.READY, MillingStoneState.getStage(station.furniture)); + IngredientLineage lineage = IngredientLineage.ofMain("Emmer"); + MillingStoneState.setLineage(station.furniture, lineage); MillingStoneState.setOutputQuality(station.furniture, 4); + MillingStoneState.setStage(station.furniture, MillingStoneStage.LOADED); + assertEquals(lineage, MillingStoneState.getLineage(station.furniture)); + assertEquals(4, MillingStoneState.getOutputQuality(station.furniture)); + assertEquals(MillingStoneStage.LOADED, MillingStoneState.getStage(station.furniture)); + station.variables.put("unrelated", "keep"); MillingStoneState.clear(station.furniture); + assertEquals(Map.of("unrelated", "keep"), station.variables); + } + + @Test + void visualsShowOnlyTheCurrentStageAndUseTheConfiguredOrFallbackFlourModel() { + MillingStoneDisplay.syncVisuals(station.furniture, MillingStoneStage.LOADED); + assertTrue(station.active.containsKey("top")); assertFalse(station.active.containsKey("flour")); + for (String slot : MillingStoneSlots.WHEAT) assertEquals(Material.WHEAT, station.active.get(slot).getCurrentItem().getType()); + MillingStoneDisplay.syncVisuals(station.furniture, MillingStoneStage.READY); + assertTrue(station.active.containsKey("flour")); + for (String slot : MillingStoneSlots.WHEAT) assertFalse(station.active.containsKey(slot)); + verify(env.api.getCreator()).getItemFromPath("model.flour"); + env.cache.when(() -> ItemCache.getMixingModel("flour")).thenReturn(null); + MillingStoneDisplay.showFlour(station.furniture); + verify(env.api.getCreator()).getItemFromPath("ia.tfmc_cooking:flour_model"); + MillingStoneDisplay.syncVisuals(station.furniture, MillingStoneStage.EMPTY); + assertEquals(List.of("top"), List.copyOf(station.active.keySet())); + } + + @Test + void missingDefinitionsTypesAndModelsDoNotCreateBrokenDisplays() { + station.definitions.clear(); + MillingStoneDisplay.showTop(station.furniture); MillingStoneDisplay.showWheat(station.furniture); + MillingStoneDisplay.showFlour(station.furniture); MillingStoneDisplay.clearAll(station.furniture); + assertTrue(station.active.isEmpty()); + when(station.furniture.getType()).thenReturn(null); + MillingStoneDisplay.showTop(station.furniture); MillingStoneDisplay.showWheat(station.furniture); + MillingStoneDisplay.showFlour(station.furniture); MillingStoneDisplay.clearAll(station.furniture); + assertTrue(station.active.isEmpty()); + when(station.furniture.getType()).thenReturn(station.type); + station.define("top"); station.define("flour"); + when(env.api.getCreator().getItemFromPath(anyString())).thenReturn(null); + MillingStoneDisplay.showTop(station.furniture); MillingStoneDisplay.showFlour(station.furniture); + assertTrue(station.active.isEmpty()); + } + + @Test + void placementAndUnrelatedFurnitureEventsRespectTheRegistry() { + Station chair = env.station("chair"); + handler.onPlace(new FurniturePlaceEvent(chair.furniture, env.player)); + handler.onInteract(chair.interact()); handler.onBreak(new FurnitureBreakEvent(chair.furniture, env.player)); + assertTrue(chair.active.isEmpty()); + handler.onPlace(new FurniturePlaceEvent(station.furniture, env.player)); + assertTrue(station.active.containsKey("top")); verify(env.manager).markDirty(station.furniture); + MillingRecipeRegistry.load(Map.of()); + ItemStack hand = env.hold(new ItemStack(Material.WHEAT, 8)); + var event = station.interact(); handler.onInteract(event); assertFalse(event.isCancelled()); assertEquals(8, hand.getAmount()); + handler.onBreak(new FurnitureBreakEvent(station.furniture, env.player)); + assertTrue(station.variables.isEmpty()); + } + + @Test + void loadingRejectsInsufficientOrWrongInputWithoutChangingItsCount() { + ItemStack shortStack = env.hold(new ItemStack(Material.WHEAT, 4)); + var tooFew = station.interact(); handler.onInteract(tooFew); + assertTrue(tooFew.isCancelled()); assertEquals(4, shortStack.getAmount()); + assertTrue(env.messages.contains("§cYou need 8 wheat to fill the mill.")); + ItemStack wrong = env.hold(new ItemStack(Material.STONE, 8)); + handler.onInteract(station.interact()); assertEquals(8, wrong.getAmount()); + assertTrue(env.messages.contains("§cThat doesn't go in the mill.")); + assertTrue(station.variables.isEmpty()); + recipe(null, null, null); + FoodItem rejected = env.food("barley", "Barley", 3); + env.hold(env.stack(rejected, Material.WHEAT, 8)); + handler.onInteract(station.interact()); assertTrue(station.variables.isEmpty()); + } + + @Test + void configuredFoodPreservesQualityAndLineageAcrossLoadingGrindingAndTaking() { + FoodItem wheat = env.food("WHEAT", "Emmer", 4); wheat.setCategory("grain"); + IngredientLineage lineage = IngredientLineage.ofMain("Emmer"); wheat.setLineage(lineage); + ItemStack input = env.hold(env.stack(wheat, Material.WHEAT, 10)); + handler.onPlace(new FurniturePlaceEvent(station.furniture, env.player)); + ItemDisplayMock top = station.display("top"); Transformation start = top.getTransformation(); + var load = station.interact(); handler.onInteract(load); + assertTrue(load.isCancelled()); assertEquals(2, input.getAmount()); + assertEquals(4, MillingStoneState.getOutputQuality(station.furniture)); + assertEquals(lineage, MillingStoneState.getLineage(station.furniture)); + var occupied = station.interact(); handler.onInteract(occupied); + assertTrue(occupied.isCancelled()); assertEquals(2, input.getAmount()); + env.hold(null); handler.onInteract(station.interact()); + assertTrue(MillingStoneAnimation.isAnimating(station.furniture)); + handler.onInteract(station.interact()); + verify(env.player, times(2)).swingMainHand(); + env.server.getScheduler().performTicks(3); + assertNotEquals(start.getLeftRotation(), top.getTransformation().getLeftRotation()); + assertEquals(start.getTranslation(), top.getTransformation().getTranslation()); + env.server.getScheduler().performTicks(8); + assertEquals(start, top.getTransformation()); + assertFalse(MillingStoneAnimation.isAnimating(station.furniture)); + assertEquals(MillingStoneStage.READY, MillingStoneState.getStage(station.furniture)); + assertTrue(station.active.containsKey("flour")); + env.hold(new ItemStack(Material.STICK)); + var full = station.interact(); handler.onInteract(full); assertTrue(full.isCancelled()); + assertTrue(station.active.containsKey("flour")); + env.hold(null); handler.onInteract(station.interact()); + assertEquals(1, env.outputs().size()); assertEquals("flour", env.lastBuilt.getId()); + assertEquals("grain", env.lastBuilt.getCategory()); assertEquals("Wheat", env.lastBuilt.getOrigin()); + assertEquals(lineage, env.lastBuilt.getLineage()); assertEquals(4, env.lastQuality); + assertTrue(station.variables.isEmpty()); assertFalse(station.active.containsKey("flour")); + assertEquals(MillingStoneStage.EMPTY, MillingStoneState.getStage(station.furniture)); + } + + @ParameterizedTest + @ValueSource(strings = {"Barley", "", "NULL"}) + void inputMatcherAcceptsOtherGrainsAndDerivesLineageWhenMissing(String origin) { + recipe(null, "food:grain", null); + FoodItem barley = env.food("barley", origin.equals("NULL") ? null : origin, 2); barley.setCategory("grain"); + env.hold(env.stack(barley, Material.WHEAT, 8)); handler.onInteract(station.interact()); + assertEquals(2, MillingStoneState.getOutputQuality(station.furniture)); + assertEquals(IngredientLineage.ofMain(origin.equals("Barley") ? "Barley" : "Wheat"), MillingStoneState.getLineage(station.furniture)); + } + + @ParameterizedTest + @ValueSource(booleans = {true, false}) + void vanillaWheatUsesOriginQualityWithOrWithoutAFoodTemplate(boolean templateAvailable) { + FoodItem template = templateAvailable ? env.templates.get("wheat") : null; + if (!templateAvailable) env.templates.remove("wheat"); + loaded(); + assertEquals(3, MillingStoneState.getOutputQuality(station.furniture)); + assertEquals(IngredientLineage.ofMain("Wheat"), MillingStoneState.getLineage(station.furniture)); + env.originQuality.verify(() -> OriginQualityResolver.resolve(env.player, template)); + } + + @Test + void legacyAndSavedStatesRebuildMissingVisualsAndKeepAlreadyPresentDisplays() { + station.place("wheat_3", new ItemStack(Material.WHEAT)); + MillingStoneState.setStage(station.furniture, MillingStoneStage.EMPTY); + env.hold(new ItemStack(Material.STICK)); handler.onInteract(station.interact()); + assertEquals(MillingStoneStage.LOADED, MillingStoneState.getStage(station.furniture)); + assertTrue(station.active.containsKey("wheat_1")); + MillingStoneDisplay.clearAll(station.furniture); MillingStoneState.setStage(station.furniture, MillingStoneStage.READY); + handler.onInteract(station.interact()); assertTrue(station.active.containsKey("flour")); + PlacedSlot flour = station.active.get("flour"); + handler.onInteract(station.interact()); assertSame(flour, station.active.get("flour")); + MillingStoneDisplay.clearAll(station.furniture); MillingStoneState.clear(station.furniture); env.hold(null); + var empty = station.interact(); handler.onInteract(empty); assertTrue(empty.isCancelled()); assertTrue(env.outputs().isEmpty()); + } + + @Test + void unavailableFlourDoesNotClearAReadyBatchAndRejectedInventoryDeliveryDropsIt() { + ready(); env.templates.remove("flour"); + handler.onInteract(station.interact()); + assertTrue(env.messages.contains("§cFailed to create flour.")); + assertEquals(MillingStoneStage.READY, MillingStoneState.getStage(station.furniture)); + assertTrue(station.active.containsKey("flour")); + env.templates.put("flour", env.food("flour", "Wheat", 3)); + try (MockedStatic delivery = mockStatic(InventoryAdder.class)) { + delivery.when(() -> InventoryAdder.addItem(eq(env.player), any())).thenAnswer(inv -> inv.getArgument(1)); + handler.onInteract(station.interact()); + } + assertEquals(1, env.dropped.size()); assertEquals("flour", env.resolve(env.dropped.get(0)).getId()); + assertTrue(station.variables.isEmpty()); + } + + @ParameterizedTest + @ValueSource(booleans = {true, false}) + void breakingRefundsTheLoadedWheatOrDropsTheCompletedFlour(boolean ground) { + if (ground) ready(); else loaded(); + station.variables.put("unrelated", "keep"); + handler.onBreak(new FurnitureBreakEvent(station.furniture, env.player)); + assertEquals(1, env.dropped.size()); + ItemStack dropped = env.dropped.get(0); FoodItem food = env.resolve(dropped); + assertEquals(ground ? "flour" : "wheat", food.getId()); + assertEquals(ground ? 1 : 8, dropped.getAmount()); + assertEquals("grain", food.getCategory()); assertEquals("Wheat", food.getOrigin()); + assertEquals(IngredientLineage.ofMain("Wheat"), food.getLineage()); + assertEquals(3, env.lastQuality); + assertEquals(Map.of("unrelated", "keep"), station.variables); + assertFalse(station.active.containsKey("flour")); + for (String slot : MillingStoneSlots.WHEAT) assertFalse(station.active.containsKey(slot)); + } + + @ParameterizedTest + @ValueSource(booleans = {true, false}) + void failedBreakRefundKeepsTheBatchAndCancelsFurnitureRemoval(boolean ground) { + if (ground) ready(); else loaded(); + Map saved = new LinkedHashMap<>(station.variables); + env.templates.remove(ground ? "flour" : "wheat"); + var event = new FurnitureBreakEvent(station.furniture, env.player); + handler.onBreak(event); + assertTrue(event.isCancelled(), "A failed refund must not silently destroy the paid ingredients"); + assertEquals(saved, station.variables); assertTrue(env.dropped.isEmpty()); + assertEquals(ground ? MillingStoneStage.READY : MillingStoneStage.LOADED, MillingStoneState.getStage(station.furniture)); + } + + @ParameterizedTest + @ValueSource(booleans = {true, false}) + void removingARecipeDuringReloadDoesNotDiscardAnExistingBatch(boolean ground) { + if (ground) ready(); else loaded(); + Map saved = new LinkedHashMap<>(station.variables); + MillingRecipeRegistry.load(Map.of()); + var event = new FurnitureBreakEvent(station.furniture, env.player); + handler.onBreak(event); + assertTrue(event.isCancelled()); assertEquals(saved, station.variables); assertTrue(env.dropped.isEmpty()); + } + + @ParameterizedTest + @ValueSource(booleans = {true, false}) + void nullOrAirRenderedFlourLeavesAReadyBatchAvailable(boolean air) { + ready(); + Map saved = new LinkedHashMap<>(station.variables); + ItemStack failed = air ? new ItemStack(Material.AIR) : null; + env.builder.when(() -> ItemBuilder.buildSingleWithQuality(any(FoodItem.class), anyInt())).thenReturn(failed); + handler.onInteract(station.interact()); + assertEquals(saved, station.variables); assertTrue(station.active.containsKey("flour")); + assertTrue(env.outputs().isEmpty()); assertTrue(env.dropped.isEmpty()); + } + + @ParameterizedTest + @CsvSource({"true,true", "true,false", "false,true", "false,false"}) + void nullOrAirRefundsNeverClearIngredientsDuringABreak(boolean ground, boolean air) { + if (ground) ready(); else loaded(); + Map saved = new LinkedHashMap<>(station.variables); + ItemStack failed = air ? new ItemStack(Material.AIR) : null; + env.builder.when(() -> ItemBuilder.buildSingleWithQuality(any(FoodItem.class), anyInt())).thenReturn(failed); + var event = new FurnitureBreakEvent(station.furniture, env.player); + handler.onBreak(event); + assertTrue(event.isCancelled()); assertEquals(saved, station.variables); assertTrue(env.dropped.isEmpty()); + } + + @Test + void inferredLegacyBatchesUseWheatLineageWhenNoSnapshotExists() { + station.place("flour", new ItemStack(Material.SUGAR)); + handler.onBreak(new FurnitureBreakEvent(station.furniture, env.player)); + assertEquals(IngredientLineage.ofMain("Wheat"), env.lastBuilt.getLineage()); + station.place("wheat_4", new ItemStack(Material.WHEAT)); + handler.onBreak(new FurnitureBreakEvent(station.furniture, env.player)); + assertEquals(IngredientLineage.ofMain("Wheat"), env.lastBuilt.getLineage()); + handler.onBreak(new FurnitureBreakEvent(station.furniture, env.player)); + assertEquals(2, env.dropped.size()); + } + + @Test + void animationStopsOnDisplayRemovalAndMissingTopDisplaysCompleteSynchronously() { + assertFalse(MillingStoneAnimation.isAnimating(null)); + Furniture noId = mock(Furniture.class); + assertFalse(MillingStoneAnimation.isAnimating(noId)); + MillingStoneAnimation.clearAnimating(null); MillingStoneAnimation.clearAnimating(noId); + AtomicInteger callbacks = new AtomicInteger(); + MillingStoneAnimation.playMill(station.furniture, 1, 8, callbacks::incrementAndGet); + assertEquals(1, callbacks.get()); + MillingStoneAnimation.playMill(station.furniture, 1, 8, null); + PlacedSlot top = station.active.get("top"); + when(top.getDisplayStandId()).thenReturn(java.util.UUID.randomUUID()); + MillingStoneAnimation.playMill(station.furniture, 1, 8, callbacks::incrementAndGet); + assertEquals(2, callbacks.get()); + ItemDisplayMock display = station.display("top"); + MillingStoneAnimation.playMill(station.furniture, 1, 8, callbacks::incrementAndGet); + assertTrue(MillingStoneAnimation.isAnimating(station.furniture)); + env.server.getScheduler().performOneTick(); display.remove(); + env.server.getScheduler().performTicks(10); + assertEquals(2, callbacks.get()); assertFalse(MillingStoneAnimation.isAnimating(station.furniture)); + station.active.clear(); station.definitions.clear(); + MillingStoneAnimation.playMill(station.furniture, 1, 8, callbacks::incrementAndGet); + assertEquals(3, callbacks.get()); + } +} diff --git a/src/test/java/net/tfminecraft/cooking/mixing/MixingBowlCoverageTest.java b/src/test/java/net/tfminecraft/cooking/mixing/MixingBowlCoverageTest.java new file mode 100644 index 0000000..c48e47a --- /dev/null +++ b/src/test/java/net/tfminecraft/cooking/mixing/MixingBowlCoverageTest.java @@ -0,0 +1,265 @@ +package net.tfminecraft.cooking.mixing; + +import static org.junit.jupiter.api.Assertions.*; +import static org.mockito.ArgumentMatchers.*; +import static org.mockito.Mockito.*; + +import java.util.*; +import net.tfminecraft.cooking.cache.ItemCache; +import net.tfminecraft.cooking.cache.NamingConfig; +import net.tfminecraft.cooking.churn.ButterChurnCoverageTest.KitchenFixture; +import net.tfminecraft.cooking.churn.ButterChurnCoverageTest.Station; +import net.tfminecraft.cooking.item.FoodItem; +import net.tfminecraft.cooking.item.IngredientLineage; +import net.tfminecraft.cooking.utils.IngredientConverter; +import net.tfminecraft.interactiblefurniture.events.FurnitureBreakEvent; +import net.tfminecraft.interactiblefurniture.furniture.Furniture; +import net.tfminecraft.interactiblefurniture.furniture.PlacedSlot; +import org.bukkit.Material; +import org.bukkit.inventory.ItemStack; +import org.bukkit.util.Transformation; +import org.joml.Quaternionf; +import org.joml.Vector3f; +import org.junit.jupiter.api.*; +import org.mockbukkit.mockbukkit.entity.ItemDisplayMock; + +class MixingBowlCoverageTest { + private KitchenFixture e; + private Station s; + private MixingBowlHandler handler; + + @BeforeEach void setUp() { + e = new KitchenFixture(); s = e.station("bowl"); handler = new MixingBowlHandler(); + for (String slot : List.of("flour", "water", "yeast", "dough")) s.define(slot); + var naming = e.scoped(NamingConfig.class); + naming.when(() -> NamingConfig.isCategory(anyString(), any())).thenAnswer(call -> { + String bucket = call.getArgument(0), category = call.getArgument(1); + return "sweetener".equals(bucket) ? "sweetener".equals(category) : "dough-filler".equals(bucket) && "fruit".equals(category); + }); + naming.when(NamingConfig::getDoughFillerMax).thenReturn(2); + } + + @AfterEach void tearDown() { e.close(); } + + @Test void emptyPersistedStateHasSafeDefaultsAndStagesFollowVisibleLayers() { + assertEquals(MixingBowlStage.EMPTY, MixingBowlState.getStage(s.f)); + assertEquals(0, MixingBowlState.getMixCount(s.f)); + assertNull(MixingBowlState.getFlourQuality(s.f)); assertNull(MixingBowlState.getYeastQuality(s.f)); + assertNull(MixingBowlState.getSugarQuality(s.f)); assertFalse(MixingBowlState.hasSugar(s.f)); + assertNull(MixingBowlState.getFlourFreshness(s.f)); assertNull(MixingBowlState.getYeastFreshness(s.f)); assertNull(MixingBowlState.getSugarFreshness(s.f)); + assertTrue(MixingBowlState.getFruitOrigins(s.f).isEmpty()); assertTrue(MixingBowlState.getFruitQualities(s.f).isEmpty()); + assertTrue(MixingBowlState.getFruitFreshness(s.f).isEmpty()); assertTrue(MixingBowlState.getFruitLineages(s.f).isEmpty()); + assertEquals("Wheat", MixingBowlState.getFlourOrigin(s.f)); assertEquals("Yeast", MixingBowlState.getYeastOrigin(s.f)); assertEquals("Sugar", MixingBowlState.getSugarOrigin(s.f)); + assertTrue(MixingBowlState.getFlourLineage(s.f).isEmpty()); assertTrue(MixingBowlState.getYeastLineage(s.f).isEmpty()); assertTrue(MixingBowlState.getSugarLineage(s.f).isEmpty()); + s.variables.put(MixingBowlState.VAR_STAGE, "removed-stage-from-old-config"); + for (var entry : Map.of("flour", MixingBowlStage.HAS_FLOUR, "water", MixingBowlStage.HAS_WATER, "yeast", MixingBowlStage.HAS_YEAST, "dough", MixingBowlStage.DOUGH_READY).entrySet()) { + s.active.clear(); s.place(entry.getKey(), new ItemStack(Material.STONE)); + assertEquals(entry.getValue(), MixingBowlState.getStage(s.f)); + } + assertEquals("flour", MixingBowlStage.EMPTY.nextIngredientSlot()); + assertEquals("water", MixingBowlStage.HAS_FLOUR.nextIngredientSlot()); + assertEquals("yeast", MixingBowlStage.HAS_WATER.nextIngredientSlot()); + assertNull(MixingBowlStage.HAS_YEAST.nextIngredientSlot()); assertNull(MixingBowlStage.DOUGH_READY.nextIngredientSlot()); + assertEquals(MixingBowlStage.HAS_FLOUR, MixingBowlStage.EMPTY.advance()); + assertEquals(MixingBowlStage.HAS_WATER, MixingBowlStage.HAS_FLOUR.advance()); + assertEquals(MixingBowlStage.HAS_YEAST, MixingBowlStage.HAS_WATER.advance()); + assertEquals(MixingBowlStage.HAS_YEAST, MixingBowlStage.HAS_YEAST.advance()); + assertEquals(MixingBowlStage.DOUGH_READY, MixingBowlStage.DOUGH_READY.advance()); + } + + @Test void snapshotsRoundTripEveryIngredientAndClearOnlyBowlVariables() { + s.variables.put("unrelated", "keep"); + IngredientLineage flour = IngredientLineage.ofMain("Rye"), yeast = IngredientLineage.ofMain("Starter"), sugar = IngredientLineage.ofExtra("Honey"), apple = IngredientLineage.ofExtra("Apple"); + MixingBowlState.setStage(s.f, MixingBowlStage.HAS_YEAST); MixingBowlState.setMixCount(s.f, 2); + MixingBowlState.setFlourQuality(s.f, 4); MixingBowlState.setFlourFreshness(s.f, 9); MixingBowlState.setFlourOrigin(s.f, "Rye"); MixingBowlState.setFlourLineage(s.f, flour); + MixingBowlState.setYeastQuality(s.f, 3); MixingBowlState.setYeastFreshness(s.f, 8); MixingBowlState.setYeastOrigin(s.f, "Starter"); MixingBowlState.setYeastLineage(s.f, yeast); + MixingBowlState.setHasSugar(s.f, true); MixingBowlState.setSugarQuality(s.f, 2); MixingBowlState.setSugarFreshness(s.f, 7); MixingBowlState.setSugarOrigin(s.f, "Honey"); MixingBowlState.setSugarLineage(s.f, sugar); + MixingBowlState.addFruit(s.f, "Apple", 4, 6, apple); MixingBowlState.addFruit(s.f, "Pear", 3, 5, IngredientLineage.empty()); + assertEquals(MixingBowlStage.HAS_YEAST, MixingBowlState.getStage(s.f)); assertEquals(2, MixingBowlState.getMixCount(s.f)); + assertEquals(4, MixingBowlState.getFlourQuality(s.f)); assertEquals(9, MixingBowlState.getFlourFreshness(s.f)); assertEquals("Rye", MixingBowlState.getFlourOrigin(s.f)); assertEquals(flour, MixingBowlState.getFlourLineage(s.f)); + assertEquals(3, MixingBowlState.getYeastQuality(s.f)); assertEquals(8, MixingBowlState.getYeastFreshness(s.f)); assertEquals("Starter", MixingBowlState.getYeastOrigin(s.f)); assertEquals(yeast, MixingBowlState.getYeastLineage(s.f)); + assertTrue(MixingBowlState.hasSugar(s.f)); assertEquals(2, MixingBowlState.getSugarQuality(s.f)); assertEquals(7, MixingBowlState.getSugarFreshness(s.f)); assertEquals("Honey", MixingBowlState.getSugarOrigin(s.f)); assertEquals(sugar, MixingBowlState.getSugarLineage(s.f)); + assertEquals(List.of("Apple", "Pear"), MixingBowlState.getFruitOrigins(s.f)); assertEquals(List.of(4, 3), MixingBowlState.getFruitQualities(s.f)); assertEquals(List.of(6, 5), MixingBowlState.getFruitFreshness(s.f)); assertEquals(List.of(apple, IngredientLineage.empty()), MixingBowlState.getFruitLineages(s.f)); + MixingBowlState.clear(s.f); assertEquals(Map.of("unrelated", "keep"), s.variables); + } + + @Test void malformedLegacyListsKeepReadableEntriesAndFallbackOrigins() { + s.variables.put(MixingBowlState.VAR_FRUIT_ORIGINS, " :Apple::Pear:"); + s.variables.put(MixingBowlState.VAR_FRUIT_QUALITIES, ":bad:3: :4:"); + s.variables.put(MixingBowlState.VAR_FRUIT_FRESHNESS, ":bad:9: :8:"); + assertEquals(List.of("Apple", "Pear"), MixingBowlState.getFruitOrigins(s.f)); + assertEquals(List.of(3, 4), MixingBowlState.getFruitQualities(s.f)); assertEquals(List.of(9, 8), MixingBowlState.getFruitFreshness(s.f)); + s.variables.put(MixingBowlState.VAR_FRUIT_ORIGINS, " "); s.variables.put(MixingBowlState.VAR_FRUIT_QUALITIES, " "); s.variables.put(MixingBowlState.VAR_FRUIT_FRESHNESS, " "); s.variables.put(MixingBowlState.VAR_FRUIT_LINEAGE, " "); + assertTrue(MixingBowlState.getFruitOrigins(s.f).isEmpty()); assertTrue(MixingBowlState.getFruitQualities(s.f).isEmpty()); assertTrue(MixingBowlState.getFruitFreshness(s.f).isEmpty()); assertTrue(MixingBowlState.getFruitLineages(s.f).isEmpty()); + MixingBowlState.setFlourOrigin(s.f, " "); assertEquals("Wheat", MixingBowlState.getFlourOrigin(s.f)); + MixingBowlState.setHasSugar(s.f, false); assertFalse(MixingBowlState.hasSugar(s.f)); + } + + @Test void wrongOrderAndUnpreparedIngredientsAreRejectedWithoutConsumption() { + handler.onInteract(e.station("other").interact()); + handler.onInteract(s.interact()); assertTrue(s.active.isEmpty()); + e.hold(new ItemStack(Material.STONE, 2)); var wrong = s.interact(); handler.onInteract(wrong); + assertTrue(wrong.isCancelled()); assertTrue(e.messages.contains("§cAdd flour first.")); + e.hold(new ItemStack(Material.WHEAT, 2)); var raw = s.interact(); handler.onInteract(raw); + assertTrue(raw.isCancelled()); assertEquals(2, e.player.getInventory().getItemInMainHand().getAmount()); assertTrue(e.messages.getLast().contains("prepare")); + addFlour(); e.hold(new ItemStack(Material.STONE)); handler.onInteract(s.interact()); assertTrue(e.messages.contains("§cAdd water next.")); + addWater(Material.WATER_BUCKET, 1); e.hold(new ItemStack(Material.STONE)); handler.onInteract(s.interact()); assertTrue(e.messages.contains("§cAdd yeast next.")); + e.hold(new ItemStack(Material.BROWN_MUSHROOM)); handler.onInteract(s.interact()); assertEquals(MixingBowlStage.HAS_WATER, MixingBowlState.getStage(s.f)); + } + + @Test void flourWaterAndYeastAdvanceInOrderWithQualityAndLineageSnapshots() { + FoodItem flour = e.food("flour", "grain", "Rye", 4, 9); flour.setLineage(IngredientLineage.ofMain("Rye")); + add(flour, Material.WHEAT); assertEquals(MixingBowlStage.HAS_FLOUR, MixingBowlState.getStage(s.f)); + assertEquals(4, MixingBowlState.getFlourQuality(s.f)); assertEquals(9, MixingBowlState.getFlourFreshness(s.f)); assertEquals("Rye", MixingBowlState.getFlourOrigin(s.f)); assertEquals(flour.getLineage(), MixingBowlState.getFlourLineage(s.f)); + addWater(Material.WATER_BUCKET, 1); assertEquals(MixingBowlStage.HAS_WATER, MixingBowlState.getStage(s.f)); + FoodItem yeast = e.food("yeast", "ingredient", "Starter", 3, 7); yeast.setLineage(IngredientLineage.ofMain("Starter")); + add(yeast, Material.BROWN_MUSHROOM); assertEquals(MixingBowlStage.HAS_YEAST, MixingBowlState.getStage(s.f)); + assertEquals(3, MixingBowlState.getYeastQuality(s.f)); assertEquals(7, MixingBowlState.getYeastFreshness(s.f)); assertEquals("Starter", MixingBowlState.getYeastOrigin(s.f)); assertEquals(yeast.getLineage(), MixingBowlState.getYeastLineage(s.f)); + assertEquals(Set.of("flour", "water", "yeast"), s.active.keySet()); verify(e.manager, times(3)).markDirty(s.f); + } + + @Test void waterCupsReturnTheirContainerToHandInventoryOrGroundWithoutDuplication() { + addFlour(); addWater(Material.HONEY_BOTTLE, 1); + assertEquals(Material.GLASS_BOTTLE, e.player.getInventory().getItemInMainHand().getType()); + resetBowl(); addFlour(); addWater(Material.HONEY_BOTTLE, 2); + assertEquals(1, e.player.getInventory().getItemInMainHand().getAmount()); assertEquals(1, e.player.getInventory().all(Material.GLASS_BOTTLE).size()); + resetBowl(); addFlour(); + for (int slot = 0; slot < e.player.getInventory().getSize(); slot++) e.player.getInventory().setItem(slot, new ItemStack(Material.STONE, 64)); + addWater(Material.HONEY_BOTTLE, 2); + verify(e.world).dropItemNaturally(eq(s.f.getLoc()), argThat(item -> item.getType() == Material.GLASS_BOTTLE && item.getAmount() == 1)); + } + + @Test void unavailableIngredientModelLeavesInventoryAndBowlUnchanged() { + e.cache.when(() -> ItemCache.getMixingModel("flour")).thenReturn(null); + e.hold(e.stack(e.food("flour", "grain", "Wheat", 3, 0), Material.WHEAT, 2)); + handler.onInteract(s.interact()); + assertEquals(2, e.player.getInventory().getItemInMainHand().getAmount(), "A missing configured model must not eat an ingredient"); + assertEquals(MixingBowlStage.EMPTY, MixingBowlState.getStage(s.f)); assertTrue(s.active.isEmpty()); assertTrue(s.variables.isEmpty()); + } + + @Test void aPersistedStageBehindItsVisibleLayerCannotConsumeTheSameIngredientTwice() { + // Preserve an already loaded layer when saved stage metadata lags the visible model. + MixingBowlState.setStage(s.f, MixingBowlStage.EMPTY); + PlacedSlot existing = s.place("flour", new ItemStack(Material.WHEAT)); + e.hold(e.stack(e.food("flour", "grain", "Wheat", 3, 0), Material.WHEAT, 2)); + handler.onInteract(s.interact()); + assertEquals(2, e.player.getInventory().getItemInMainHand().getAmount()); + assertSame(existing, s.active.get("flour")); verify(existing, never()).forceModel(any()); + } + + @Test void unavailableDoughModelPreservesIngredientsAndCanCompleteAfterConfigurationRepair() { + prepare(); MixingBowlState.setMixCount(s.f, ItemCache.mixingStirCount - 1); + e.cache.when(() -> ItemCache.getMixingModel("dough")).thenReturn(null); e.hold(null); + handler.onInteract(s.interact()); + assertEquals(MixingBowlStage.HAS_YEAST, MixingBowlState.getStage(s.f), "An invisible result must not discard its visible ingredients"); + assertEquals(Set.of("flour", "water", "yeast"), s.active.keySet()); + e.cache.when(() -> ItemCache.getMixingModel("dough")).thenReturn("model.dough"); + handler.onInteract(s.interact()); assertEquals(MixingBowlStage.DOUGH_READY, MixingBowlState.getStage(s.f)); assertEquals(Set.of("dough"), s.active.keySet()); + } + + @Test void mixinsConsumeOneConvertedItemAndRejectDuplicateSugarFruitAndOtherFoods() { + prepare(); + FoodItem sugar = e.food("sugar", "sweetener", null, 4, -1); sugar.setLineage(IngredientLineage.ofExtra("Sugar")); + ItemStack converted = e.stack(sugar, Material.SUGAR, 2); + e.converter.when(() -> IngredientConverter.convertIfNeeded(eq(e.player), any())).thenReturn(converted); + e.hold(new ItemStack(Material.SUGAR, 2)); handler.onInteract(s.interact()); + assertTrue(MixingBowlState.hasSugar(s.f)); assertEquals(4, MixingBowlState.getSugarQuality(s.f)); assertEquals(0, MixingBowlState.getSugarFreshness(s.f)); assertEquals("Sugar", MixingBowlState.getSugarOrigin(s.f)); assertEquals(sugar.getLineage(), MixingBowlState.getSugarLineage(s.f)); assertEquals(1, e.player.getInventory().getItemInMainHand().getAmount()); + e.converter.when(() -> IngredientConverter.convertIfNeeded(any(), any())).thenAnswer(call -> call.getArgument(1)); + handler.onInteract(s.interact()); assertEquals(1, e.player.getInventory().getItemInMainHand().getAmount()); assertTrue(e.messages.getLast().contains("already")); + FoodItem apple = e.food("apple", "fruit", "Apple", 3, 12); apple.setLineage(IngredientLineage.ofExtra("Apple")); + add(apple, Material.APPLE); assertEquals(List.of("Apple"), MixingBowlState.getFruitOrigins(s.f)); assertEquals(List.of(12), MixingBowlState.getFruitFreshness(s.f)); assertEquals(List.of(apple.getLineage()), MixingBowlState.getFruitLineages(s.f)); + add(apple, Material.APPLE); assertEquals(List.of("Apple"), MixingBowlState.getFruitOrigins(s.f)); assertTrue(e.messages.getLast().contains("already")); + add(e.food("pear", "fruit", "Pear", 2, 8), Material.APPLE); add(e.food("plum", "fruit", "Plum", 2, 8), Material.APPLE); + assertEquals(List.of("Apple", "Pear"), MixingBowlState.getFruitOrigins(s.f)); assertTrue(e.messages.getLast().contains("more fruit")); + add(e.food("carrot", "vegetable", "Carrot", 2, 0), Material.CARROT); assertTrue(e.messages.getLast().contains("doesn't go")); + e.hold(new ItemStack(Material.STONE)); handler.onInteract(s.interact()); assertTrue(e.messages.getLast().contains("doesn't go")); + } + + @Test void fruitWithoutOriginIsRejectedAndBlankIngredientOriginsUseDocumentedFallbacks() { + add(e.food("flour", "grain", " ", 3, -1), Material.WHEAT); addWater(Material.WATER_BUCKET, 1); add(e.food("yeast", "ingredient", null, 3, -1), Material.BROWN_MUSHROOM); + assertEquals("Wheat", MixingBowlState.getFlourOrigin(s.f)); assertEquals("Yeast", MixingBowlState.getYeastOrigin(s.f)); + for (String origin : Arrays.asList(null, " ")) { + add(e.food("fruit", "fruit", origin, 3, 0), Material.APPLE); + assertTrue(MixingBowlState.getFruitOrigins(s.f).isEmpty()); assertTrue(e.messages.getLast().contains("doesn't go")); + } + } + + @Test void stirringUsesAnimationAsCooldownThenReplacesLayersWithCollectableDough() { + prepare(); e.hold(null); ItemDisplayMock display = e.display(s.id); + Transformation initial = display.getTransformation(); + handler.onInteract(s.interact()); assertEquals(1, MixingBowlState.getMixCount(s.f)); assertTrue(MixingBowlAnimation.isAnimating(s.f)); + var repeat = s.interact(); handler.onInteract(repeat); assertTrue(repeat.isCancelled()); assertEquals(1, MixingBowlState.getMixCount(s.f)); + e.server.getScheduler().performTicks(6); assertFalse(MixingBowlAnimation.isAnimating(s.f)); assertEquals(initial, display.getTransformation()); + handler.onInteract(s.interact()); e.server.getScheduler().performTicks(6); handler.onInteract(s.interact()); + assertEquals(MixingBowlStage.DOUGH_READY, MixingBowlState.getStage(s.f)); assertEquals(Set.of("dough"), s.active.keySet()); + e.hold(new ItemStack(Material.STONE)); var finished = s.interact(); handler.onInteract(finished); assertTrue(finished.isCancelled()); + var take = s.take(new ItemStack(Material.STONE), "dough"); handler.onTake(take); + assertFalse(take.isCancelled()); assertEquals(Material.PAPER, take.getItem().getType()); assertTrue(s.variables.isEmpty()); assertFalse(MixingBowlAnimation.isAnimating(s.f)); assertEquals("Wheat", e.lastBuilt.getOrigin()); + } + + @Test void takingAndBreakingOnlyReleaseCompletedDoughAndHandleMissingTemplates() { + handler.onTake(e.station("other").take(null, "dough")); handler.onTake(s.take(null, "flour")); + var unready = s.take(null, "dough"); handler.onTake(unready); assertTrue(unready.isCancelled()); assertTrue(e.messages.getLast().contains("Knead")); + MixingBowlState.setStage(s.f, MixingBowlStage.DOUGH_READY); e.templates.remove("dough"); + var missing = s.take(null, "dough"); handler.onTake(missing); assertTrue(missing.isCancelled()); assertEquals(MixingBowlStage.DOUGH_READY, MixingBowlState.getStage(s.f)); + handler.onBreak(new FurnitureBreakEvent(s.f, e.player)); assertTrue(s.variables.isEmpty()); verify(e.world, never()).dropItemNaturally(any(), any()); + e.templates.put("dough", e.food("dough", "grain", "Wheat", 3, 0)); MixingBowlState.setStage(s.f, MixingBowlStage.DOUGH_READY); s.place("dough", new ItemStack(Material.STONE)); + handler.onBreak(new FurnitureBreakEvent(s.f, e.player)); verify(e.world).dropItemNaturally(eq(s.f.getLoc()), argThat(item -> item.getType() == Material.PAPER)); assertFalse(s.active.containsKey("dough")); assertTrue(s.variables.isEmpty()); + handler.onBreak(new FurnitureBreakEvent(s.f, e.player)); handler.onBreak(new FurnitureBreakEvent(e.station("other").f, e.player)); + } + + @Test void collectedDoughCarriesAllIngredientSnapshotsAndSweetFruitTags() { + prepare(); add(e.food("sugar", "sweetener", "Honey", 2, 6), Material.SUGAR); + FoodItem apple = e.food("apple", "fruit", "Apple", 4, 5); apple.setLineage(IngredientLineage.ofExtra("Apple")); add(apple, Material.APPLE); + add(e.food("pear", "fruit", "Pear", 3, 4), Material.APPLE); + MixingBowlState.setFlourLineage(s.f, IngredientLineage.ofMain("Wheat")); MixingBowlState.setYeastLineage(s.f, IngredientLineage.ofMain("Starter")); MixingBowlState.setSugarLineage(s.f, IngredientLineage.ofExtra("Honey")); + MixingBowlState.setStage(s.f, MixingBowlStage.DOUGH_READY); + var take = s.take(null, "dough"); handler.onTake(take); assertFalse(take.isCancelled()); + assertEquals(List.of("grain", "ingredient", "sweetener", "fruit", "fruit"), e.composedInputs.stream().map(FoodItem::getCategory).toList()); + assertEquals(List.of("Wheat", "Yeast", "Honey", "Apple", "Pear"), e.composedInputs.stream().map(FoodItem::getOrigin).toList()); + assertEquals(IngredientLineage.ofMain("Wheat"), e.composedInputs.getFirst().getLineage()); assertTrue(e.lastBuilt.hasTagTrack("sweet")); assertTrue(e.lastBuilt.hasTagTrack("richly_fruity")); + assertEquals(apple.getLineage(), e.composedInputs.get(3).getLineage()); + assertEquals(List.of("Apple", "Pear"), e.lastBuilt.getIngredients()); + } + + @Test void olderSavedDoughWithoutOptionalSnapshotFieldsRemainsCollectable() { + // Furniture persisted before origin/freshness/lineage fields were added can have only qualities. + MixingBowlState.setStage(s.f, MixingBowlStage.DOUGH_READY); + MixingBowlState.setFlourQuality(s.f, 3); MixingBowlState.setYeastQuality(s.f, 2); MixingBowlState.setHasSugar(s.f, true); + s.variables.put(MixingBowlState.VAR_FRUIT_QUALITIES, "4:3"); s.variables.put(MixingBowlState.VAR_FRUIT_ORIGINS, "Apple"); s.variables.put(MixingBowlState.VAR_FRUIT_FRESHNESS, "-1"); + var take = s.take(null, "dough"); handler.onTake(take); assertFalse(take.isCancelled()); assertEquals(4, e.composedInputs.size()); assertTrue(e.lastBuilt.hasTagTrack("sweet")); assertTrue(e.lastBuilt.hasTagTrack("fruity")); + } + + @Test void displayCreationValidatesConfigurationAndClearingPreservesOtherLayers() { + e.cache.when(() -> ItemCache.getMixingModel("flour")).thenReturn(null); assertFalse(MixingBowlDisplay.showLayer(s.f, "flour")); + e.cache.when(() -> ItemCache.getMixingModel("flour")).thenReturn("model.flour"); + when(s.f.getType()).thenReturn(null); assertFalse(MixingBowlDisplay.showLayer(s.f, "flour")); MixingBowlDisplay.clearLayer(s.f, "flour"); + when(s.f.getType()).thenReturn(s.type); s.definitions.remove("flour"); assertFalse(MixingBowlDisplay.showLayer(s.f, "flour")); MixingBowlDisplay.clearLayer(s.f, "flour"); + s.define("flour"); when(e.itemApi.getCreator().getItemFromPath("model.flour")).thenReturn(null); assertFalse(MixingBowlDisplay.showLayer(s.f, "flour")); + when(e.itemApi.getCreator().getItemFromPath("model.flour")).thenReturn(new ItemStack(Material.WHEAT)); assertTrue(MixingBowlDisplay.showLayer(s.f, "flour")); + s.place("water", new ItemStack(Material.WATER_BUCKET)); MixingBowlDisplay.clearLayer(s.f, "flour"); assertEquals(Set.of("water"), s.active.keySet()); + } + + @Test void animationHandlesMissingDeadAndOffsetDisplaysAndFollowsActiveIngredients() { + assertFalse(MixingBowlAnimation.isAnimating(null)); MixingBowlAnimation.clearAnimating(null); + Furniture missingId = mock(Furniture.class); assertFalse(MixingBowlAnimation.isAnimating(missingId)); MixingBowlAnimation.clearAnimating(missingId); + MixingBowlAnimation.playStir(s.f); assertFalse(MixingBowlAnimation.isAnimating(s.f)); + ItemDisplayMock display = e.display(s.id); ItemCache.mixingStirPivotY = 0.3f; + Transformation original = new Transformation(new Vector3f(1, 2, 3), new Quaternionf(), new Vector3f(1), new Quaternionf()); display.setTransformation(original); + PlacedSlot flour = s.place("flour", new ItemStack(Material.WHEAT)); MixingBowlAnimation.playStir(s.f); e.server.getScheduler().performOneTick(); + assertTrue(MixingBowlAnimation.isAnimating(s.f)); verify(flour).followParentTransform(display); + e.server.getScheduler().performTicks(5); assertFalse(MixingBowlAnimation.isAnimating(s.f)); + Transformation restored = display.getTransformation(); + assertTrue(original.getTranslation().equals(restored.getTranslation(), 0.00001f)); + assertTrue(original.getScale().equals(restored.getScale(), 0.00001f)); + assertEquals(1f, Math.abs(original.getLeftRotation().dot(restored.getLeftRotation())), 0.00001f); + assertEquals(1f, Math.abs(original.getRightRotation().dot(restored.getRightRotation())), 0.00001f); + MixingBowlAnimation.playStir(s.f); display.remove(); e.server.getScheduler().performOneTick(); assertFalse(MixingBowlAnimation.isAnimating(s.f)); + } + + private void addFlour() { add(e.food("flour", "grain", "Wheat", 3, 9), Material.WHEAT); } + private void addWater(Material material, int amount) { e.hold(new ItemStack(material, amount)); handler.onInteract(s.interact()); } + private void add(FoodItem food, Material material) { e.hold(e.stack(food, material, 2)); handler.onInteract(s.interact()); } + private void prepare() { addFlour(); addWater(Material.WATER_BUCKET, 1); add(e.food("yeast", "ingredient", "Yeast", 3, 8), Material.BROWN_MUSHROOM); } + private void resetBowl() { s.active.clear(); MixingBowlState.clear(s.f); e.player.getInventory().clear(); } +} diff --git a/src/test/java/net/tfminecraft/cooking/nutrition/NutritionListenersCoverageTest.java b/src/test/java/net/tfminecraft/cooking/nutrition/NutritionListenersCoverageTest.java new file mode 100644 index 0000000..2caf86c --- /dev/null +++ b/src/test/java/net/tfminecraft/cooking/nutrition/NutritionListenersCoverageTest.java @@ -0,0 +1,319 @@ +package net.tfminecraft.cooking.nutrition; + +import static org.junit.jupiter.api.Assertions.*; +import static org.mockito.Mockito.*; + +import java.util.ArrayList; +import java.util.HashMap; +import java.util.List; +import java.util.Map; +import java.util.logging.Logger; +import net.bytebuddy.ByteBuddy; +import net.bytebuddy.asm.MemberRemoval; +import net.bytebuddy.dynamic.ClassFileLocator; +import net.bytebuddy.dynamic.loading.ByteArrayClassLoader; +import net.bytebuddy.dynamic.loading.PackageDefinitionStrategy; +import static net.bytebuddy.matcher.ElementMatchers.named; +import net.tfminecraft.cooking.Cooking; +import net.tfminecraft.cooking.cache.FurnitureCache; +import net.tfminecraft.cooking.cache.ItemCache; +import net.tfminecraft.cooking.cup.DrinkConsumeListener; +import net.tfminecraft.cooking.item.FoodItem; +import net.tfminecraft.cooking.item.tag.TagTrack; +import net.tfminecraft.cooking.loader.FoodLoader; +import net.tfminecraft.cooking.loader.TrackLoader; +import net.tfminecraft.cooking.manager.PlateManager; +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.objects.api.ItemAPI; +import org.bukkit.GameMode; +import org.bukkit.Material; +import org.bukkit.configuration.file.YamlConfiguration; +import org.bukkit.entity.Entity; +import org.bukkit.entity.HumanEntity; +import org.bukkit.event.entity.EntityRegainHealthEvent; +import org.bukkit.event.entity.EntityRegainHealthEvent.RegainReason; +import org.bukkit.event.entity.FoodLevelChangeEvent; +import org.bukkit.event.entity.PlayerDeathEvent; +import org.bukkit.event.player.PlayerItemConsumeEvent; +import org.bukkit.event.player.PlayerJoinEvent; +import org.bukkit.event.player.PlayerRespawnEvent; +import org.bukkit.inventory.EquipmentSlot; +import org.bukkit.inventory.ItemStack; +import org.bukkit.persistence.PersistentDataType; +import org.bukkit.plugin.Plugin; +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; +import org.mockito.MockedStatic; + +class NutritionListenersCoverageTest { + private ServerMock server; + private PlayerMock player; + private RPCharacter character; + private PlayerManager players; + private Cooking previousPlugin; + private MockedStatic rp; + private MockedStatic tlibs; + private ItemAPI api; + private List foods; + private List tracks; + private List battles; + private String previousWater, previousMilk, previousEmpty, previousBowl; + + @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); + players=mock(PlayerManager.class); rp=mockStatic(RPCharacters.class); + rp.when(()->RPCharacters.getActiveCharacter(player)).thenReturn(character); + rp.when(RPCharacters::getPlayerManager).thenReturn(players); + tlibs=mockStatic(TLibs.class); api=mock(ItemAPI.class,RETURNS_DEEP_STUBS); + tlibs.when(TLibs::getItemAPI).thenReturn(api); + foods=new ArrayList<>(FoodLoader.oList); tracks=new ArrayList<>(TrackLoader.oList); + battles=new ArrayList<>(BattleManager.get()); BattleManager.get().clear(); + FoodLoader.oList.clear(); TrackLoader.oList.clear(); + previousWater=ItemCache.cupOfWater; previousMilk=ItemCache.cupOfMilk; previousEmpty=ItemCache.emptyCup; + previousBowl=FurnitureCache.bowl; + ItemCache.cupOfWater="cup.water"; ItemCache.cupOfMilk="cup.milk"; ItemCache.emptyCup="cup.empty"; + FurnitureCache.bowl="bowl"; + when(api.getCreator().getItemFromPath("cup.empty")).thenReturn(new ItemStack(Material.BOWL)); + NutritionConfig.load(new YamlConfiguration()); NutritionLog.configure(false,false,null); + } + + @AfterEach void cleanup() { + SaturationGuard.stop(); NutritionDrainTask.stop(); + FoodLoader.oList.clear(); FoodLoader.oList.addAll(foods); TrackLoader.oList.clear(); TrackLoader.oList.addAll(tracks); + 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; + } + + @Test void lifecycleEventsRestoreHudAndResetDeathFoodOutsideStartedBattles() { + MockBukkit.createMockPlugin("RPCharacters"); + NutritionLifecycleListener listener=new NutritionLifecycleListener(); + listener.onJoin(new PlayerJoinEvent(player,"joined")); + assertEquals(20,player.getFoodLevel()); server.getScheduler().performTicks(2); + 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)); + 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); + assertEquals(50,character.getFoodValue()); + rp.when(()->RPCharacters.getActiveCharacter(player)).thenReturn(character); + listener.onPlayerDeath(death); assertEquals(80,character.getFoodValue()); + MockBukkit.createMockPlugin("SimpleFactions"); + Battle battle=mock(Battle.class); when(battle.hasStarted()).thenReturn(true); + when(battle.getSideByMemberId(player.getUniqueId())).thenReturn(mock(BattleSide.class)); + BattleManager.addBattle(battle); + character.setFoodValue(15); listener.onPlayerDeath(death); assertEquals(15,character.getFoodValue()); + } + + @Test void battleLookupRequiresAnEnabledStartedBattleAndToleratesUnavailableIntegrations() { + 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)); + 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()); + assertFalse(BattleFoodGate.inStartedBattle(player)); + doThrow(new IllegalStateException("offline")).when(battle).getSideByMemberId(player.getUniqueId()); + assertFalse(BattleFoodGate.inStartedBattle(player)); + server.getPluginManager().disablePlugin(factions); assertFalse(BattleFoodGate.inStartedBattle(player)); + } + + @Test void saturationGuardsHandleJoinEventsAndOnePeriodicTask() { + SaturationGuard guard=new SaturationGuard(); + player.setSaturation(5); guard.onJoin(new PlayerJoinEvent(player,"joined")); + assertEquals(0,player.getSaturation()); + guard.onFoodLevelChange(new FoodLevelChangeEvent(mock(HumanEntity.class),4)); + player.setSaturation(4); guard.onFoodLevelChange(new FoodLevelChangeEvent(player,4)); + assertEquals(4,player.getSaturation()); server.getScheduler().performOneTick(); assertEquals(0,player.getSaturation()); + SaturationGuard.stop(); SaturationGuard.start(); SaturationGuard.start(); + player.setSaturation(5); server.getScheduler().performTicks(40); assertEquals(0,player.getSaturation()); + MockBukkit.createMockPlugin("RPCharacters"); + player.setSaturation(6); server.getScheduler().performTicks(40); assertEquals(0,player.getSaturation()); + server.getScheduler().performTicks(40); + SaturationGuard.stop(); SaturationGuard.stop(); + player.setSaturation(5); server.getScheduler().performTicks(40); assertEquals(5,player.getSaturation()); + } + + @Test void foodLevelGuardDefersVanillaChangesButAllowsItsOwnSyncEvents() { + FoodLevelChangeGuard guard=new FoodLevelChangeGuard(); + FoodLevelChangeEvent nonPlayer=new FoodLevelChangeEvent(mock(HumanEntity.class),1); + guard.onFoodLevelChange(nonPlayer); assertFalse(nonPlayer.isCancelled()); + FoodLevelChangeEvent unavailable=new FoodLevelChangeEvent(player,1); + guard.onFoodLevelChange(unavailable); assertTrue(unavailable.isCancelled()); server.getScheduler().performOneTick(); + MockBukkit.createMockPlugin("RPCharacters"); + FoodLevelChangeEvent event=new FoodLevelChangeEvent(player,1); + guard.onFoodLevelChange(event); assertTrue(event.isCancelled()); + server.getScheduler().performOneTick(); assertEquals(10,player.getFoodLevel()); + PlayerMock duringSync=spy(player); + doAnswer(call->{ + FoodLevelChangeEvent nested=new FoodLevelChangeEvent(duringSync,call.getArgument(0)); + guard.onFoodLevelChange(nested); assertFalse(nested.isCancelled()); return null; + }).when(duringSync).setFoodLevel(anyInt()); + character.setFoodValue(40); NutritionDisplayService.sync(duringSync,character); + assertFalse(NutritionDisplayService.isSyncing(duringSync)); + } + + @Test void naturalFoodRegenerationIsBlockedAndOtherHealingIsPreserved() { + RegenBlocker listener=new RegenBlocker(); + EntityRegainHealthEvent other=new EntityRegainHealthEvent(mock(Entity.class),2,RegainReason.SATIATED); + listener.onRegainHealth(other); assertFalse(other.isCancelled()); + EntityRegainHealthEvent satiated=new EntityRegainHealthEvent(player,2,RegainReason.SATIATED); + listener.onRegainHealth(satiated); assertTrue(satiated.isCancelled()); + EntityRegainHealthEvent magic=new EntityRegainHealthEvent(player,2,RegainReason.MAGIC); + listener.onRegainHealth(magic); assertFalse(magic.isCancelled()); + EntityRegainHealthEvent fast=new EntityRegainHealthEvent(player,2,RegainReason.MAGIC,true); + listener.onRegainHealth(fast); assertTrue(fast.isCancelled()); + EntityRegainHealthEvent brokenBridge=mock(EntityRegainHealthEvent.class); + when(brokenBridge.getEntity()).thenReturn(player); when(brokenBridge.getRegainReason()).thenReturn(RegainReason.MAGIC); + when(brokenBridge.isFastRegen()).thenThrow(new IllegalStateException("bridge failed")); + assertDoesNotThrow(()->listener.onRegainHealth(brokenBridge)); + verify(brokenBridge,never()).setCancelled(true); + } + + @Test void regenerationCompatibilityWorksWhenTheServerApiHasNoFastRegenMethod() throws Exception { + // Exercise the older API contract in its own class loader, without changing live static fields. + byte[] legacyEvent=new ByteBuddy().redefine(EntityRegainHealthEvent.class) + .visit(new MemberRemoval().stripMethods(named("isFastRegen"))).make().getBytes(); + Map classes=Map.of(EntityRegainHealthEvent.class.getName(),legacyEvent, + RegenBlocker.class.getName(),ClassFileLocator.ForClassLoader.read(RegenBlocker.class)); + ClassLoader loader=new ByteArrayClassLoader.ChildFirst(getClass().getClassLoader(),classes, + RegenBlocker.class.getProtectionDomain(),ByteArrayClassLoader.PersistenceHandler.LATENT, + PackageDefinitionStrategy.Trivial.INSTANCE); + Class eventType=loader.loadClass(EntityRegainHealthEvent.class.getName()); + Class listenerType=loader.loadClass(RegenBlocker.class.getName()); + Object event=eventType.getConstructor(Entity.class,double.class,RegainReason.class).newInstance(player,2,RegainReason.MAGIC); + listenerType.getMethod("onRegainHealth",eventType).invoke(listenerType.getConstructor().newInstance(),event); + assertEquals(false,eventType.getMethod("isCancelled").invoke(event)); + } + + @Test void foodConsumptionRejectsRawFoodAndLeavesDedicatedDrinkHandlingAlone() { + MockBukkit.createMockPlugin("RPCharacters"); + FoodConsumeListener listener=new FoodConsumeListener(); + PlayerItemConsumeEvent absent=mock(PlayerItemConsumeEvent.class); listener.onConsume(absent); + ItemStack water=new ItemStack(Material.POTION); + when(api.getChecker().checkItemWithPath(water,"cup.water")).thenReturn(true); + for(ItemStack stack:List.of(water,new ItemStack(Material.MILK_BUCKET),new ItemStack(Material.APPLE),stack("cup_of_milk",true,"drink",Material.POTION))) { + PlayerItemConsumeEvent event=consume(stack,EquipmentSlot.HAND); listener.onConsume(event); assertFalse(event.isCancelled()); + } + 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()); + } + + @Test void bowlEatingRequiresAnEmptyHandAndConsumesOnlyEdibleSoup() { + MockBukkit.createMockPlugin("RPCharacters"); + Furniture bowl=mock(Furniture.class); when(bowl.getId()).thenReturn("bowl"); when(bowl.getLoc()).thenReturn(player.getLocation()); + 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); + FurnitureInteractEvent event=mock(FurnitureInteractEvent.class); when(event.getFurniture()).thenReturn(bowl); when(event.getPlayer()).thenReturn(player); + when(bowl.getId()).thenReturn("other"); listener.onInteract(event); when(bowl.getId()).thenReturn("bowl"); + player.setSneaking(true); listener.onInteract(event); player.setSneaking(false); + player.getInventory().setItemInMainHand(new ItemStack(Material.STICK)); listener.onInteract(event); + player.getInventory().setItemInMainHand(new ItemStack(Material.AIR)); listener.onInteract(event); + slots.put("food_item",null); listener.onInteract(event); + PlacedSlot slot=mock(PlacedSlot.class); slots.put("food_item",slot); listener.onInteract(event); + when(slot.getCurrentItem()).thenReturn(new ItemStack(Material.APPLE)); listener.onInteract(event); + when(slot.getCurrentItem()).thenReturn(stack("grain",true,"grain",Material.BREAD)); listener.onInteract(event); + verifyNoInteractions(plates); assertEquals(100,character.getFoodValue()); + try(MockedStatic furniture=mockStatic(InteractibleFurniture.class)) { + InteractibleFurniture plugin=mock(InteractibleFurniture.class,RETURNS_DEEP_STUBS); + furniture.when(InteractibleFurniture::getInstance).thenReturn(plugin); + when(slot.getCurrentItem()).thenReturn(stack("soup",true,"soup",Material.MUSHROOM_STEW)); + listener.onInteract(event); verify(event).setCancelled(true); verify(plates).clear(bowl); + verify(plugin.getFurnitureManager()).markDirty(bowl); assertEquals(110,character.getFoodValue()); + clearInvocations(plates,plugin.getFurnitureManager()); + when(slot.getCurrentItem()).thenReturn(stack("raw_soup",false,"soup",Material.MUSHROOM_STEW)); + listener.onInteract(event); + verifyNoInteractions(plates,plugin.getFurnitureManager()); assertEquals(110,character.getFoodValue()); + } + } + + @Test void milkBucketsUseTheActualHandAndReturnExactlyOneBucketOutsideCreative() { + MockBukkit.createMockPlugin("RPCharacters"); DrinkConsumeListener listener=new DrinkConsumeListener(); + PlayerItemConsumeEvent absent=mock(PlayerItemConsumeEvent.class); listener.onMilkBucket(absent); + listener.onMilkBucket(consume(new ItemStack(Material.APPLE),EquipmentSlot.HAND)); + ItemStack milk=stack("milk_bucket",true,"drink",Material.MILK_BUCKET); + player.getInventory().setItemInMainHand(milk); + PlayerItemConsumeEvent event=consume(milk,EquipmentSlot.HAND); listener.onMilkBucket(event); + assertTrue(event.isCancelled()); assertEquals(110,character.getFoodValue()); + assertEquals(Material.BUCKET,player.getInventory().getItemInMainHand().getType()); + player.setGameMode(GameMode.CREATIVE); player.getInventory().setItemInOffHand(new ItemStack(Material.MILK_BUCKET)); + listener.onMilkBucket(consume(new ItemStack(Material.MILK_BUCKET),EquipmentSlot.OFF_HAND)); + assertEquals(Material.MILK_BUCKET,player.getInventory().getItemInOffHand().getType()); player.setGameMode(GameMode.SURVIVAL); + listener.onMilkBucket(consume(new ItemStack(Material.MILK_BUCKET),EquipmentSlot.HAND)); + assertEquals(Material.BUCKET,player.getInventory().getItemInOffHand().getType()); + player.getInventory().setItemInMainHand(new ItemStack(Material.MILK_BUCKET,2)); + listener.onMilkBucket(consume(new ItemStack(Material.MILK_BUCKET),EquipmentSlot.OFF_HAND)); + assertEquals(1,player.getInventory().getItemInMainHand().getAmount()); + player.getInventory().clear(); listener.onMilkBucket(consume(new ItemStack(Material.MILK_BUCKET),EquipmentSlot.HAND)); + assertEquals(0,player.getInventory().all(Material.BUCKET).size()); + for(int i=0;ie instanceof org.bukkit.entity.Item).count(); + listener.onMilkBucket(consume(new ItemStack(Material.MILK_BUCKET),EquipmentSlot.OFF_HAND)); + assertEquals(1,player.getInventory().getItemInOffHand().getAmount()); + assertEquals(before+1,player.getWorld().getEntities().stream().filter(e->e instanceof org.bukkit.entity.Item).count()); + } + + @Test void drinkReplacementUsesTheOriginalSlotAfterVanillaLeavesABottle() { + MockBukkit.createMockPlugin("RPCharacters"); DrinkConsumeListener listener=new DrinkConsumeListener(); + listener.onDrink(mock(PlayerItemConsumeEvent.class)); + listener.onDrink(consume(new ItemStack(Material.APPLE),EquipmentSlot.HAND)); + ItemStack water=new ItemStack(Material.POTION); + when(api.getChecker().checkItemWithPath(water,"cup.water")).thenReturn(true); + player.getInventory().setHeldItemSlot(3); listener.onDrink(consume(water,EquipmentSlot.HAND)); + 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)); + server.getScheduler().performOneTick(); assertEquals(Material.BOWL,player.getInventory().getItemInOffHand().getType()); + assertEquals(110,character.getFoodValue()); + ItemStack untrackedMilk=new ItemStack(Material.HONEY_BOTTLE); + when(api.getChecker().checkItemWithPath(untrackedMilk,"cup.milk")).thenReturn(true); + listener.onDrink(consume(untrackedMilk,EquipmentSlot.HAND)); server.getScheduler().performOneTick(); + water.setAmount(2); listener.onDrink(consume(water,EquipmentSlot.HAND)); + player.getInventory().setItemInMainHand(new ItemStack(Material.GLASS_BOTTLE)); + server.getScheduler().performOneTick(); assertEquals(Material.GLASS_BOTTLE,player.getInventory().getItemInMainHand().getType()); + } + + 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); + ItemStack stack=new ItemStack(material); var meta=stack.getItemMeta(); + meta.getPersistentDataContainer().set(Keys.FOOD_ID,PersistentDataType.STRING,id); + stack.setItemMeta(meta); return stack; + } + private PlayerItemConsumeEvent consume(ItemStack stack,EquipmentSlot hand) { + return new PlayerItemConsumeEvent(player,stack,hand); + } +} diff --git a/src/test/java/net/tfminecraft/cooking/nutrition/NutritionRuntimeCoverageTest.java b/src/test/java/net/tfminecraft/cooking/nutrition/NutritionRuntimeCoverageTest.java new file mode 100644 index 0000000..0e173a0 --- /dev/null +++ b/src/test/java/net/tfminecraft/cooking/nutrition/NutritionRuntimeCoverageTest.java @@ -0,0 +1,254 @@ +package net.tfminecraft.cooking.nutrition; + +import static org.junit.jupiter.api.Assertions.*; +import static org.mockito.Mockito.*; + +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.ArrayList; +import java.util.List; +import java.util.logging.Logger; +import net.Indyuce.mmocore.api.player.PlayerData; +import net.Indyuce.mmocore.api.player.attribute.PlayerAttributes.AttributeInstance; +import net.tfminecraft.cooking.Cooking; +import net.tfminecraft.cooking.item.FoodItem; +import net.tfminecraft.cooking.item.IngredientLineage; +import net.tfminecraft.cooking.item.tag.TagTrack; +import net.tfminecraft.cooking.loader.TrackLoader; +import net.tfminecraft.cooking.quality.QualityConfig; +import net.tfminecraft.rpcharacters.RPCharacters; +import net.tfminecraft.rpcharacters.managers.PlayerManager; +import net.tfminecraft.rpcharacters.objects.RPCharacter; +import org.bukkit.configuration.file.YamlConfiguration; +import org.bukkit.plugin.Plugin; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.io.TempDir; +import org.mockbukkit.mockbukkit.MockBukkit; +import org.mockbukkit.mockbukkit.ServerMock; +import org.mockbukkit.mockbukkit.entity.PlayerMock; +import org.mockito.MockedStatic; + +class NutritionRuntimeCoverageTest { + @TempDir Path directory; + private ServerMock server; + private PlayerMock player; + private RPCharacter character; + private PlayerManager players; + private MockedStatic rp; + private Cooking previousPlugin; + private List tracks; + + @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); + players = mock(PlayerManager.class); + rp = mockStatic(RPCharacters.class); + rp.when(RPCharacters::getPlayerManager).thenReturn(players); + rp.when(() -> RPCharacters.getActiveCharacter(player)).thenReturn(character); + tracks = new ArrayList<>(TrackLoader.oList); TrackLoader.oList.clear(); + NutritionConfig.load(new YamlConfiguration()); QualityConfig.apply(1,5,null,null); + NutritionLog.configure(false,false,null); + } + + @AfterEach void cleanup() { + 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; + } + + @Test void eatingUpdatesTheCharacterHistoryHudAndPersistenceOnlyWhenFoodIsGained() { + FoodItem food = food(10, 500); + character.setFoodValue(100); + NutritionService.tryApplyEat(null, food); NutritionService.tryApplyEat(player, null); + NutritionService.tryApplyEat(player, food); // Dependency not enabled yet. + assertEquals(100, character.getFoodValue()); verifyNoInteractions(players); + MockBukkit.createMockPlugin("RPCharacters"); + rp.when(() -> RPCharacters.getActiveCharacter(player)).thenReturn(null); + NutritionService.tryApplyEat(player, food); assertEquals(100, character.getFoodValue()); + rp.when(() -> RPCharacters.getActiveCharacter(player)).thenReturn(character); + NutritionService.tryApplyEat(player, food); + assertEquals(110, character.getFoodValue()); assertTrue(character.getRawDietScore() > 0); + assertEquals(11, player.getFoodLevel()); assertEquals(0, player.getSaturation()); + assertEquals(1, VarietyService.current(player).meals()); verify(players).savePlayer(player); + character.setFoodValue(198); NutritionService.tryApplyEat(player, food); + assertEquals(200, character.getFoodValue()); + clearInvocations(players); + NutritionService.tryApplyEat(player, food); + character.setFoodValue(100); NutritionService.tryApplyEat(player, food(0,20)); + assertEquals(100, character.getFoodValue()); verifyNoInteractions(players); + character.setRawDietScore(0); character.setDietScore(0); + NutritionService.tryApplyEat(player, food(1,0)); + assertEquals(101, character.getFoodValue()); assertEquals(0, character.getDietScore()); + } + + @Test void extremeConfiguredFoodValuesFillThePoolWithoutIntegerOverflow() { + MockBukkit.createMockPlugin("RPCharacters"); + character.setFoodValue(10); + NutritionService.tryApplyEat(player, food(Integer.MAX_VALUE,20)); + assertEquals(200, character.getFoodValue(), "Eating must never empty an existing food pool"); + verify(players).savePlayer(player); + } + + @Test void characterStorageCapsDoNotAwardNutritionForFoodThatWasNeverGained() { + MockBukkit.createMockPlugin("RPCharacters"); + configure("nutrition.max-food",400); + character.setFoodValue(RPCharacter.MAX_FOOD_VALUE); + NutritionService.tryApplyEat(player,food(20,500)); + assertEquals(RPCharacter.MAX_FOOD_VALUE,character.getFoodValue()); + assertEquals(0,VarietyService.current(player).meals()); + assertEquals(0,character.getRawDietScore()); verifyNoInteractions(players); + } + + @Test void displaySynchronizationClampsFoodClearsSaturationAndAlwaysReleasesItsGuard() { + assertEquals(0,NutritionDisplayService.toFoodLevel(-1)); + assertEquals(20,NutritionDisplayService.toFoodLevel(Integer.MAX_VALUE)); + assertFalse(NutritionDisplayService.isSyncing(null)); + NutritionDisplayService.sync(null,character); NutritionDisplayService.sync(player,null); + NutritionDisplayService.syncFromPlayer(null); + NutritionDisplayService.syncFromPlayer(player); + 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); + doAnswer(call -> { + assertTrue(NutritionDisplayService.isSyncing(failing)); + throw new IllegalStateException("Disconnected during HUD update"); + }).when(failing).setFoodLevel(anyInt()); + character.setFoodValue(20); + assertThrows(IllegalStateException.class,()->NutritionDisplayService.sync(failing,character)); + assertFalse(NutritionDisplayService.isSyncing(failing)); + } + + @Test void drainSchedulesOnceHonorsIntervalsAndHandlesMissingOrEmptyCharacters() { + configure("nutrition.drain-interval",2,"nutrition.drain-amount",3); + NutritionDrainTask.stop(); NutritionDrainTask.start(); NutritionDrainTask.start(); + server.getScheduler().performTicks(20); assertEquals(200,character.getFoodValue()); + MockBukkit.createMockPlugin("RPCharacters"); + rp.when(() -> RPCharacters.getActiveCharacter(player)).thenReturn(null); + server.getScheduler().performTicks(20); verifyNoInteractions(players); + rp.when(() -> RPCharacters.getActiveCharacter(player)).thenReturn(character); + server.getScheduler().performTicks(20); assertEquals(200,character.getFoodValue()); + server.getScheduler().performTicks(20); assertEquals(197,character.getFoodValue()); + verify(players).savePlayer(player); + character.setFoodValue(0); server.getScheduler().performTicks(40); + assertEquals(0,character.getFoodValue()); + configure("nutrition.drain-interval",1,"nutrition.drain-amount",0); + character.setFoodValue(20); server.getScheduler().performTicks(20); + assertEquals(20,character.getFoodValue()); + NutritionDrainTask.stop(); NutritionDrainTask.stop(); + configure("nutrition.drain-interval",1,"nutrition.drain-amount",3); + server.getScheduler().performTicks(40); assertEquals(20,character.getFoodValue()); + } + + @Test void invalidNegativeDrainCannotGenerateFood() { + MockBukkit.createMockPlugin("RPCharacters"); + configure("nutrition.drain-interval",1,"nutrition.drain-amount",-3); + character.setFoodValue(20); NutritionDrainTask.start(); + server.getScheduler().performTicks(20); + assertEquals(20,character.getFoodValue()); verifyNoInteractions(players); + } + + @Test void varietyHistoryAndTierMessagesTrackActualChanges() { + assertEquals(0,VarietyService.current(null).meals()); + VarietyService.recordMeal(null,IngredientLineage.ofMain("Wheat")); + VarietyService.applyEffective(null,null); + VarietyService.recordMeal(player,IngredientLineage.ofMain("Wheat")); + assertEquals(1,VarietyService.current(player).meals()); + character.setRawDietScore(20); VarietyService.applyEffective(player,character); + assertTrue(character.getDietScore() <= 20); + configure("nutrition.variety.enabled",false); + VarietyService.recordMeal(player,IngredientLineage.ofMain("Carrot")); + 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)); + verifyNoInteractions(players); + MockBukkit.createMockPlugin("RPCharacters"); character.setLastDietTierId(" "); + assertTrue(DietTierService.seedIfAbsent(player,character)); verify(players).savePlayer(player); + 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); + assertTrue(player.nextMessage().contains("Your diet is now")); assertNull(player.nextMessage()); + configure("nutrition.variety.enabled",true); + character.setDietScore(40); DietTierService.checkAndNotify(player,character,VarietyService.current(player)); + assertTrue(player.nextMessage().contains("Your diet is now")); + assertTrue(player.nextMessage().contains("nutrition penalty")); assertNull(player.nextMessage()); + } + + @Test void attributeBridgeOnlyUpdatesAvailableConfiguredAttributes() { + NutritionAttributeBridge.apply(null,character); NutritionAttributeBridge.apply(player,null); + NutritionAttributeBridge.apply(player,character); + MockBukkit.createMockPlugin("MMOCore"); + try (MockedStatic data = mockStatic(PlayerData.class)) { + PlayerData pd = mock(PlayerData.class,RETURNS_DEEP_STUBS); + data.when(() -> PlayerData.get(player)).thenReturn(pd); + String key = NutritionConfig.attributeName(); + when(pd.getAttributes().getInstance(key)).thenReturn(null); + NutritionAttributeBridge.apply(player,character); + AttributeInstance attribute = mock(AttributeInstance.class); + when(pd.getAttributes().getInstance(key)).thenReturn(attribute); + character.setDietScore(12); NutritionAttributeBridge.apply(player,character); + verify(attribute).setBase(12); + } + } + + @Test void nutritionLogWritesSanitizedRecordsAndHandlesFilesystemFailures() throws Exception { + NutritionLog.configure(true,false,null); assertTrue(NutritionLog.isEnabled()); + NutritionLog.append("IGNORED",player,character,null); + NutritionLog.configure(true,true,directory.toFile()); + NutritionLog.append("MEAL with\nnewline",player,character,"first\nsecond\rthird"); + NutritionLog.append(null,null,null," "); + Path log=directory.resolve("logs/nutrition.log"); + String text=Files.readString(log); + assertTrue(text.contains("event=MEAL_with_newline")); assertTrue(text.contains("first second third")); + assertTrue(text.contains("character=-")); assertTrue(text.contains("event=-")); + NutritionLog.configure(true,true,directory.toFile()); + assertFalse(Files.readString(log).contains("MEAL")); + Files.delete(log); Files.createDirectory(log); Files.writeString(log.resolve("child"),"occupied"); + assertDoesNotThrow(()->NutritionLog.configure(true,true,directory.toFile())); + verify(Cooking.plugin.getLogger()).warning(contains("Failed to wipe")); + verify(Cooking.plugin.getLogger()).warning(contains("Failed to write")); + verify(Cooking.plugin.getLogger(),never()).severe(anyString()); + Cooking saved=Cooking.plugin; Cooking.plugin=null; + try { assertDoesNotThrow(()->NutritionLog.append("WRITE_FAIL",null,null,null)); } + finally { Cooking.plugin=saved; } + NutritionLog.configure(false,false,directory.toFile()); assertFalse(NutritionLog.isEnabled()); + } + + @Test void emptyHistoryAndZeroWeightsHaveNoPenaltyAndNoFoodGainLeavesDietUntouched() { + assertEquals(20,DietMath.lerpRaw(20,40,0,200,1)); + assertEquals(20,DietMath.lerpRaw(20,40,1,0,1)); + assertTrue(VarietyHistory.of(null,10).isEmpty()); + assertEquals("",VarietyHistoryCodec.encode(null)); + assertEquals("",VarietyHistoryCodec.encode(VarietyHistory.empty())); + VarietyHistory history=VarietyHistory.of(List.of(IngredientLineage.ofMain("Wheat")),5); + var noWeights=VarietyMath.evaluate(history,0,0,8,60); + assertEquals(0,noWeights.penaltyPercent()); assertEquals(100,noWeights.progressPercent()); + } + + 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); + item.setOrigin("Wheat"); item.setLineage(IngredientLineage.ofMain("Wheat")); return item; + } + + private void configure(Object... entries) { + YamlConfiguration config = new YamlConfiguration(); + for(int i=0;i ItemCache.matchesOvenFuel(any())).thenAnswer(inv -> { + ItemStack item = inv.getArgument(0); return item != null && item.getType() == Material.OAK_LOG; + }); + oven = bakery.station("oven_bottom"); + for (String slot : OvenSlots.FILL_ORDER) oven.define(slot); + oven.define(OvenSlots.FIRE); + cavity = bakery.consumer(); bakery.nest(cavity, "tray", bakery.tray.furniture); + when(bakery.tray.furniture.isAttached()).thenReturn(true); + burns = new OvenBurnManager(); handler = new OvenHandler(burns); + } + @AfterEach void close() { + burns.stopAll(); bakery.close(); + FurnitureCache.ovenBottom = oldBottom; FurnitureCache.ovenTop = oldTop; + ItemCache.ovenWoodModel = oldWood; ItemCache.ovenWoodBurntModel = oldBurntWood; ItemCache.ovenFireModel = oldFire; + ItemCache.ovenBurnIntervalTicks = oldInterval; ItemCache.ovenBurnChanceFresh = oldFreshChance; ItemCache.ovenBurnChanceBurnt = oldBurntChance; + } + + void ignite(String slot) { + OvenState.setStage(oven.furniture, slot, OvenSlots.WoodStage.FRESH); OvenState.setLit(oven.furniture, true); + } + + @Test + void persistentFuelStagesValidateSnapshotsAndBurnFromTheHighestLayer() { + assertFalse(OvenState.isLit(oven.furniture)); assertFalse(OvenState.hasHeat(oven.furniture)); + oven.variables.put(OvenState.VAR_LIT, "true"); assertTrue(OvenState.isLit(oven.furniture)); + oven.variables.put(OvenState.VAR_LIT, 1); assertFalse(OvenState.isLit(oven.furniture)); + oven.variables.put(OvenState.VAR_STAGES, "bad"); assertEquals(0, OvenState.getWoodCount(oven.furniture)); + oven.variables.put(OvenState.VAR_STAGES, "XEFEBF"); + assertEquals(OvenSlots.WoodStage.EMPTY, OvenState.getStage(oven.furniture, OvenSlots.WOOD_1)); + assertEquals(OvenSlots.WoodStage.FRESH, OvenState.getStage(oven.furniture, OvenSlots.WOOD_3)); + assertEquals(OvenSlots.WoodStage.BURNT, OvenState.getStage(oven.furniture, OvenSlots.WOOD_5)); + assertArrayEquals(new String[] {OvenSlots.WOOD_6}, OvenState.getActiveBurnLayer(oven.furniture)); + OvenState.setStage(oven.furniture, OvenSlots.WOOD_6, OvenSlots.WoodStage.EMPTY); + assertArrayEquals(new String[] {OvenSlots.WOOD_4, OvenSlots.WOOD_5}, OvenState.getActiveBurnLayer(oven.furniture)); + OvenState.setStage(oven.furniture, OvenSlots.WOOD_5, OvenSlots.WoodStage.EMPTY); + assertArrayEquals(new String[] {OvenSlots.WOOD_1, OvenSlots.WOOD_2, OvenSlots.WOOD_3}, OvenState.getActiveBurnLayer(oven.furniture)); + assertEquals(OvenSlots.WOOD_1, OvenState.findNextFillSlot(oven.furniture)); + OvenState.setStage(oven.furniture, "missing", OvenSlots.WoodStage.FRESH); + assertEquals(OvenSlots.WoodStage.EMPTY, OvenState.getStage(oven.furniture, "missing")); + for (String slot : OvenSlots.FILL_ORDER) OvenState.setStage(oven.furniture, slot, OvenSlots.WoodStage.FRESH); + assertTrue(OvenState.isFull(oven.furniture)); assertNull(OvenState.findNextFillSlot(oven.furniture)); + OvenState.setLit(oven.furniture, true); assertTrue(OvenState.shouldShowFire(oven.furniture)); + oven.variables.put("unrelated", 3); OvenState.clear(oven.furniture); + assertEquals(Map.of("unrelated", 3), oven.variables); assertNull(OvenState.getActiveBurnLayer(oven.furniture)); + assertEquals('E', OvenSlots.WoodStage.EMPTY.code()); assertEquals('F', OvenSlots.WoodStage.FRESH.code()); assertEquals('B', OvenSlots.WoodStage.BURNT.code()); + assertEquals(OvenSlots.WoodStage.EMPTY, OvenSlots.WoodStage.fromCode('?')); + } + + @Test + void addingFuelFillsEachSlotOnceThenLightsAndDamagesTheIgniter() { + var empty = oven.interact(); handler.onInteract(empty); assertFalse(empty.isCancelled()); + ItemStack wood = env.hold(new ItemStack(Material.OAK_LOG, 7)); + for (int n = 1; n <= 6; n++) { + var add = oven.interact(); handler.onInteract(add); assertTrue(add.isCancelled()); + assertEquals(n, OvenState.getWoodCount(oven.furniture)); assertEquals(7 - n, wood.getAmount()); + } + handler.onInteract(oven.interact()); assertEquals(1, wood.getAmount()); assertTrue(env.messages.contains("§cThe oven is full of wood.")); + env.hold(null); var occupied = oven.interact(); handler.onInteract(occupied); assertTrue(occupied.isCancelled()); + ItemStack flint = env.hold(new ItemStack(Material.FLINT_AND_STEEL)); + handler.onInteract(oven.interact()); assertTrue(OvenState.isLit(oven.furniture)); assertTrue(oven.active.containsKey("fire")); + assertEquals(1, ((Damageable) flint.getItemMeta()).getDamage()); + handler.onInteract(oven.interact()); assertEquals(1, ((Damageable) flint.getItemMeta()).getDamage()); + env.hold(null); var lit = oven.interact(); handler.onInteract(lit); assertTrue(lit.isCancelled()); + verify(env.manager, times(7)).markDirty(oven.furniture); + } + + @Test + void emptyOvensCannotBeLitAndAnIgniterBreaksAtItsMaximumDamage() { + ItemStack flint = env.hold(new ItemStack(Material.FLINT_AND_STEEL)); + handler.onInteract(oven.interact()); assertFalse(OvenState.isLit(oven.furniture)); + assertTrue(env.messages.contains("§cAdd wood before lighting the oven.")); assertEquals(0, ((Damageable) flint.getItemMeta()).getDamage()); + OvenState.setStage(oven.furniture, OvenSlots.WOOD_1, OvenSlots.WoodStage.FRESH); + Damageable meta = (Damageable) flint.getItemMeta(); meta.setDamage(Material.FLINT_AND_STEEL.getMaxDurability() - 1); flint.setItemMeta(meta); + handler.onInteract(oven.interact()); assertTrue(OvenState.isLit(oven.furniture)); assertTrue(env.player.getInventory().getItemInMainHand().getType().isAir()); + env.hold(new ItemStack(Material.STICK)); var unrelated = oven.interact(); handler.onInteract(unrelated); assertFalse(unrelated.isCancelled()); + var otherFurniture = bakery.station("chair").interact(); handler.onInteract(otherFurniture); assertFalse(otherFurniture.isCancelled()); + } + + @Test + void displaysTrackFreshBurntEmptyAndFireAndTolerateUnavailableModels() { + ignite(OvenSlots.WOOD_1); OvenState.setStage(oven.furniture, OvenSlots.WOOD_2, OvenSlots.WoodStage.BURNT); + OvenDisplay.syncAll(oven.furniture); + assertTrue(oven.active.containsKey(OvenSlots.WOOD_1)); assertTrue(oven.active.containsKey(OvenSlots.WOOD_2)); assertTrue(oven.active.containsKey(OvenSlots.FIRE)); + verify(env.api.getCreator()).getItemFromPath("model.wood"); verify(env.api.getCreator()).getItemFromPath("model.burnt"); verify(env.api.getCreator()).getItemFromPath("model.fire"); + OvenState.setLit(oven.furniture, false); OvenDisplay.syncAll(oven.furniture); assertFalse(oven.active.containsKey(OvenSlots.FIRE)); + OvenDisplay.clearAll(oven.furniture); assertTrue(oven.active.isEmpty()); + oven.definitions.remove(OvenSlots.WOOD_1); ItemCache.ovenWoodBurntModel = null; + OvenDisplay.syncAll(oven.furniture); assertTrue(oven.active.isEmpty()); + oven.define(OvenSlots.WOOD_1); when(env.api.getCreator().getItemFromPath("model.wood")).thenReturn(null); + OvenDisplay.syncAll(oven.furniture); assertTrue(oven.active.isEmpty()); + when(oven.furniture.getType()).thenReturn(null); OvenDisplay.syncAll(oven.furniture); OvenDisplay.clearAll(oven.furniture); assertTrue(oven.active.isEmpty()); + } + + @Test + void deterministicBurningConsumesOnlyTheHighestLayerAndStopsAfterTheLastFuel() { + ItemCache.ovenBurnChanceFresh = 1; ItemCache.ovenBurnChanceBurnt = 1; + for (String slot : OvenSlots.FILL_ORDER) OvenState.setStage(oven.furniture, slot, OvenSlots.WoodStage.FRESH); + OvenState.setLit(oven.furniture, true); burns.startBurning(oven.furniture); burns.startBurning(oven.furniture); + env.server.getScheduler().performOneTick(); + assertEquals(OvenSlots.WoodStage.BURNT, OvenState.getStage(oven.furniture, OvenSlots.WOOD_6)); + assertEquals(OvenSlots.WoodStage.FRESH, OvenState.getStage(oven.furniture, OvenSlots.WOOD_5)); + env.server.getScheduler().performOneTick(); assertEquals(OvenSlots.WoodStage.EMPTY, OvenState.getStage(oven.furniture, OvenSlots.WOOD_6)); + env.server.getScheduler().performTicks(4); + assertEquals(0, OvenState.getWoodCount(oven.furniture)); assertFalse(OvenState.isLit(oven.furniture)); assertFalse(oven.active.containsKey(OvenSlots.FIRE)); + clearInvocations(env.manager); env.server.getScheduler().performTicks(3); verifyNoInteractions(env.manager); + } + + @Test + void zeroBurnChancesPreserveFuelAndStoppedOrRemovedOvensDoNotKeepTicking() { + burns.startBurning(null); burns.startBurning(bakery.station("chair").furniture); burns.startBurning(oven.furniture); + burns.stopBurning(null); burns.stopBurning(UUID.randomUUID()); + ignite(OvenSlots.WOOD_1); ItemCache.ovenBurnIntervalTicks = 0; + burns.startBurning(oven.furniture); env.server.getScheduler().performTicks(3); + assertEquals(OvenSlots.WoodStage.FRESH, OvenState.getStage(oven.furniture, OvenSlots.WOOD_1)); + burns.stopBurning(oven.id); ItemCache.ovenBurnChanceFresh = 1; + env.server.getScheduler().performTicks(2); assertEquals(OvenSlots.WoodStage.FRESH, OvenState.getStage(oven.furniture, OvenSlots.WOOD_1)); + burns.startBurning(oven.furniture); bakery.placed.remove(oven.id); env.server.getScheduler().performTicks(2); + assertEquals(OvenSlots.WoodStage.FRESH, OvenState.getStage(oven.furniture, OvenSlots.WOOD_1)); + bakery.placed.put(oven.id, oven.furniture); burns.startBurning(oven.furniture); FurnitureCache.ovenBottom = "replacement_bottom"; + env.server.getScheduler().performTicks(2); assertEquals(OvenSlots.WoodStage.FRESH, OvenState.getStage(oven.furniture, OvenSlots.WOOD_1)); + } + + @Test + void extinguishedOvensStopBeforeBurningAnyMoreFuel() { + ignite(OvenSlots.WOOD_1); ItemCache.ovenBurnChanceFresh = 1; + burns.startBurning(oven.furniture); OvenState.setLit(oven.furniture, false); + env.server.getScheduler().performTicks(2); + assertEquals(OvenSlots.WoodStage.FRESH, OvenState.getStage(oven.furniture, OvenSlots.WOOD_1)); + } + + @Test + void resumingRepairsLitButEmptyOvensAndStopAllCancelsEveryActiveTask() { + burns.resume(null); burns.resume(bakery.station("chair").furniture); + OvenState.setLit(oven.furniture, true); burns.resume(oven.furniture); assertFalse(OvenState.isLit(oven.furniture)); + verify(env.manager).markDirty(oven.furniture); + ignite(OvenSlots.WOOD_1); burns.resumeAll(); burns.stopAll(); ItemCache.ovenBurnChanceFresh = 1; + env.server.getScheduler().performTicks(3); assertEquals(OvenSlots.WoodStage.FRESH, OvenState.getStage(oven.furniture, OvenSlots.WOOD_1)); + } + + @Test + void chunkLifecycleStopsBurningAndResumesOnlyLoadedOvensOneTickLater() { + Chunk chunk = mock(Chunk.class), other = mock(Chunk.class); + when(env.world.getChunkAt(any(Location.class))).thenReturn(chunk); + Station distant = bakery.station("oven_bottom"); Location distantLocation = mock(Location.class); + when(distantLocation.getChunk()).thenReturn(other); when(distant.furniture.getLoc()).thenReturn(distantLocation); + OvenLifecycleHandler lifecycle = new OvenLifecycleHandler(burns); + ignite(OvenSlots.WOOD_1); lifecycle.resumeLoadedOvens(); + lifecycle.onChunkUnload(new ChunkUnloadEvent(chunk)); ItemCache.ovenBurnChanceFresh = 1; + env.server.getScheduler().performTicks(3); assertEquals(OvenSlots.WoodStage.FRESH, OvenState.getStage(oven.furniture, OvenSlots.WOOD_1)); + lifecycle.onChunkLoad(new ChunkLoadEvent(chunk, false)); + assertEquals(OvenSlots.WoodStage.FRESH, OvenState.getStage(oven.furniture, OvenSlots.WOOD_1)); + env.server.getScheduler().performTicks(2); assertEquals(OvenSlots.WoodStage.BURNT, OvenState.getStage(oven.furniture, OvenSlots.WOOD_1)); + lifecycle.onBreak(new FurnitureBreakEvent(bakery.tray.furniture, env.player)); + lifecycle.onBreak(new FurnitureBreakEvent(oven.furniture, env.player)); + assertTrue(oven.variables.isEmpty()); assertTrue(oven.active.isEmpty()); + env.server.getScheduler().performTicks(2); assertTrue(oven.variables.isEmpty()); + } + + @Test + void ambienceRespectsHeatWorldAndRandomChoicesWithoutMovingTheFurniture() { + Location initial = oven.furniture.getLoc().clone(); + assertEquals(initial.clone().add(0, 0.2, 0), OvenEffects.fireLocation(oven.furniture)); + assertEquals(initial, oven.furniture.getLoc()); + Random random = mock(Random.class); + OvenEffects.playAmbience(oven.furniture, random); verifyNoInteractions(random); + ignite(OvenSlots.WOOD_1); OvenEffects.playIgnite(oven.furniture); + when(random.nextFloat()).thenReturn(0f, 0.5f, 0f, 0f); when(random.nextBoolean()).thenReturn(true); + OvenEffects.playAmbience(oven.furniture, random); + verify(env.world).playSound(any(Location.class), eq(Sound.BLOCK_FIRE_AMBIENT), eq(1f), eq(1f)); + verify(env.world).spawnParticle(eq(Particle.SMOKE), any(Location.class), eq(1), eq(0.05), eq(0.04), eq(0.05), eq(0.005)); + when(random.nextFloat()).thenReturn(0f, 0.5f, 0f, 1f); when(random.nextBoolean()).thenReturn(false); + OvenEffects.playAmbience(oven.furniture, random); + verify(env.world).playSound(any(Location.class), eq("block.furnace.fire_crackle"), eq(1f), eq(1f)); + clearInvocations(env.world); when(random.nextFloat()).thenReturn(1f); OvenEffects.playAmbience(oven.furniture, random); verifyNoInteractions(env.world); + when(oven.furniture.getLoc()).thenReturn(new Location(null, 1, 64, 1)); + OvenEffects.playIgnite(oven.furniture); OvenEffects.playAmbience(oven.furniture, random); verifyNoInteractions(env.world); + } + + @Test + void cavitiesAcceptOnlyBakingTraysInFurnitureSlots() { + OvenCavityHandler handler = new OvenCavityHandler(); + var slot = cavity.define("tray"); when(slot.getSlotType()).thenReturn(SlotType.FURNITURE); + var allowed = new FurnitureSlotFurnitureAddEvent(env.player, cavity.furniture, slot, bakery.tray.furniture); + handler.onFurnitureAdd(allowed); assertFalse(allowed.isCancelled()); + var empty = new FurnitureSlotFurnitureAddEvent(env.player, cavity.furniture, slot, null); handler.onFurnitureAdd(empty); assertFalse(empty.isCancelled()); + var wrong = new FurnitureSlotFurnitureAddEvent(env.player, cavity.furniture, slot, oven.furniture); handler.onFurnitureAdd(wrong); assertTrue(wrong.isCancelled()); + assertTrue(env.messages.contains("§cOnly baking trays can go in the oven.")); + var other = new FurnitureSlotFurnitureAddEvent(env.player, oven.furniture, slot, bakery.tray.furniture); handler.onFurnitureAdd(other); assertFalse(other.isCancelled()); + when(slot.getSlotType()).thenReturn(SlotType.ITEM); + var itemSlot = new FurnitureSlotFurnitureAddEvent(env.player, cavity.furniture, slot, oven.furniture); handler.onFurnitureAdd(itemSlot); assertFalse(itemSlot.isCancelled()); + } + + @Test + void cavityClocksPauseWithoutHeatThenCookAndBurnEachLoafAtItsOwnThreshold() { + bakery.tray.place("left_a", bakery.loaf(0, 1)); bakery.tray.place("left_b", bakery.loaf(1, 1)); + bakery.tray.place("right_a", bakery.loaf(2, 1)); bakery.tray.place("right_b", new ItemStack(Material.STONE)); + bakery.nest(cavity, "empty", null); bakery.nest(cavity, "wrong", oven.furniture); + bakery.heated = false; new OvenCavityManager().start(); env.server.getScheduler().performTicks(40); + assertEquals(0, BakingTrayState.getSlotElapsed(bakery.tray.furniture, "left_a")); + bakery.heated = true; env.server.getScheduler().performTicks(20); + assertEquals(1, BakingTrayState.getSlotElapsed(bakery.tray.furniture, "left_a")); + assertEquals(0, env.resolve(bakery.tray.active.get("left_a").getCurrentItem()).getTagTrack("cooked").getValue()); + env.server.getScheduler().performTicks(20); + assertEquals(1, env.resolve(bakery.tray.active.get("left_a").getCurrentItem()).getTagTrack("cooked").getValue()); + env.server.getScheduler().performTicks(40); + assertEquals(2, env.resolve(bakery.tray.active.get("left_a").getCurrentItem()).getTagTrack("cooked").getValue()); + assertEquals(2, env.resolve(bakery.tray.active.get("left_b").getCurrentItem()).getTagTrack("cooked").getValue()); + assertEquals(0, BakingTrayState.getSlotElapsed(bakery.tray.furniture, "right_a")); + assertEquals(0, BakingTrayState.getSlotElapsed(bakery.tray.furniture, "right_b")); + verify(env.manager, times(2)).markDirty(bakery.tray.furniture); + } + + @Test + void untaggedBreadCanBeginCookingInAHeatedCavity() { + bakery.tray.place("left_a", env.stack(bakery.food("bread", null), Material.BREAD, 1)); + new OvenCavityManager().start(); env.server.getScheduler().performTicks(40); + FoodItem loaf = env.resolve(bakery.tray.active.get("left_a").getCurrentItem()); + assertNotNull(loaf.getTagTrack("cooked")); assertEquals(1, loaf.getTagTrack("cooked").getValue()); + } + + @Test + void emptyUnrecognizedAndBurntContentsDoNotAdvanceBakingClocks() { + bakery.tray.place("left_a", null); bakery.tray.place("left_b", new ItemStack(Material.AIR)); + bakery.tray.place("right_a", new ItemStack(Material.STONE)); bakery.tray.place("right_b", bakery.loaf(2, 1)); + new OvenCavityManager().start(); env.server.getScheduler().performTicks(40); + assertTrue(bakery.tray.variables.isEmpty()); verify(env.manager, never()).markDirty(bakery.tray.furniture); + bakery.tray.active.clear(); env.server.getScheduler().performTicks(20); assertTrue(bakery.tray.variables.isEmpty()); + } + + @Test + void aWorkingCavitySkipsEmptySlotsWhileAdvancingItsRawBread() { + bakery.tray.place("left_a", bakery.loaf(0, 1)); + bakery.tray.place("left_b", null); bakery.tray.place("right_a", new ItemStack(Material.AIR)); + new OvenCavityManager().start(); env.server.getScheduler().performTicks(20); + assertEquals(1, BakingTrayState.getSlotElapsed(bakery.tray.furniture, "left_a")); + assertEquals(0, BakingTrayState.getSlotElapsed(bakery.tray.furniture, "left_b")); + assertEquals(0, BakingTrayState.getSlotElapsed(bakery.tray.furniture, "right_a")); + } + + @Test + void emptyEntriesInThePublicSlotMapDoNotPreventOtherLoavesFromBaking() { + bakery.tray.furniture.getActiveSlots().put("left_a", null); + bakery.tray.place("left_b", bakery.loaf(0, 1)); + new OvenCavityManager().start(); env.server.getScheduler().performTicks(40); + assertEquals(1, env.resolve(bakery.tray.active.get("left_b").getCurrentItem()).getTagTrack("cooked").getValue()); + assertEquals(0, BakingTrayState.getSlotElapsed(bakery.tray.furniture, "left_a")); + assertNull(bakery.tray.furniture.getActiveSlots().get("left_a")); + } + + @Test + void failedBakingRenderDoesNotReplaceTheDisplayedLoafOrClaimASuccessfulChange() { + PlacedSlot slot = bakery.tray.place("left_a", bakery.loaf(0, 1)); + ItemStack original = slot.getCurrentItem(); + bakery.updater.when(() -> ItemUpdater.applyItemUpdate(any(), any(), any())).thenReturn(null); + new OvenCavityManager().start(); env.server.getScheduler().performTicks(40); + assertSame(original, slot.getCurrentItem()); verify(slot, never()).forceModel(any()); + verify(env.manager, never()).markDirty(bakery.tray.furniture); + } +} diff --git a/src/test/java/net/tfminecraft/cooking/quality/QualityRuntimeCoverageTest.java b/src/test/java/net/tfminecraft/cooking/quality/QualityRuntimeCoverageTest.java new file mode 100644 index 0000000..b0c2695 --- /dev/null +++ b/src/test/java/net/tfminecraft/cooking/quality/QualityRuntimeCoverageTest.java @@ -0,0 +1,146 @@ +package net.tfminecraft.cooking.quality; + +import static org.junit.jupiter.api.Assertions.*; +import static org.mockito.Mockito.*; + +import java.util.ArrayList; +import java.util.Arrays; +import java.util.List; +import java.util.Map; +import java.util.concurrent.ThreadLocalRandom; +import net.tfminecraft.cooking.Cooking; +import net.tfminecraft.cooking.item.FoodItem; +import net.tfminecraft.cooking.item.tag.TagStep; +import net.tfminecraft.cooking.item.tag.TagTrack; +import net.tfminecraft.cooking.loader.TrackLoader; +import net.tfminecraft.cooking.utils.QualityUtils; +import org.bukkit.configuration.MemoryConfiguration; +import org.bukkit.entity.Player; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.mockbukkit.mockbukkit.MockBukkit; + +class QualityRuntimeCoverageTest { + Cooking plugin; + List tracks; + @BeforeEach void setup() { + MockBukkit.mock(); plugin=Cooking.plugin; Cooking.plugin=mock(Cooking.class); + when(Cooking.plugin.getName()).thenReturn("Cooking"); when(Cooking.plugin.namespace()).thenReturn("cooking"); + tracks=new ArrayList<>(TrackLoader.oList); TrackLoader.oList.clear(); + QualityConfig.apply(2,2,null,null); PermissionEffectsConfig.apply(null,null); configure(false,true,1,1,true); + } + @AfterEach void cleanup() { + TrackLoader.oList.clear(); TrackLoader.oList.addAll(tracks); + QualityConfig.apply(1,5,null,null); PermissionEffectsConfig.apply(null,null); configure(true,true,.12,.15,true); + MockBukkit.unmock(); Cooking.plugin=plugin; + } + + @Test void partitionsPreserveRolesAndMainQualityWhileApplyingFreshnessAndLineage() { + FoodItem beef=food("beef","meat",4), pork=food("pork","meat",2), salt=food("salt","salt",4); + FoodItem plate=food("plate","neutral",5); + CompositionConfig.apply(false,null,null,java.util.Set.of("neutral"),null,false,0,0,true); + beef.addOrModifyTrack(track("freshness",true,10)); pork.addOrModifyTrack(track("freshness",true,20)); + beef.addOrModifyTrack(track("cooked",false,5)); beef.addOrModifyTrack(track("rising",true,5)); + CompositionResult result=CompositionQualityResolver.compose(null,Arrays.asList(beef,null,pork,salt,plate),CompositionContext.CARVE); + assertEquals(3,result.getBaselineQuality()); assertEquals(3,result.getFinalQuality()); + assertEquals(List.of(beef,pork),result.getMains()); assertEquals(List.of(salt),result.getExtras()); assertEquals(List.of(plate),result.getNeutral()); + assertEquals(Map.of("freshness",15),result.getFreshnessTracks()); assertNotNull(result.getLineage()); + assertTrue(CompositionQualityResolver.partitionByRole(null,null).getMains().isEmpty()); + assertEquals(2,CompositionQualityResolver.resolveMainsQuality(null)); assertEquals(2,CompositionQualityResolver.resolveMainsQuality(List.of())); + assertTrue(CompositionQualityResolver.resolveMainsFreshness(null).isEmpty()); + assertEquals(3,CompositionQualityResolver.resolve(null,List.of(beef,pork),null)); + FoodItem output=food("meal","meat",1); TrackLoader.oList.add(track("freshness",true,0)); + CompositionApplier.apply(output,result); assertEquals(3,output.getQualityMin()); assertEquals(15,output.getTagTrack("freshness").getValue()); + CompositionApplier.apply(null,result); CompositionApplier.apply(output,null); + CompositionFreshnessApplier.applyTracks(null,Map.of("freshness",3)); CompositionFreshnessApplier.applyTracks(output,null); + CompositionFreshnessApplier.applyTracks(output,Map.of()); CompositionFreshnessApplier.applyTracks(output,Map.of("missing",3)); + CompositionResult empty=new CompositionResult(1,2,null,null,null,null,null); + assertTrue(empty.getMains().isEmpty()); assertTrue(empty.getExtras().isEmpty()); assertTrue(empty.getNeutral().isEmpty()); + } + + @Test void extrasUseQualityGapsCraftBonusesAndAgePenalties() { + assertEquals(3,CompositionQualityResolver.applyExtras(3,null)); assertEquals(3,CompositionQualityResolver.applyExtras(3,List.of())); + FoodItem high=food("high","salt",4), low=food("low","salt",1), same=food("same","salt",3); + high.addOrModifyTrack(new TagTrack("craft",false,List.of(new TagStep("boost","Boost",0,1,1,0,.5)))); + high.addOrModifyTrack(new TagTrack("empty",true,List.of())); + assertEquals(4,CompositionQualityResolver.applyExtras(3,List.of(high))); + assertEquals(2,CompositionQualityResolver.applyExtras(3,List.of(low))); + assertEquals(3,CompositionQualityResolver.applyExtras(3,List.of(same))); + same.addOrModifyTrack(new TagTrack("aged",true,List.of(new TagStep("stale","Stale",0,1,1,1,0)))); + assertEquals(2,CompositionQualityResolver.applyExtras(3,List.of(same))); + configure(false,true,0,0,false); + assertEquals(3,CompositionQualityResolver.applyExtras(3,List.of(high,low))); + } + + @Test void chefBoostsUseTheBestPermittedChanceAndRespectContexts() { + Player chef=mock(Player.class); when(chef.hasPermission("chef")).thenReturn(true); + PermissionEffectsConfig.apply(null,List.of(new PermissionEffectsConfig.CompositionEffect("none","other",1,4), + new PermissionEffectsConfig.CompositionEffect("weak","chef",.5,1),new PermissionEffectsConfig.CompositionEffect("best","chef",1,2), + new PermissionEffectsConfig.CompositionEffect("last","chef",.3,4))); + FoodItem input=food("beef","meat",2); + assertEquals(4,CompositionQualityResolver.compose(chef,List.of(input),CompositionContext.CARVE).getFinalQuality()); + assertEquals(2,CompositionQualityResolver.compose(chef,List.of(input),CompositionContext.MILLING).getFinalQuality()); + assertEquals(2,CompositionQualityResolver.compose(chef,List.of(input),null).getFinalQuality()); + PermissionEffectsConfig.apply(null,List.of(new PermissionEffectsConfig.CompositionEffect("zero","chef",0,4))); + assertEquals(2,CompositionQualityResolver.compose(chef,List.of(input),CompositionContext.CARVE).getFinalQuality()); + PermissionEffectsConfig.apply(null,null); assertEquals(2,CompositionQualityResolver.compose(chef,List.of(input),CompositionContext.CARVE).getFinalQuality()); + PermissionEffectsConfig.apply(null,List.of(new PermissionEffectsConfig.CompositionEffect("chance","chef",.5,2))); + ThreadLocalRandom random=mock(ThreadLocalRandom.class); when(random.nextDouble()).thenReturn(.9); + try(var randomness=mockStatic(ThreadLocalRandom.class)) { + randomness.when(ThreadLocalRandom::current).thenReturn(random); + assertEquals(2,CompositionQualityResolver.compose(chef,List.of(input),CompositionContext.CARVE).getFinalQuality()); + } + } + + @Test void legacyCompositionFiltersExcludedExtrasAndSupportsChefBoosts() { + configure(true,true,0,0,true); Player chef=mock(Player.class); when(chef.hasPermission("chef")).thenReturn(true); + FoodItem main=food("meat","meat",4), extra=food("salt","salt",1); + assertEquals(4,CompositionQualityResolver.resolve(null,Arrays.asList(main,null,extra),CompositionContext.CARVE)); + assertEquals(3,CompositionQualityResolver.resolve(null,List.of(main,extra),null)); + assertEquals(2,CompositionQualityResolver.resolve(null,null,null)); + PermissionEffectsConfig.apply(null,List.of(new PermissionEffectsConfig.CompositionEffect("chef","chef",1,1))); + assertEquals(5,CompositionQualityResolver.resolve(chef,List.of(main),CompositionContext.CARVE)); + } + + @Test void pickupPermissionsTakeTheLargestMinimumAndBias() { + Player player=mock(Player.class); when(player.hasPermission("pickup")).thenReturn(true); + PermissionEffectsConfig.apply(List.of(new PermissionEffectsConfig.PickupEffect("other","other",5,5), + new PermissionEffectsConfig.PickupEffect("first","pickup",4,1),new PermissionEffectsConfig.PickupEffect("weaker","pickup",3,0)),null); + assertEquals(4,OriginQualityResolver.resolve(player,food("beef","meat",1))); + assertEquals(2,OriginQualityResolver.resolve(null,null)); + assertEquals(4,OriginQualityResolver.applyPickupPermissions(player,1)); + assertEquals(5,OriginQualityResolver.adjust(4,1,2)); assertEquals(1,OriginQualityResolver.adjust(-5,1,0)); + } + + @Test void qualityAndFreshnessArithmeticDoesNotOverflow() { + assertEquals(5,QualityUtils.average(Integer.MAX_VALUE,Integer.MAX_VALUE)); + assertEquals(5,OriginQualityResolver.adjust(4,1,Integer.MAX_VALUE)); + FoodItem first=food("first","meat",3), second=food("second","meat",3); + first.addOrModifyTrack(track("freshness",true,Integer.MAX_VALUE-1)); second.addOrModifyTrack(track("freshness",true,Integer.MAX_VALUE-1)); + assertEquals(Integer.MAX_VALUE-1,CompositionQualityResolver.resolveMainsFreshness(List.of(first,second)).get("freshness")); + } + + @Test void configuredPickupQualityStaysInsideTheSupportedStarRange() { + QualityConfig.apply(Integer.MAX_VALUE,Integer.MAX_VALUE,null,null); + assertEquals(5,QualityConfig.getPickupMin()); assertEquals(5,QualityConfig.getPickupMax()); + assertEquals(5,OriginQualityResolver.resolve(null,null)); + } + + @Test void extremeChefBoostsClampToFiveStars() { + Player player=mock(Player.class); when(player.hasPermission("chef")).thenReturn(true); + PermissionEffectsConfig.apply(null,List.of(new PermissionEffectsConfig.CompositionEffect("chef","chef",1,Integer.MAX_VALUE))); + assertEquals(5,CompositionQualityResolver.compose(player,List.of(food("beef","meat",4)),CompositionContext.CARVE).getFinalQuality()); + } + + private static void configure(boolean legacy,boolean modifiers,double up,double down,boolean craft) { + CompositionConfig.apply(legacy,null,null,null,null,modifiers,up,down,craft); + } + private FoodItem food(String id,String category,int quality) { + FoodItem item=new FoodItem(id,new MemoryConfiguration()); item.setCategory(category); item.setQualityRange(quality,quality); return item; + } + private TagTrack track(String id,boolean ageable,int value) { + TagTrack track=new TagTrack(id,ageable,List.of(new TagStep("fresh","Fresh",Integer.MAX_VALUE,1,1,0,0))); + track.forceSetValue(value); return track; + } +} diff --git a/src/test/java/net/tfminecraft/cooking/sausagemaker/SausageMakerCoverageTest.java b/src/test/java/net/tfminecraft/cooking/sausagemaker/SausageMakerCoverageTest.java new file mode 100644 index 0000000..67a71dc --- /dev/null +++ b/src/test/java/net/tfminecraft/cooking/sausagemaker/SausageMakerCoverageTest.java @@ -0,0 +1,455 @@ +package net.tfminecraft.cooking.sausagemaker; + +import static org.junit.jupiter.api.Assertions.*; +import static org.mockito.ArgumentMatchers.*; +import static org.mockito.Mockito.*; + +import java.io.File; +import java.util.ArrayList; +import java.util.Arrays; +import java.util.HashMap; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; +import java.util.Optional; +import java.util.UUID; +import java.util.concurrent.atomic.AtomicInteger; + +import org.bukkit.Location; +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; +import org.joml.Quaternionf; +import org.joml.Vector3f; +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.ItemDisplayMock; +import org.mockito.MockedStatic; + +import net.tfminecraft.cooking.Cooking; +import net.tfminecraft.cooking.cache.FurnitureCache; +import net.tfminecraft.cooking.cache.ItemCache; +import net.tfminecraft.cooking.item.FoodItem; +import net.tfminecraft.cooking.item.IngredientLineage; +import net.tfminecraft.cooking.item.model.FoodModel; +import net.tfminecraft.cooking.item.tag.TagTrack; +import net.tfminecraft.cooking.loader.FoodLoader; +import net.tfminecraft.cooking.loader.TrackLoader; +import net.tfminecraft.cooking.milling.MillingStoneAnimation; +import net.tfminecraft.cooking.quality.CompositionContext; +import net.tfminecraft.cooking.quality.CompositionQualityResolver; +import net.tfminecraft.cooking.quality.CompositionResult; +import net.tfminecraft.cooking.quality.OriginQualityResolver; +import net.tfminecraft.cooking.utils.InventoryAdder; +import net.tfminecraft.cooking.utils.ItemBuilder; +import net.tfminecraft.interactiblefurniture.InteractibleFurniture; +import net.tfminecraft.interactiblefurniture.events.FurnitureBreakEvent; +import net.tfminecraft.interactiblefurniture.events.FurnitureInteractEvent; +import net.tfminecraft.interactiblefurniture.events.FurnitureSlotItemAddEvent; +import net.tfminecraft.interactiblefurniture.furniture.Furniture; +import net.tfminecraft.interactiblefurniture.furniture.FurnitureType; +import net.tfminecraft.interactiblefurniture.furniture.PlacedSlot; +import net.tfminecraft.interactiblefurniture.furniture.SlotDefinition; +import net.tfminecraft.interactiblefurniture.manager.FurnitureManager; +import net.tfminecraft.tlibs.TLibs; +import net.tfminecraft.tlibs.objects.api.ItemAPI; + +public class SausageMakerCoverageTest { + Workshop env; + Station station; + SausageMakerHandler handler; + @BeforeEach void open() { env = new Workshop(); station = env.station("sausage_maker"); handler = new SausageMakerHandler(); } + @AfterEach void close() { env.close(); } + + void fill() { + for (int n = 1; n <= 3; n++) { + FoodItem food = env.food("meat_" + n, "Beef", n + 1); + food.setBaseFood(n); food.setBaseNutrition(n * 2); + station.place("meat_" + n, env.stack(food, Material.BEEF, 1)); + } + } + + @Test + void meatRulesAndPaperCasingRejectUnrelatedAndDisguisedItems() { + assertFalse(SausageMeatRules.isMeat(null)); + assertFalse(SausageMeatRules.isMeat(env.food(null, "Beef", 2))); + assertTrue(SausageMeatRules.isMeat(env.food("ROAST", "Beef", 2))); + assertTrue(SausageMeatRules.isMeat(env.food("Meat_Cut", "Beef", 2))); + assertFalse(SausageMeatRules.isMeat(env.food("soup", "Mixed", 2))); + assertFalse(CasingPaper.isCasing(null)); + ItemStack usedPaper = new ItemStack(Material.PAPER); usedPaper.setAmount(0); + assertFalse(CasingPaper.isCasing(usedPaper)); + assertTrue(CasingPaper.isCasing(new ItemStack(Material.PAPER, 2))); + assertFalse(CasingPaper.isCasing(env.stack(env.food("soup", "Mixed", 2), Material.PAPER, 1))); + ItemStack custom = new ItemStack(Material.PAPER); + var meta = custom.getItemMeta(); meta.setDisplayName("Masher"); custom.setItemMeta(meta); + assertFalse(CasingPaper.isCasing(custom)); + } + + @Test + void meatSlotAdmissionLeavesOtherFurnitureAndDecorationsAlone() { + var other = env.station("chair").add("meat_1", new ItemStack(Material.STONE)); + handler.onMeatAdd(other); assertFalse(other.isCancelled()); + var decor = station.add("decor", new ItemStack(Material.STONE)); + handler.onMeatAdd(decor); assertFalse(decor.isCancelled()); + var rejected = station.add("meat_2", new ItemStack(Material.STONE)); + handler.onMeatAdd(rejected); assertTrue(rejected.isCancelled()); + assertTrue(env.messages.contains("§cOnly meat can go in the sausage maker.")); + var meat = station.add("meat_3", env.stack(env.food("roast", "Pork", 3), Material.PORKCHOP, 1)); + handler.onMeatAdd(meat); assertFalse(meat.isCancelled()); + } + + @Test + void meatInsertionFillsFirstEmptySlotConsumesOneAndRejectsAFullMachine() { + var outside = env.station("chair").interact(); handler.onInteract(outside); assertFalse(outside.isCancelled()); + ItemStack hand = env.hold(env.stack(env.food("roast", "Pork", 4), Material.PORKCHOP, 4)); + station.place("meat_1", null); + for (int n = 1; n <= 3; n++) { + var event = station.interact(); handler.onInteract(event); + assertTrue(event.isCancelled()); + assertEquals(1, station.active.get("meat_" + n).getCurrentItem().getAmount()); + assertEquals(4 - n, hand.getAmount()); + } + handler.onInteract(station.interact()); + assertEquals(1, hand.getAmount()); + assertTrue(env.messages.contains("§cThe sausage maker is full.")); + verify(env.manager, times(3)).markDirty(station.furniture); + } + + @Test + void emptyHandTakesTheLastMeatAndEmptyMachinesDoNothing() { + var empty = station.interact(); handler.onInteract(empty); assertFalse(empty.isCancelled()); + fill(); + ItemStack expected = station.active.get("meat_3").getCurrentItem(); + var take = station.interact(); handler.onInteract(take); + assertTrue(take.isCancelled()); + assertFalse(station.active.containsKey("meat_3")); + assertEquals(expected, env.player.getInventory().getItemInMainHand()); + assertEquals(2, station.active.size()); + verify(env.manager).markDirty(station.furniture); + env.hold(null); + try (MockedStatic delivery = mockStatic(InventoryAdder.class)) { + delivery.when(() -> InventoryAdder.addItem(eq(env.player), any())).thenAnswer(inv -> inv.getArgument(1)); + handler.onInteract(station.interact()); + } + assertEquals(1, env.dropped.size()); + assertEquals("meat_2", env.resolve(env.dropped.get(0)).getId()); + } + + @Test + void unsupportedFoodAndMissingMeatOrCasingAreRejectedWithoutConsumption() { + ItemStack vegetable = env.hold(env.stack(env.food("vegetable", "Carrot", 3), Material.CARROT, 2)); + var wrongFood = station.interact(); handler.onInteract(wrongFood); + assertTrue(wrongFood.isCancelled()); assertEquals(2, vegetable.getAmount()); + env.hold(new ItemStack(Material.STICK)); handler.onInteract(station.interact()); + assertTrue(env.messages.contains("§cNeed 3 meats.")); + fill(); station.place("meat_2", new ItemStack(Material.STONE)); + handler.onInteract(station.interact()); + assertEquals(3, station.active.size()); + fill(); handler.onInteract(station.interact()); + assertTrue(env.messages.contains("§cHold paper to casing.")); + assertEquals(3, station.active.size()); + assertTrue(env.outputs().isEmpty()); + } + + @Test + void crankWaitsForAnimationThenConsumesOneCasingAndComposesTheThreeMeats() { + fill(); + ItemDisplayMock parent = env.display(station.id); + Transformation original = parent.getTransformation(); + ItemStack casing = env.hold(new ItemStack(Material.PAPER, 2)); + assertTrue(CasingPaper.isCasing(casing), casing.toString()); + var crank = station.interact(); handler.onInteract(crank); + assertTrue(crank.isCancelled()); assertTrue(SausageMakerAnimation.isAnimating(station.furniture), env.messages.toString()); + assertEquals(2, casing.getAmount()); assertEquals(3, station.active.size()); + var repeat = station.interact(); handler.onInteract(repeat); + assertTrue(repeat.isCancelled()); assertTrue(env.messages.contains("§cWait…")); + env.server.getScheduler().performTicks(4); + assertNotEquals(original.getLeftRotation(), parent.getTransformation().getLeftRotation()); + assertEquals(original.getTranslation(), parent.getTransformation().getTranslation()); + env.server.getScheduler().performTicks(6); + assertEquals(original, parent.getTransformation()); + assertFalse(SausageMakerAnimation.isAnimating(station.furniture)); + assertTrue(station.active.isEmpty()); + assertEquals(1, casing.getAmount()); + assertEquals(1, env.outputs().size()); + assertEquals("sausage_chain", env.lastBuilt.getId()); + assertEquals("Mixed", env.lastBuilt.getOrigin()); + assertEquals("meat", env.lastBuilt.getCategory()); + assertEquals(6, env.lastBuilt.getBaseFood()); + assertEquals(4, env.lastBuilt.getBaseNutrition()); + assertEquals(0, env.lastBuilt.getTagTrack("cooked").getValue()); + env.composer.verify(() -> CompositionQualityResolver.compose(eq(env.player), argThat(items -> items.size() == 3), eq(CompositionContext.SAUSAGE_MAKER))); + verify(env.manager).markDirty(station.furniture); + } + + @Test + void offhandCasingAndRejectedDeliveryDropTheChainOncePerCooldown() { + ItemCache.sausageMakerCooldownTicks = 1200; + fill(); + env.hold(new ItemStack(Material.STICK)); + env.player.getInventory().setItemInOffHand(new ItemStack(Material.PAPER, 2)); + try (MockedStatic delivery = mockStatic(InventoryAdder.class)) { + delivery.when(() -> InventoryAdder.addItem(eq(env.player), any())).thenAnswer(inv -> inv.getArgument(1)); + handler.onInteract(station.interact()); + assertEquals(1, env.player.getInventory().getItemInOffHand().getAmount()); + assertEquals(1, env.dropped.size()); + assertEquals("sausage_chain", env.resolve(env.dropped.get(0)).getId()); + assertTrue(station.active.isEmpty()); + fill(); + handler.onInteract(station.interact()); + assertEquals(1, env.dropped.size(), "A second crank during the cooldown cannot make another chain"); + assertEquals(3, station.active.size()); + handler.onBreak(new FurnitureBreakEvent(station.furniture, env.player)); + handler.onBreak(new FurnitureBreakEvent(env.station("chair").furniture, env.player)); + handler.onInteract(station.interact()); + assertEquals(2, env.dropped.size()); + } + } + + @ParameterizedTest + @ValueSource(strings = {"offline", "removed-meat", "null-meat", "changed-meat", "removed-casing", "missing-template", "null-build", "air-build"}) + void completionRechecksPlayerIngredientsCasingAndOutputTemplate(String failure) { + fill(); env.display(station.id); + ItemStack casing = env.hold(new ItemStack(Material.PAPER, 2)); + handler.onInteract(station.interact()); + ItemStack air = new ItemStack(Material.AIR); + switch (failure) { + case "offline" -> when(env.player.isOnline()).thenReturn(false); + case "removed-meat" -> station.active.remove("meat_2"); + case "null-meat" -> station.place("meat_2", null); + case "changed-meat" -> station.place("meat_2", new ItemStack(Material.STONE)); + case "removed-casing" -> env.hold(new ItemStack(Material.STICK)); + case "missing-template" -> env.templates.remove("sausage_chain"); + case "null-build" -> env.builder.when(() -> ItemBuilder.buildComposedWithQuality(any(), anyInt())).thenReturn(null); + case "air-build" -> env.builder.when(() -> ItemBuilder.buildComposedWithQuality(any(), anyInt())).thenReturn(air); + default -> fail("Unknown completion failure"); + } + env.server.getScheduler().performTicks(10); + assertTrue(env.outputs().isEmpty()); assertTrue(env.dropped.isEmpty()); + assertEquals(2, casing.getAmount()); + assertTrue(station.active.containsKey("meat_1")); + assertTrue(station.active.containsKey("meat_3")); + assertFalse(SausageMakerAnimation.isAnimating(station.furniture)); + } + + @Test + void batchTotalsIgnoreMissingMeatsAndKeepTemplateNutritionForAnEmptyBatch() { + FoodItem chain = env.food("sausage_chain", "Mixed", 2); chain.setBaseNutrition(9); + SausageItems.applyBatchTotals(chain, null); + assertEquals(0, chain.getBaseFood()); assertEquals(9, chain.getBaseNutrition()); + FoodItem beef = env.food("meat_beef", "Beef", 2); beef.setBaseFood(5); beef.setBaseNutrition(3); + FoodItem pork = env.food("meat_pork", "Pork", 2); pork.setBaseFood(7); pork.setBaseNutrition(5); + SausageItems.applyBatchTotals(chain, Arrays.asList(beef, null, pork)); + assertEquals(12, chain.getBaseFood()); assertEquals(4, chain.getBaseNutrition()); + TrackLoader.oList.clear(); + assertNotNull(SausageItems.fromMeats(env.player, List.of(beef))); + assertFalse(env.lastBuilt.hasTagTrack("cooked")); + } + + @Test + void animationCompletesImmediatelyWithoutADisplayAndStopsIfItsParentDisappears() { + assertFalse(SausageMakerAnimation.isAnimating(null)); + Furniture noId = mock(Furniture.class); + assertFalse(SausageMakerAnimation.isAnimating(noId)); + SausageMakerAnimation.clearAnimating(null); SausageMakerAnimation.clearAnimating(noId); + AtomicInteger completions = new AtomicInteger(); + SausageMakerAnimation.playWobble(station.furniture, completions::incrementAndGet); + SausageMakerAnimation.playWobble(station.furniture, null); + assertEquals(1, completions.get()); + ItemDisplayMock parent = env.display(station.id); + station.place("meat_2", env.stack(env.food("meat_beef", "Beef", 2), Material.BEEF, 1)); + SausageMakerAnimation.playWobble(station.furniture, completions::incrementAndGet); + env.server.getScheduler().performOneTick(); + verify(station.active.get("meat_2")).followParentTransform(parent); + parent.remove(); + env.server.getScheduler().performTicks(10); + assertFalse(SausageMakerAnimation.isAnimating(station.furniture)); + assertEquals(1, completions.get()); + } + + @Test + void zeroDurationWobbleNeverWritesANonFiniteTransform() { + ItemCache.sausageMakerDurationTicks = 0; + ItemDisplayMock parent = env.display(station.id); + AtomicInteger completions = new AtomicInteger(); + SausageMakerAnimation.playWobble(station.furniture, completions::incrementAndGet); + env.server.getScheduler().performOneTick(); + Quaternionf rotation = parent.getTransformation().getLeftRotation(); + assertTrue(Float.isFinite(rotation.x) && Float.isFinite(rotation.y) && Float.isFinite(rotation.z) && Float.isFinite(rotation.w)); + env.server.getScheduler().performTicks(3); + assertEquals(1, completions.get()); + } + + /** 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 FurnitureManager manager = mock(FurnitureManager.class); + public final ItemAPI api = mock(ItemAPI.class, RETURNS_DEEP_STUBS); + public final List dropped = new ArrayList<>(); + public final List messages = new ArrayList<>(); + public final List stations = new ArrayList<>(); + public final Map templates = new LinkedHashMap<>(); + public final MockedStatic builder; + public final MockedStatic cache; + public final MockedStatic composer; + public final MockedStatic originQuality; + public FoodItem lastBuilt; + public int lastQuality; + final Cooking previousPlugin = Cooking.plugin; + final List previousTracks = TrackLoader.oList; + final String previousSausage = FurnitureCache.sausageMaker; + final String previousMill = FurnitureCache.millingStone; + final int previousDuration = ItemCache.sausageMakerDurationTicks; + final float previousWobble = ItemCache.sausageMakerWobbleDegrees; + final int previousCooldown = ItemCache.sausageMakerCooldownTicks; + final List> mocks = new ArrayList<>(); + final Map foods = new HashMap<>(); + final NamespacedKey key = NamespacedKey.fromString("test:processor_food"); + + public Workshop() { + Cooking.plugin = mock(Cooking.class); + 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)); + 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); + }); + doAnswer(inv -> { messages.add(inv.getArgument(0)); return null; }).when(player).sendMessage(anyString()); + FurnitureCache.sausageMaker = "sausage_maker"; FurnitureCache.millingStone = "milling_stone"; + ItemCache.sausageMakerDurationTicks = 6; ItemCache.sausageMakerWobbleDegrees = 20; ItemCache.sausageMakerCooldownTicks = 10; + TrackLoader.oList = new ArrayList<>(); new TrackLoader().load(new File("src/main/resources/tags.yml")); + MockedStatic codec = scoped(mockStatic(FoodItem.class, CALLS_REAL_METHODS)); + codec.when(() -> FoodItem.fromItem(any())).thenAnswer(inv -> resolve(inv.getArgument(0))); + scoped(mockStatic(FoodLoader.class)).when(() -> FoodLoader.getByString(any())).thenAnswer(inv -> templates.get(inv.getArgument(0))); + for (String id : List.of("sausage_chain", "wheat", "flour")) templates.put(id, food(id, "Wheat", 3)); + scoped(mockStatic(TLibs.class)).when(TLibs::getItemAPI).thenReturn(api); + when(api.getCreator().getItemFromPath(anyString())).thenAnswer(inv -> new ItemStack(Material.STONE)); + when(api.getChecker().checkItemWithPath(any(), anyString())).thenAnswer(inv -> { + ItemStack stack = inv.getArgument(0); String path = inv.getArgument(1); + if (stack == null || stack.getType().isAir()) return false; + FoodItem food = resolve(stack); + boolean vanilla = !stack.getItemMeta().hasDisplayName() + && stack.getItemMeta().getPersistentDataContainer().getKeys().isEmpty(); + return switch (path) { + case "v.paper" -> stack.getType() == Material.PAPER && vanilla; + case "v.wheat" -> stack.getType() == Material.WHEAT && vanilla; + case "food:grain" -> food != null && food.getCategory().equals("grain"); + default -> false; + }; + }); + InteractibleFurniture plugin = mock(InteractibleFurniture.class); + when(plugin.getFurnitureManager()).thenReturn(manager); + scoped(mockStatic(InteractibleFurniture.class)).when(InteractibleFurniture::getInstance).thenReturn(plugin); + cache = scoped(mockStatic(ItemCache.class)); + cache.when(() -> ItemCache.getMixingModel("flour")).thenReturn("model.flour"); + composer = scoped(mockStatic(CompositionQualityResolver.class)); + composer.when(() -> CompositionQualityResolver.compose(any(), any(), any())).thenReturn( + new CompositionResult(3, 4, Map.of(), List.of(), List.of(), List.of(), IngredientLineage.empty())); + originQuality = scoped(mockStatic(OriginQualityResolver.class)); + originQuality.when(() -> OriginQualityResolver.resolve(any(), any())).thenReturn(3); + builder = scoped(mockStatic(ItemBuilder.class)); + builder.when(() -> ItemBuilder.buildComposedWithQuality(any(), anyInt())).thenAnswer(inv -> build(inv.getArgument(0), inv.getArgument(1))); + builder.when(() -> ItemBuilder.buildSingleWithQuality(any(FoodItem.class), anyInt())).thenAnswer(inv -> build(inv.getArgument(0), inv.getArgument(1))); + } + public MockedStatic scoped(MockedStatic mock) { mocks.add(mock); return mock; } + ItemStack build(FoodItem food, int quality) { lastBuilt = food; lastQuality = quality; return stack(food, Material.PAPER, 1); } + public FoodItem food(String id, String origin, int quality) { + MemoryConfiguration config = new MemoryConfiguration(); config.set("name", id == null ? "Unknown" : id); config.set("update", false); + FoodItem food = new FoodItem(id, config); food.setCategory("meat"); food.setOrigin(origin); food.setQualityRange(quality, quality); + food.setModel(new FoodModel(new ItemStack(Material.CARROT))); + return food; + } + public ItemStack stack(FoodItem food, Material material, int amount) { + ItemStack item = new ItemStack(material, amount); String id = UUID.randomUUID().toString(); foods.put(id, food); + var meta = item.getItemMeta(); meta.getPersistentDataContainer().set(key, PersistentDataType.STRING, id); item.setItemMeta(meta); return item; + } + public FoodItem resolve(ItemStack stack) { + if (stack == null || stack.getType().isAir() || !stack.hasItemMeta()) return null; + if (stack.getItemMeta() == null) return null; + return foods.get(stack.getItemMeta().getPersistentDataContainer().get(key, PersistentDataType.STRING)); + } + public ItemStack hold(ItemStack stack) { player.getInventory().setItemInMainHand(stack); return player.getInventory().getItemInMainHand(); } + public List outputs() { + List output = new ArrayList<>(); + for (ItemStack stack : player.getInventory().getStorageContents()) { + FoodItem food = resolve(stack); + if (food != null && (food.getId().equals("sausage_chain") || food.getId().equals("flour"))) output.add(stack); + } + return output; + } + public Station station(String kind) { Station station = new Station(this, kind); stations.add(station); return station; } + public ItemDisplayMock display(UUID id) { + ItemDisplayMock display = new ItemDisplayMock(server, id); + display.setTransformation(new Transformation(new Vector3f(0.1f, 0.2f, 0.3f), new Quaternionf(), new Vector3f(1), new Quaternionf())); + server.registerEntity(display); return display; + } + @Override public void close() { + for (Station station : stations) { SausageMakerAnimation.clearAnimating(station.furniture); MillingStoneAnimation.clearAnimating(station.furniture); } + for (int n = mocks.size() - 1; n >= 0; n--) mocks.get(n).close(); + TrackLoader.oList = previousTracks; Cooking.plugin = previousPlugin; + FurnitureCache.sausageMaker = previousSausage; FurnitureCache.millingStone = previousMill; + ItemCache.sausageMakerDurationTicks = previousDuration; ItemCache.sausageMakerWobbleDegrees = previousWobble; ItemCache.sausageMakerCooldownTicks = previousCooldown; + MockBukkit.unmock(); + } + } + + public static final class Station { + public final Furniture furniture = mock(Furniture.class); + public final FurnitureType type = mock(FurnitureType.class); + public final UUID id = UUID.randomUUID(); + public final Map definitions = new LinkedHashMap<>(); + public final Map active = new LinkedHashMap<>(); + public final Map variables = new LinkedHashMap<>(); + final Workshop env; + Station(Workshop env, String kind) { + this.env = env; + when(furniture.getId()).thenReturn(kind); when(furniture.getEntityId()).thenReturn(id); when(furniture.getType()).thenReturn(type); + when(furniture.getLoc()).thenReturn(new Location(env.world, 1, 64, 1)); when(furniture.getVariables()).thenReturn(variables); + when(type.getId()).thenReturn(kind); when(type.getSlots()).thenReturn(definitions); + when(type.getSlot(anyString())).thenAnswer(inv -> definitions.get(inv.getArgument(0))); + when(furniture.getActiveSlots()).thenReturn(active); + when(furniture.hasActiveSlot(anyString())).thenAnswer(inv -> active.containsKey(inv.getArgument(0))); + when(furniture.getActiveSlot(anyString())).thenAnswer(inv -> Optional.ofNullable(active.get(inv.getArgument(0)))); + when(furniture.getOrCreatePlacedSlot(anyString())).thenAnswer(inv -> { + String slot = inv.getArgument(0); return active.containsKey(slot) ? active.get(slot) : place(slot, null); + }); + doAnswer(inv -> { active.remove(inv.getArgument(0)); return null; }).when(furniture).removeActiveSlot(anyString()); + } + public SlotDefinition define(String id) { + return definitions.computeIfAbsent(id, key -> { SlotDefinition def = mock(SlotDefinition.class); when(def.getId()).thenReturn(key); return def; }); + } + public PlacedSlot place(String id, ItemStack stack) { + PlacedSlot slot = mock(PlacedSlot.class); ItemStack[] value = {stack}; + when(slot.getId()).thenReturn(id); + when(slot.getCurrentItem()).thenAnswer(inv -> value[0]); + doAnswer(inv -> { value[0] = inv.getArgument(0); active.put(id, slot); return null; }).when(slot).setCurrentItem(any()); + doAnswer(inv -> { value[0] = inv.getArgument(0); active.put(id, slot); return null; }).when(slot).forceModel(any()); + doAnswer(inv -> { value[0] = null; active.remove(id); return null; }).when(slot).clearModel(); + active.put(id, slot); return slot; + } + public FurnitureInteractEvent interact() { return new FurnitureInteractEvent(env.player, furniture); } + public FurnitureSlotItemAddEvent add(String slot, ItemStack stack) { return new FurnitureSlotItemAddEvent(env.player, furniture, define(slot), stack); } + public ItemDisplayMock display(String slot) { + PlacedSlot placed = active.get(slot); UUID id = UUID.randomUUID(); when(placed.getDisplayStandId()).thenReturn(id); return env.display(id); + } + } +} diff --git a/src/test/java/net/tfminecraft/cooking/trough/TroughCoverageTest.java b/src/test/java/net/tfminecraft/cooking/trough/TroughCoverageTest.java new file mode 100644 index 0000000..c53cd9e --- /dev/null +++ b/src/test/java/net/tfminecraft/cooking/trough/TroughCoverageTest.java @@ -0,0 +1,141 @@ +package net.tfminecraft.cooking.trough; + +import static org.junit.jupiter.api.Assertions.*; +import static org.mockito.ArgumentMatchers.*; +import static org.mockito.Mockito.*; + +import java.util.List; +import org.bukkit.Location; +import org.bukkit.Material; +import org.bukkit.inventory.ItemStack; +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.mockito.MockedStatic; +import net.tfminecraft.cooking.cache.FurnitureCache; +import net.tfminecraft.cooking.cache.ItemCache; +import net.tfminecraft.cooking.events.DishCookedEvent; +import net.tfminecraft.cooking.item.FoodItem; +import net.tfminecraft.cooking.loader.TrackLoader; +import net.tfminecraft.cooking.manager.MealManagersCoverageTest.Kitchen; +import net.tfminecraft.cooking.sausagemaker.SausageMakerCoverageTest.Station; +import net.tfminecraft.cooking.sausagemaker.SausageMakerCoverageTest.Workshop; +import net.tfminecraft.cooking.utils.IngredientConverter; +import net.tfminecraft.cooking.utils.InventoryAdder; +import net.tfminecraft.cooking.utils.ItemUpdater; + +class TroughCoverageTest { + Kitchen kitchen; + Workshop env; + Station trough; + TroughHandler handler; + MockedStatic converter; + String previousTrough, previousFeed; + int previousCount; + + @BeforeEach void open() { + kitchen = new Kitchen(); env = kitchen.env; trough = kitchen.station("trough"); handler = new TroughHandler(); + previousTrough = FurnitureCache.trough; previousFeed = ItemCache.troughFeed; previousCount = ItemCache.troughItemsPerClick; + FurnitureCache.trough = "trough"; ItemCache.troughFeed = "feed"; ItemCache.troughItemsPerClick = 3; + for (String id : TroughSlots.ALL) trough.define(id); + when(env.api.getCreator().getItemFromPath("feed")).thenAnswer(inv -> new ItemStack(Material.WHEAT)); + converter = env.scoped(mockStatic(IngredientConverter.class)); + converter.when(() -> IngredientConverter.convertIfNeeded(any(), any())).thenAnswer(inv -> inv.getArgument(1)); + } + @AfterEach void close() { + kitchen.close(); FurnitureCache.trough = previousTrough; ItemCache.troughFeed = previousFeed; ItemCache.troughItemsPerClick = previousCount; + } + FoodItem carrot() { return kitchen.food("carrot", "vegetable_root"); } + FoodItem rotten() { + FoodItem food = carrot(); food.addOrModifyTrack(TrackLoader.getByString("freshness")); + food.getTagTrack("freshness").forceSetValue(Math.toIntExact(food.getTagTrack("freshness").getSteps().getLast().getRequiredValue())); return food; + } + void fill() { for (String id : TroughSlots.ALL) trough.place(id, env.stack(carrot(), Material.CARROT, 3)); } + + @Test void ingredientsAcceptWheatAndFreshVegetablesAndResolveConvertedHands() { + assertNull(TroughIngredients.resolveHand(env.player, null)); assertTrue(TroughIngredients.isRotten(null)); assertFalse(TroughIngredients.isAllowed(null)); + FoodItem fresh = carrot(); assertTrue(TroughIngredients.isAllowed(fresh)); assertFalse(TroughIngredients.isRotten(fresh)); + fresh.addOrModifyTrack(TrackLoader.getByString("freshness")); assertFalse(TroughIngredients.isRotten(fresh)); + assertTrue(TroughIngredients.isRotten(rotten())); assertFalse(TroughIngredients.isAllowed(rotten())); + assertTrue(TroughIngredients.isAllowed(kitchen.food("WHEAT", "grain"))); assertFalse(TroughIngredients.isAllowed(kitchen.food("steak", "meat"))); + for (String category : new String[] {null, "", "grain"}) assertFalse(TroughIngredients.isAllowedCategory(category)); + assertFalse(TroughIngredients.isWheatFood(null)); assertTrue(TroughIngredients.isAllowedCategory("VEGETABLE_ROOT")); + ItemStack original = env.hold(new ItemStack(Material.CARROT, 4)), converted = env.stack(fresh, Material.PAPER, 4); + converter.when(() -> IngredientConverter.convertIfNeeded(env.player, original)).thenReturn(converted); + assertSame(fresh, TroughIngredients.resolveHand(env.player, original)); assertEquals(converted, env.player.getInventory().getItemInMainHand()); + } + + @Test void interactionRoutesRespectFurnitureAndSneakingThenFillOneSlotPerClick() { + var ignored = kitchen.station("chair").interact(); handler.onInteract(ignored); assertFalse(ignored.isCancelled()); + when(env.player.isSneaking()).thenReturn(true); var sneaking = trough.interact(); handler.onInteract(sneaking); assertFalse(sneaking.isCancelled()); + when(env.player.isSneaking()).thenReturn(false); var empty = trough.interact(); handler.onInteract(empty); assertTrue(empty.isCancelled()); assertTrue(trough.active.isEmpty()); + ItemStack held = env.hold(env.stack(carrot(), Material.CARROT, 8)); handler.onInteract(trough.interact()); + assertEquals(5, held.getAmount()); assertEquals(3, trough.active.get("a").getCurrentItem().getAmount()); assertEquals("b", TroughHandler.findFirstEmpty(trough.furniture)); + handler.onInteract(trough.interact()); assertEquals(2, held.getAmount()); assertEquals(3, trough.active.get("b").getCurrentItem().getAmount()); + handler.onInteract(trough.interact()); assertEquals(2, held.getAmount()); assertTrue(env.messages.getLast().contains("3 at a time")); verify(env.manager, times(2)).markDirty(trough.furniture); + } + + @Test void invalidRottenAndFullInputsKeepTheHeldFood() { + for (ItemStack input : List.of(new ItemStack(Material.STONE), env.stack(rotten(), Material.CARROT, 3), env.stack(kitchen.food("steak", "meat"), Material.BEEF, 3))) { + ItemStack held = env.hold(input); handler.onInteract(trough.interact()); assertEquals(held, env.player.getInventory().getItemInMainHand()); assertTrue(trough.active.isEmpty()); + } + assertTrue(env.messages.stream().anyMatch(message -> message.contains("Rotten"))); + fill(); ItemStack held = env.hold(env.stack(carrot(), Material.CARROT, 3)); handler.onInteract(trough.interact()); + assertEquals(3, held.getAmount()); assertTrue(env.messages.getLast().contains("full")); assertNull(TroughHandler.findFirstEmpty(trough.furniture)); assertTrue(TroughHandler.isFull(trough.furniture)); + trough.active.put("a", null); assertFalse(TroughHandler.isOccupied(trough.furniture, "a")); + trough.place("a", new ItemStack(Material.AIR)); assertFalse(TroughHandler.isOccupied(trough.furniture, "a")); + } + + @Test void completedFeedConsumesAllFourInputsAndDropsInventoryOverflow() { + fill(); handler.onInteract(trough.interact()); assertTrue(trough.active.isEmpty()); assertEquals(Material.WHEAT, env.player.getInventory().getItem(0).getType()); + verify(env.manager).markDirty(trough.furniture); + env.server.getPluginManager().assertEventFired(DishCookedEvent.class, + event -> event.getResult().getAmount() == 1 && event.getMethod().equals("trough")); + env.player.getInventory().clear(); fill(); + try (MockedStatic delivery = mockStatic(InventoryAdder.class)) { + delivery.when(() -> InventoryAdder.addItem(any(), any())).thenAnswer(inv -> inv.getArgument(1)); handler.onInteract(trough.interact()); + } + assertTrue(trough.active.isEmpty()); assertEquals(1, env.dropped.size()); assertEquals(Material.WHEAT, env.dropped.getFirst().getType()); + } + + @ParameterizedTest @ValueSource(strings = {"null-path", "blank-path", "null", "air", "throw"}) + void failedFeedCreationPreservesAllIngredients(String failure) { + fill(); if (failure.equals("null-path")) ItemCache.troughFeed = null; + else if (failure.equals("blank-path")) ItemCache.troughFeed = " "; + else if (failure.equals("throw")) when(env.api.getCreator().getItemFromPath("feed")).thenThrow(new IllegalArgumentException("Missing feed template")); + else when(env.api.getCreator().getItemFromPath("feed")).thenReturn(failure.equals("air") ? new ItemStack(Material.AIR) : null); + handler.onInteract(trough.interact()); assertEquals(4, trough.active.size()); assertTrue(env.player.getInventory().isEmpty()); verify(env.manager, never()).markDirty(trough.furniture); + } + + @ParameterizedTest @ValueSource(ints = {0, -1}) + void invalidBatchAmountsNeverConsumeOrStoreFood(int count) { + ItemCache.troughItemsPerClick = count; ItemStack held = env.hold(env.stack(carrot(), Material.CARROT, 4)); + handler.onInteract(trough.interact()); assertEquals(4, held.getAmount()); assertTrue(trough.active.isEmpty()); verify(env.manager, never()).markDirty(trough.furniture); + } + + @Test void agingUpdatesFreshFoodAndEjectsUnrecognizedOrRottenContents() { + var fresh = trough.place("a", env.stack(carrot(), Material.CARROT, 3)); trough.place("b", new ItemStack(Material.STONE)); trough.place("c", env.stack(rotten(), Material.CARROT, 3)); trough.active.put("d", null); + Station ignored = kitchen.station("chair"); var unaged = ignored.place("a", fresh.getCurrentItem()); + new TroughAging().start(); env.server.getScheduler().performTicks(20); + assertEquals(2, env.dropped.size()); assertFalse(trough.active.containsKey("b")); assertFalse(trough.active.containsKey("c")); verify(fresh).forceModel(any()); verify(unaged, never()).forceModel(any()); + verify(env.manager).markDirty(trough.furniture); assertEquals(3, fresh.getCurrentItem().getAmount()); + kitchen.updater.when(() -> ItemUpdater.updateItem(any(), any(), any())).thenReturn(null); + trough.place("b", null); trough.place("c", new ItemStack(Material.AIR)); env.server.getScheduler().performTicks(20); verify(env.manager).markDirty(trough.furniture); + } + + @Test void agingEjectsFoodWhoseUpdatedMetadataIsGoneAndHandlesUnavailableDropLocations() { + trough.place("a", env.stack(carrot(), Material.CARROT, 3)); kitchen.updater.when(() -> ItemUpdater.updateItem(any(), any(), any())).thenReturn(new ItemStack(Material.STONE)); + new TroughAging().start(); env.server.getScheduler().performTicks(20); assertFalse(trough.active.containsKey("a")); assertEquals(Material.STONE, env.dropped.getFirst().getType()); + trough.place("a", new ItemStack(Material.STONE)); when(trough.furniture.getLoc()).thenReturn(null); env.server.getScheduler().performTicks(20); assertFalse(trough.active.containsKey("a")); assertEquals(1, env.dropped.size()); + trough.place("a", new ItemStack(Material.STONE)); when(trough.furniture.getLoc()).thenReturn(new Location(null, 0, 0, 0)); env.server.getScheduler().performTicks(20); assertFalse(trough.active.containsKey("a")); assertEquals(1, env.dropped.size()); + } + + @Test void failedAgingRenderingNeverDeletesStoredFood() { + ItemStack original = env.stack(carrot(), Material.CARROT, 3); var slot = trough.place("a", original); + kitchen.updater.when(() -> ItemUpdater.updateItem(any(), any(), any())).thenReturn(new ItemStack(Material.AIR)); + new TroughAging().start(); env.server.getScheduler().performTicks(20); + assertSame(original, slot.getCurrentItem()); assertTrue(trough.active.containsKey("a")); assertTrue(env.dropped.isEmpty()); verify(env.manager, never()).markDirty(trough.furniture); + } +} diff --git a/src/test/java/net/tfminecraft/cooking/utils/FoodParserCoverageTest.java b/src/test/java/net/tfminecraft/cooking/utils/FoodParserCoverageTest.java new file mode 100644 index 0000000..8adf832 --- /dev/null +++ b/src/test/java/net/tfminecraft/cooking/utils/FoodParserCoverageTest.java @@ -0,0 +1,123 @@ +package net.tfminecraft.cooking.utils; + +import static org.junit.jupiter.api.Assertions.*; +import static org.mockito.Mockito.*; +import java.util.ArrayList; +import java.util.List; +import java.util.Locale; +import java.util.Map; +import org.bukkit.Material; +import org.bukkit.configuration.MemoryConfiguration; +import org.bukkit.inventory.ItemStack; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.NullAndEmptySource; +import org.junit.jupiter.params.provider.ValueSource; +import org.mockbukkit.mockbukkit.MockBukkit; +import net.tfminecraft.cooking.Cooking; +import net.tfminecraft.cooking.item.FoodItem; +import net.tfminecraft.cooking.item.IngredientLineage; +import net.tfminecraft.cooking.item.model.FoodModel; +import net.tfminecraft.cooking.item.tag.TagStep; +import net.tfminecraft.cooking.item.tag.TagTrack; +import net.tfminecraft.cooking.loader.FoodLoader; +import net.tfminecraft.cooking.loader.TrackLoader; + +class FoodParserCoverageTest { + List foods; + List tracks; + Cooking previousPlugin; + @BeforeEach void setup() { + MockBukkit.mock(); previousPlugin=Cooking.plugin; Cooking.plugin=mock(Cooking.class); + when(Cooking.plugin.getName()).thenReturn("Cooking"); when(Cooking.plugin.namespace()).thenReturn("cooking"); + foods=new ArrayList<>(FoodLoader.oList); tracks=new ArrayList<>(TrackLoader.oList); + FoodLoader.oList.clear(); TrackLoader.oList.clear(); + for(String id:List.of("bread","sauce")) { + FoodItem food=new FoodItem(id,new MemoryConfiguration()); food.setModel(new FoodModel(new ItemStack(Material.BREAD))); + FoodLoader.oList.add(food); + } + for(String id:List.of("freshness","warmth")) + TrackLoader.oList.add(new TagTrack(id,true,List.of(new TagStep("fresh","Fresh",0,1,1),new TagStep("rotten","Rotten",1000,.5,.5)))); + } + @AfterEach void cleanup() { + FoodLoader.oList.clear(); FoodLoader.oList.addAll(foods); TrackLoader.oList.clear(); TrackLoader.oList.addAll(tracks); + MockBukkit.unmock(); Cooking.plugin=previousPlugin; + } + + @Test void fieldsRetainNestedValuesAndSkipTrailingTextWithoutAnAssignment() { + assertEquals(Map.of("type","bread","sauce","sauce(type=sauce;quality=2)","origin","Wheat"), + FoodParser.extractFields("type=bread;sauce=(sauce(type=sauce;quality=2));origin=Wheat;trailing")); + assertEquals(Map.of("sauce","sauce(type=sauce)","name","Gravy"), + FoodParser.extractFields("sauce=(sauce(type=sauce))name=Gravy")); + assertNotNull(new FoodParser()); + } + + @Test void rangesAndMetadataParseIntoIndependentTemplates() { + var result=FoodParser.parse("bread(type=bread;amount=2-3;quality=2-4;unique=false;origin=Rye;ingredients=Rye:Salt;tags=freshness.5,warmth.25,unknown.2,bad.1.2;ignored=yes)"); + assertNotNull(result); assertFalse(result.unique); assertTrue(result.explicitQuality); + assertEquals("bread",result.template.getCategory()); assertEquals("Rye",result.template.getOrigin()); + assertTrue(result.template.getAmount()>=2&&result.template.getAmount()<=3); + assertEquals(2,result.template.getQualityMin()); assertEquals(4,result.template.getQualityMax()); + assertEquals(2,result.template._parsedQualMin); assertEquals(4,result.template._parsedQualMax); + assertEquals(List.of("Rye","Salt"),result.template.getIngredients()); + assertEquals(5,result.template.getTagTrack("freshness").getValue()); assertFalse(result.template.hasTagTrack("warmth")); + assertNotSame(FoodLoader.getByString("bread"),result.template); + assertEquals(1,FoodLoader.getByString("bread").getAmount()); + assertEquals(64,FoodParser.parse("bread(type=bread;amount=100)").template.getAmount()); + assertEquals(1,FoodParser.parse("bread(type=bread;amount=0)").template.getAmount()); + assertNotNull(FoodParser.parse("bread(type=bread;tags=;ingredients=;sauce=invalid)")); + } + + @Test void bareTagsAndLegacyAgesUseTheRegisteredTracks() { + var parsed=FoodParser.parse("bread(type=bread;tags=freshness,:dairy_freshness.5:warmth.2)"); + assertEquals(20,parsed.template.getTagTrack("freshness").getValue()); + assertEquals(2,parsed.template.getTagTrack("warmth").getValue()); + } + + @Test void nestedSauceAndLineageRoundTripThroughThePublicItemCodec() { + FoodItem item=FoodParser.parse("bread(type=bread;quality=3;origin=Rye;ingredients=Rye:Salt;tags=freshness.5:warmth.2;sauce=(sauce(type=sauce;quality=2));sauce_name=Gravy)").template; + item.setLineage(IngredientLineage.ofMain("Rye").withExtra("Salt")); + String serialized=FoodParser.toString(item,4); + var decoded=FoodParser.parse(serialized); + assertEquals(4,decoded.template.getAmount()); assertEquals(item.getLineage(),decoded.template.getLineage()); + assertEquals("sauce",decoded.template.getSauce().getId()); assertEquals("Gravy",decoded.template.getSauceName()); + assertEquals(List.of("Rye","Salt"),decoded.template.getIngredients()); assertEquals(5,decoded.template.getTagTrack("freshness").getValue()); + ItemStack stack=ItemBuilder.stamp(new ItemStack(Material.BREAD),item,"Dinner"); stack.setAmount(2); + assertEquals(2,FoodParser.parse(FoodParser.toString(stack)).template.getAmount()); + assertNull(FoodParser.toString(new ItemStack(Material.STONE))); + } + + @Test void fieldsCanPrecedeTheTypeWithoutLosingValues() { + var result=FoodParser.parse("bread(quality=3;tags=freshness.5;ingredients=Rye;type=bread;origin=Rye)"); + assertNotNull(result); assertEquals(3,result.template.getQualityMin()); + assertEquals(5,result.template.getTagTrack("freshness").getValue()); assertEquals(List.of("Rye"),result.template.getIngredients()); + } + + @Test void keysAreCaseInsensitiveAcrossServerLocales() { + Locale original=Locale.getDefault(); + try { Locale.setDefault(Locale.forLanguageTag("tr-TR")); + var result=FoodParser.parse("bread(TYPE=bread;QUALITY=3;ORIGIN=Rye)"); + assertNotNull(result); assertTrue(result.explicitQuality); assertEquals(3,result.template.getQualityMin()); + assertEquals("Rye",result.template.getOrigin()); + } finally { Locale.setDefault(original); } + } + + @ParameterizedTest @NullAndEmptySource @ValueSource(strings={" ","bad","bread(","bread(type=bread","bread(type=bread))","bread(type=bread)junk","bread(type=missing)","bread()","bread(quality=3)","bread(type=bread;quality=bad)","bread(type=bread;amount=bad)","bread(type=bread;amount=6-2)","bread(type=bread;quality=4-2)","bread(type=bread;tags=freshness.bad)"}) + void malformedRecipesAreRejectedWithoutThrowing(String recipe) { + assertNull(FoodParser.parse(recipe)); + } + + @Test void anUnknownOptionalTagDoesNotCrashOtherwiseValidRecipes() { + var result=FoodParser.parse("bread(type=bread;tags=removed)"); + assertNotNull(result); assertTrue(result.template.getTagTracks().isEmpty()); + } + + @Test void boundedRangesCannotOverflowRandomSelection() { + assertEquals(64,FoodParser.parse("bread(type=bread;amount=80-100)").template.getAmount()); + var quality=FoodParser.parse("bread(type=bread;quality=2147483647)"); + assertNotNull(quality); + assertEquals(5,FoodItem.fromItem(ItemBuilder.buildSingle(quality.template,null)).getQualityMin()); + } +} diff --git a/src/test/java/net/tfminecraft/cooking/utils/ItemAgingCoverageTest.java b/src/test/java/net/tfminecraft/cooking/utils/ItemAgingCoverageTest.java new file mode 100644 index 0000000..eb77b6a --- /dev/null +++ b/src/test/java/net/tfminecraft/cooking/utils/ItemAgingCoverageTest.java @@ -0,0 +1,149 @@ +package net.tfminecraft.cooking.utils; + +import static org.junit.jupiter.api.Assertions.*; +import static org.mockito.Mockito.*; + +import java.util.ArrayList; +import java.util.List; +import net.tfminecraft.cooking.Cooking; +import net.tfminecraft.cooking.item.FoodItem; +import net.tfminecraft.cooking.item.model.FoodModel; +import net.tfminecraft.cooking.item.tag.AgeScale; +import net.tfminecraft.cooking.item.tag.TagStep; +import net.tfminecraft.cooking.item.tag.TagTrack; +import net.tfminecraft.cooking.loader.FoodLoader; +import net.tfminecraft.cooking.loader.TrackLoader; +import org.bukkit.Material; +import org.bukkit.configuration.MemoryConfiguration; +import org.bukkit.inventory.ItemStack; +import org.bukkit.persistence.PersistentDataType; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.mockbukkit.mockbukkit.MockBukkit; + +class ItemAgingCoverageTest { + Cooking plugin; + List foods; + List tracks; + @BeforeEach void setup() { + MockBukkit.mock(); plugin=Cooking.plugin; Cooking.plugin=mock(Cooking.class); + when(Cooking.plugin.getName()).thenReturn("Cooking"); when(Cooking.plugin.namespace()).thenReturn("cooking"); + foods=new ArrayList<>(FoodLoader.oList); FoodLoader.oList.clear(); + tracks=new ArrayList<>(TrackLoader.oList); TrackLoader.oList.clear(); + TrackLoader.oList.add(new TagTrack("freshness",true,List.of(new TagStep("fresh","Fresh",0,1,1),new TagStep("stale","Stale",4,.8,.8)))); + TrackLoader.oList.add(new TagTrack("warmth",true,List.of(new TagStep("hot","Hot",0,1,1),new TagStep("cold","Cold",20,1,1)))); + } + @AfterEach void cleanup() { + FoodLoader.oList.clear(); FoodLoader.oList.addAll(foods); TrackLoader.oList.clear(); TrackLoader.oList.addAll(tracks); + MockBukkit.unmock(); Cooking.plugin=plugin; + } + + @Test void frequentUpdatesRetainPartialAgingUntilTheNextFreshnessStage() { + MemoryConfiguration config=new MemoryConfiguration(); config.set("age.freshness",2.0); + FoodItem food=food("bread",config); food.addOrModifyTrack(TrackLoader.getByString("freshness")); food.setLastUpdate(10_000); + ItemStack stack=ItemBuilder.stamp(new ItemStack(Material.BREAD),food,"Bread"); + try(var clock=mockStatic(StackNormalizer.class,CALLS_REAL_METHODS)) { + for(int tick=1;tick<=8;tick++) { + clock.when(StackNormalizer::quantizedNow).thenReturn(10_000L+tick*1000L); + stack=ItemUpdater.updateItem(stack,FoodItem.fromItem(stack),null); + assertNotNull(stack); + FoodItem aged=FoodItem.fromItem(stack); + assertEquals(tick/2,aged.getTagTrack("freshness").getValue(),"tick "+tick); + assertEquals(tick%2*.5,aged.getAgeRemainder("freshness"),.00001); + } + } + assertEquals("stale",FoodItem.fromItem(stack).getTagTrack("freshness").getCurrentStep().getId()); + } + + @Test void customTagStageChangesTriggerVisualUpdatesEvenWhenBothMapToCustomEnum() { + FoodItem food=food("custom",new MemoryConfiguration()); + food.addOrModifyTrack(new TagTrack("ferment",true,List.of(new TagStep("phase_one","Young",0,1,1),new TagStep("phase_two","Aged",1,2,1)))); + assertTrue(ItemUpdater.applyAging(food,1)); assertEquals("phase_two",food.getTagTrack("ferment").getCurrentStep().getId()); + } + + @Test void agingHandlesWarmthNestedSaucesAndNonAgeableTracks() { + FoodItem food=food("bread",new MemoryConfiguration()); FoodItem sauce=food("sauce",new MemoryConfiguration()); + food.addOrModifyTrack(TrackLoader.getByString("warmth")); sauce.addOrModifyTrack(TrackLoader.getByString("freshness")); food.setSauce(sauce); + food.addOrModifyTrack(new TagTrack("cooked",false,List.of(new TagStep("cooked","Cooked",0,1,1)))); + food.addOrModifyTrack(new TagTrack("empty",true,List.of())); + assertFalse(ItemUpdater.applyAging(null,1)); assertFalse(ItemUpdater.applyAging(food,0)); + assertTrue(ItemUpdater.applyAging(food,20)); assertNull(food.getTagTrack("warmth")); + assertEquals("stale",food.getSauce().getTagTrack("freshness").getCurrentStep().getId()); + assertEquals(0,food.getTagTrack("cooked").getValue()); + assertFalse(ItemUpdater.applyAging(food,1)); + } + + @Test void updaterPreservesStackAmountAndDisplayNamesAndRebuildsMissingLore() { + FoodItem food=food("bread",new MemoryConfiguration()); + ItemStack stack=new ItemStack(Material.BREAD,3); var meta=stack.getItemMeta(); meta.setDisplayName("Family recipe"); stack.setItemMeta(meta); + food.setModel(new FoodModel(new ItemStack(Material.PAPER))); + ItemStack updated=ItemUpdater.applyItemUpdate(stack,food,null); + assertEquals(3,updated.getAmount()); assertEquals(Material.PAPER,updated.getType()); + assertEquals("Family recipe",updated.getItemMeta().getDisplayName()); assertNotNull(FoodItem.fromItem(updated)); + ItemStack plain=mock(ItemStack.class); when(plain.getType()).thenReturn(Material.BREAD); when(plain.getAmount()).thenReturn(1); + assertEquals("bread",ItemUpdater.applyItemUpdate(plain,food,null).getItemMeta().getDisplayName()); + assertFalse(ItemUpdater.needsLoreRebuild(java.util.Arrays.asList(null,"Nutrition 1","Food 2"),true)); + assertNull(ItemUpdater.applyItemUpdate(null,food,null)); assertNull(ItemUpdater.applyItemUpdate(stack,null,null)); + assertNull(ItemUpdater.updateItem(null,food,null)); assertNull(ItemUpdater.updateItem(stack,null,null)); + assertNull(ItemUpdater.updateItem(new ItemStack(Material.AIR),food,null)); + food.setModel(null); assertEquals("bread",ItemUpdater.applyItemUpdate(new ItemStack(Material.BREAD),food,null).getItemMeta().getDisplayName()); + assertNotNull(new ItemUpdater()); + } + + @Test void fixedFoodsRebuildBrokenLoreButLeaveCompleteItemsUntouched() { + MemoryConfiguration config=new MemoryConfiguration(); config.set("update",false); FoodItem food=food("salt",config); + ItemStack stack=new ItemStack(Material.PAPER); + assertNotNull(ItemUpdater.updateItem(stack,food,null,true)); + assertNull(ItemUpdater.updateItem(stack,food,null,false)); + assertFalse(stack.getItemMeta().getPersistentDataContainer().has(Keys.LAST_UPDATE)); + } + + @Test void heldFoodsWaitForVisibleChangesWhileUnheldFoodsPersistClockUpdates() { + FoodItem food=food("bread",new MemoryConfiguration()); food.addOrModifyTrack(TrackLoader.getByString("freshness")); food.setLastUpdate(10_000); + ItemStack stack=ItemBuilder.stamp(new ItemStack(Material.BREAD),food,"Bread"); + try(var clock=mockStatic(StackNormalizer.class,CALLS_REAL_METHODS)) { + clock.when(StackNormalizer::quantizedNow).thenReturn(11_000L); + assertNull(ItemUpdater.updateItem(stack,FoodItem.fromItem(stack),null,true)); + clock.when(StackNormalizer::quantizedNow).thenReturn(14_000L); + stack=ItemUpdater.updateItem(stack,FoodItem.fromItem(stack),null,true); assertNotNull(stack); + assertEquals("stale",FoodItem.fromItem(stack).getTagTrack("freshness").getCurrentStep().getId()); + assertNull(ItemUpdater.updateItem(stack,FoodItem.fromItem(stack),null,false)); + clock.when(StackNormalizer::quantizedNow).thenReturn(15_000L); + stack=ItemUpdater.updateItem(stack,FoodItem.fromItem(stack),null,false); assertNotNull(stack); + var meta=stack.getItemMeta(); meta.getPersistentDataContainer().remove(Keys.LAST_UPDATE); stack.setItemMeta(meta); + assertNotNull(ItemUpdater.updateItem(stack,FoodItem.fromItem(stack),null,false)); + } + food.setSauce(food("sauce",new MemoryConfiguration())); food.getSauce().addOrModifyTrack(TrackLoader.getByString("warmth")); + food.getSauce().getTagTrack("warmth").forceSetValue(20); + assertNotNull(ItemUpdater.updateItem(new ItemStack(Material.PAPER),food,null,true)); + assertNull(food.getSauce().getTagTrack("warmth")); + } + + @Test void ageScalingSaturatesInsteadOfWrappingAndRejectsInfiniteDurations() { + assertEquals(Integer.MAX_VALUE,AgeScale.apply(Integer.MAX_VALUE-1,10,1,0).value()); + assertEquals(Integer.MAX_VALUE,AgeScale.apply(5,Long.MAX_VALUE,.1,0).value()); + assertEquals(1,AgeScale.clamp(Double.POSITIVE_INFINITY)); + assertEquals(Integer.MAX_VALUE,AgeScale.migrateTrackValue("dairy_freshness",Integer.MAX_VALUE)); + } + + @Test void unavailableModelsDoNotDestroyAnExistingItemOrProduceAnInvalidNewItem() { + var api=mock(net.tfminecraft.tlibs.objects.api.ItemAPI.class,RETURNS_DEEP_STUBS); + when(api.getCreator().getItemFromPath("missing.model")).thenReturn(null); + try(var tlibs=mockStatic(net.tfminecraft.tlibs.TLibs.class)) { + tlibs.when(net.tfminecraft.tlibs.TLibs::getItemAPI).thenReturn(api); + MemoryConfiguration modelConfig=new MemoryConfiguration(); modelConfig.set("raw.gui.item","missing.model"); + FoodItem food=food("bread",new MemoryConfiguration()); food.setModel(new FoodModel("missing",modelConfig)); + ItemStack input=new ItemStack(Material.BREAD,3); + assertEquals(Material.BREAD,ItemUpdater.applyItemUpdate(input,food,null).getType()); + assertNull(ItemBuilder.buildSingleWithQuality(food,2)); + food.setModel(new FoodModel("empty",new MemoryConfiguration())); + assertNull(ItemBuilder.buildSingleWithQuality(food,2)); + food.setModel(null); assertNull(ItemBuilder.buildSingleWithQuality(food,2)); + } + } + + private FoodItem food(String id,MemoryConfiguration config) { + config.set("name",id); FoodItem food=new FoodItem(id,config); FoodLoader.oList.add(food); return food; + } +} diff --git a/src/test/java/net/tfminecraft/cooking/utils/ItemBuilderCoverageTest.java b/src/test/java/net/tfminecraft/cooking/utils/ItemBuilderCoverageTest.java new file mode 100644 index 0000000..b1c51bc --- /dev/null +++ b/src/test/java/net/tfminecraft/cooking/utils/ItemBuilderCoverageTest.java @@ -0,0 +1,225 @@ +package net.tfminecraft.cooking.utils; + +import static org.junit.jupiter.api.Assertions.*; +import static org.mockito.ArgumentMatchers.*; +import static org.mockito.Mockito.*; + +import java.util.ArrayList; +import java.util.HashMap; +import java.util.List; +import java.util.Locale; +import java.util.Map; +import java.nio.file.Path; +import java.nio.file.Files; +import org.bukkit.Material; +import org.bukkit.Sound; +import org.bukkit.World; +import org.bukkit.Location; +import org.bukkit.configuration.MemoryConfiguration; +import org.bukkit.entity.Player; +import org.bukkit.inventory.ItemStack; +import org.bukkit.persistence.PersistentDataType; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.io.TempDir; +import org.mockbukkit.mockbukkit.MockBukkit; +import org.mockbukkit.mockbukkit.ServerMock; +import org.mockito.MockedStatic; +import net.tfminecraft.cooking.Cooking; +import net.tfminecraft.cooking.enums.Method; +import net.tfminecraft.cooking.item.FoodItem; +import net.tfminecraft.cooking.item.data.OverrideData; +import net.tfminecraft.cooking.item.model.FoodModel; +import net.tfminecraft.cooking.item.tag.TagStep; +import net.tfminecraft.cooking.item.tag.TagTrack; +import net.tfminecraft.cooking.loader.FoodLoader; +import net.tfminecraft.cooking.loader.TrackLoader; +import net.tfminecraft.cooking.loader.CarveSequenceLoader; +import net.tfminecraft.cooking.quality.QualityConfig; +import net.tfminecraft.cooking.quality.OriginQualityResolver; + +class ItemBuilderCoverageTest { + ServerMock server; + Cooking previousPlugin; + List previousFoods; + List previousTracks; + @TempDir Path temporary; + + @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"); + previousFoods=new ArrayList<>(FoodLoader.oList); FoodLoader.oList.clear(); + previousTracks=new ArrayList<>(TrackLoader.oList); TrackLoader.oList.clear(); + QualityConfig.apply(3,3,null,null); + } + @AfterEach void cleanup() { + FoodLoader.oList.clear(); FoodLoader.oList.addAll(previousFoods); + TrackLoader.oList.clear(); TrackLoader.oList.addAll(previousTracks); + QualityConfig.apply(1,5,null,null); MockBukkit.unmock(); Cooking.plugin=previousPlugin; + } + + @Test void batchBuildsEitherIndependentItemsOrOneRequestedStack() { + FoodItem template=food("bread","Bread"); template.setAmount(3); template.setQualityRange(2,2); + List separate=ItemBuilder.build(template,true,null); + assertEquals(3,separate.size()); + assertNotSame(separate.get(0),separate.get(1)); + for(ItemStack stack:separate) { assertEquals(1,stack.getAmount()); assertEquals(2,FoodItem.fromItem(stack).getQualityMin()); } + List combined=ItemBuilder.build(template,false,null); + assertEquals(1,combined.size()); assertEquals(3,combined.getFirst().getAmount()); + assertEquals(3,template.getAmount()); + template._parsedQualMin=4; template._parsedQualMax=4; + assertEquals(4,FoodItem.fromItem(ItemBuilder.buildSingle(template,null)).getQualityMin()); + assertEquals(5,FoodItem.fromItem(ItemBuilder.buildSingleWithQuality(template,5)).getQualityMin()); + assertNotNull(new ItemBuilder()); + } + + @Test void namesUseOriginOverridesAndInheritedDisplayNamesWithoutChangingBaseItems() { + FoodItem template=food("bread","{inherit} loaf"); + ItemStack base=new ItemStack(Material.PAPER); var meta=base.getItemMeta(); meta.setDisplayName("Rye"); base.setItemMeta(meta); + assertEquals("Rye loaf",ItemBuilder.buildSingleWithQuality(template,base,2).getItemMeta().getDisplayName()); + template.setOrigin("wheat"); + assertEquals("Wheat loaf",ItemBuilder.buildSingleWithQuality(template,2).getItemMeta().getDisplayName()); + template.setOrigin(null); + assertEquals("Unknown loaf",ItemBuilder.buildSingleWithQuality(template,2).getItemMeta().getDisplayName()); + template.setOrigin("fish"); template.getOverrides().put("FISH",new OverrideData("Fish pie",null,null)); + assertEquals("Fish pie",ItemBuilder.buildSingleWithQuality(template,2).getItemMeta().getDisplayName()); + template.getOverrides().put("FISH",new OverrideData(null,null,null)); + assertEquals("Fish loaf",ItemBuilder.buildSingleWithQuality(template,2).getItemMeta().getDisplayName()); + template.setModel(null); + assertEquals(Material.PAPER,ItemBuilder.buildSingleWithQuality(template,base,2).getType()); + assertEquals("Rye",base.getItemMeta().getDisplayName()); + } + + @Test void displayOverridesDoNotDependOnTheServerLocale() { + FoodItem template=food("bread","Bread"); template.setOrigin("fish"); + template.getOverrides().put("FISH",new OverrideData("Fish pie",null,null)); + Locale original=Locale.getDefault(); + try { Locale.setDefault(Locale.forLanguageTag("tr-TR")); + assertEquals("Fish pie",ItemBuilder.buildSingleWithQuality(template,2).getItemMeta().getDisplayName()); + } finally { Locale.setDefault(original); } + } + + @Test void compositionUsesItsIngredientNameTemplate() { + FoodItem template=food("bread","{fillers}Bread"); template.addIngredient("Rye"); + assertEquals("Rye Bread",ItemBuilder.buildComposedWithQuality(template,3).getItemMeta().getDisplayName()); + } + + @Test void restampingRemovesObsoleteMetadataAndDoesNotRequireAnItemName() { + FoodItem template=food("bread","Bread"); template.setCategory(null); + ItemStack stack=new ItemStack(Material.BREAD); var meta=stack.getItemMeta(); var p=meta.getPersistentDataContainer(); + for(var key:List.of(Keys.ORIGIN,Keys.TAGS,Keys.SAUCE,Keys.SAUCE_NAME,Keys.INGREDIENTS,Keys.LINEAGE,Keys.AGE_REMAINDER, + Keys.SEAFOOD_CUT_TYPE,Keys.CUSTOM_FISHING_ID,Keys.COOK_METHOD)) p.set(key,PersistentDataType.STRING,"old"); + p.set(Keys.BASE_FOOD,PersistentDataType.DOUBLE,99.0); p.set(Keys.BASE_NUTRITION,PersistentDataType.DOUBLE,99.0); + p.set(Keys.CATCH_SIZE_CM,PersistentDataType.INTEGER,99); p.set(Keys.COOK_TIME,PersistentDataType.INTEGER,99); + stack.setItemMeta(meta); + assertSame(stack,ItemBuilder.stamp(stack,template,null)); + p=stack.getItemMeta().getPersistentDataContainer(); + for(var key:List.of(Keys.ORIGIN,Keys.TAGS,Keys.SAUCE,Keys.SAUCE_NAME,Keys.INGREDIENTS,Keys.LINEAGE,Keys.AGE_REMAINDER, + Keys.SEAFOOD_CUT_TYPE,Keys.CUSTOM_FISHING_ID,Keys.COOK_METHOD,Keys.BASE_FOOD,Keys.BASE_NUTRITION,Keys.CATCH_SIZE_CM,Keys.COOK_TIME)) + assertFalse(p.has(key),key.toString()); + assertEquals("",p.get(Keys.CATEGORY,PersistentDataType.STRING)); + assertTrue(p.has(Keys.LAST_UPDATE)); assertTrue(p.has(Keys.LORE_INDEX_MAP)); + assertSame(stack,ItemBuilder.stamp(stack,null,null)); assertNull(ItemBuilder.stamp(null,template,null)); + assertEquals(Material.AIR,ItemBuilder.stamp(new ItemStack(Material.AIR),template,"Food").getType()); + MemoryConfiguration fixed=new MemoryConfiguration(); fixed.set("update",false); + FoodItem salt=new FoodItem("salt",fixed); ItemBuilder.stamp(stack,salt,"Salt"); + assertFalse(stack.getItemMeta().getPersistentDataContainer().has(Keys.LAST_UPDATE)); + assertFalse(ItemBuilder.writesAgeClock(null)); + } + + @Test void loreContainsWrappedIngredientsSauceAndOnlyVisibleTags() { + FoodItem food=food("bread","Bread"); food.addIngredient("Long garden carrot"); food.addIngredient("Long garden onion"); + FoodItem sauce=food("sauce","Sauce"); food.setSauce(sauce); food.setSauceName("Gravy"); + TagTrack named=track("freshness","fresh","Fresh",1,1,0); + food.addOrModifyTrack(named); + List lore=ItemBuilder.buildLore(food); + assertTrue(lore.stream().anyMatch(s->s.contains("Long garden carrot"))); + assertTrue(lore.stream().anyMatch(s->s.contains("Long garden onion"))); + assertTrue(lore.stream().anyMatch(s->s.contains("Gravy"))); + food.setSauceName(null); assertTrue(ItemBuilder.buildLore(food).stream().anyMatch(s->s.contains("Sauce"))); + food.addOrModifyTrack(track("processing","processed","Processed",1,1,0)); + assertFalse(ItemBuilder.buildLore(food).stream().anyMatch(s->s.contains("Origin:"))); + TagTrack silent=track("silent","custom","",1,1,0); + assertFalse(ItemBuilder.shouldShowTagLore(null,silent,silent.getCurrentStep())); + TagTrack foodChange=track("food","custom","",2,1,0); + assertTrue(ItemBuilder.shouldShowTagLore(null,foodChange,foodChange.getCurrentStep())); + TagTrack nutritionChange=track("nutrition","custom","",1,2,0); + assertTrue(ItemBuilder.shouldShowTagLore(null,nutritionChange,nutritionChange.getCurrentStep())); + TagTrack craftChange=track("craft","custom","",1,1,.5); + assertTrue(ItemBuilder.shouldShowTagLore(null,craftChange,craftChange.getCurrentStep())); + TagTrack colourOnly=track("colour","custom","#abcdef ",1,1,0); + assertFalse(ItemBuilder.shouldShowTagLore(null,colourOnly,colourOnly.getCurrentStep())); + TagTrack nonHex=track("nonhex","custom","#xxxxxxName",1,1,0); + assertTrue(ItemBuilder.shouldShowTagLore(null,nonHex,nonHex.getCurrentStep())); + Map indexes=new HashMap<>(); + List assembled=ItemBuilder.assembleLore("Bread",null,3,"Nutrition","Food",null,null,null,List.of(silent,named),indexes); + assertTrue(indexes.containsKey("tags")); assertFalse(assembled.contains("Origin:")); + assertFalse(ItemBuilder.assembleLore("Bread",null,3,"Nutrition","Food",null,null,null,null,null).isEmpty()); + assertEquals("§6★§7☆§7☆§7☆§7☆",ItemBuilder.qualityStars(-10)); + assertEquals("§6★§6★§6★§6★§6★",ItemBuilder.qualityStars(20)); + } + + @Test void commandsParseQualityAndBatchSizeAndPlayPickupSoundOnlyOnce() { + FoodItem template=food("bread","Bread"); + Player player=mock(Player.class); World world=mock(World.class); Location location=new Location(world,0,60,0); + when(player.getWorld()).thenReturn(world); when(player.getLocation()).thenReturn(location); + try(MockedStatic adder=mockStatic(InventoryAdder.class)) { + adder.when(()->InventoryAdder.addItem(eq(player),any())).thenReturn(null); + ItemBuilder.buildFromString(player,"bread(type=bread;amount=2;quality=4;unique=true)",null); + verify(player,times(1)).playSound(location,Sound.ENTITY_ITEM_PICKUP,1f,1f); + adder.verify(()->InventoryAdder.addItem(eq(player),any()),times(2)); + } + clearInvocations(player); + try(MockedStatic adder=mockStatic(InventoryAdder.class); + MockedStatic origin=mockStatic(OriginQualityResolver.class)) { + origin.when(()->OriginQualityResolver.resolve(eq(player),any())).thenReturn(3); + ItemStack leftover=new ItemStack(Material.BREAD,2); + adder.when(()->InventoryAdder.addItem(eq(player),any())).thenReturn(leftover); + ItemBuilder.buildFromString(player,"bread(type=bread;amount=2;unique=false)",null); + verify(world).dropItemNaturally(location,leftover); + verify(player,never()).playSound(any(Location.class),any(Sound.class),anyFloat(),anyFloat()); + origin.verify(()->OriginQualityResolver.resolve(eq(player),any()),times(1)); + ItemBuilder.buildFromString(player,"bread(type=bread;amount=2;unique=true)",null); + origin.verify(()->OriginQualityResolver.resolve(eq(player),any()),times(3)); + } + ItemBuilder.buildFromString(player,"invalid",null); verify(player).sendMessage("§cInvalid item string!"); + assertEquals(4,FoodItem.fromItem(ItemBuilder.buildSingleString("bread(type=bread;quality=4)",null)).getQualityMin()); + assertEquals(3,FoodItem.fromItem(ItemBuilder.buildSingleString("bread(type=bread)",null)).getQualityMin()); + } + + @Test void invalidSingleItemRequestIsRejectedWithoutThrowing() { + assertNull(ItemBuilder.buildSingleString("not_a_food",null)); + assertNull(ItemBuilder.buildSingleString("bread(type=missing)",null)); + } + + @Test void configuredRoastsStartAtTheirFirstCarveStage() throws Exception { + Path sequences=temporary.resolve("carve.yml"); + Files.writeString(sequences,"roast:\n start-remaining: 2\n cuts:\n - output: bread\n food: 2\n nutrition: 1\n - item: v.bone\n"); + new CarveSequenceLoader().load(sequences.toFile()); + MemoryConfiguration config=new MemoryConfiguration(); config.set("carve-sequence","roast"); + FoodItem template=new FoodItem("roast",config); template.setModel(new FoodModel(new ItemStack(Material.BEEF))); FoodLoader.oList.add(template); + FoodItem restored=FoodItem.fromItem(ItemBuilder.buildSingleWithQuality(template,2)); + assertEquals("roast",restored.getCarveSequencePdc()); assertEquals(0,restored.getCarveNextIndex()); assertEquals(2,restored.getCarveRemaining()); + template.getOverrides().put("UNKNOWN",new OverrideData(null,null,"missing")); template.setOrigin("unknown"); + assertFalse(FoodItem.fromItem(ItemBuilder.buildSingleWithQuality(template,2)).hasCarveState()); + Files.writeString(sequences,""); new CarveSequenceLoader().load(sequences.toFile()); + } + + @Test void anEmptyTagTrackDoesNotPreventRenderingFoodLore() { + FoodItem template=food("bread","Bread"); + template.addOrModifyTrack(new TagTrack("unconfigured",false,List.of())); + assertTrue(template.getCurrentTags().isEmpty()); assertTrue(template.getCurrentTagIds().isEmpty()); + assertDoesNotThrow(()->ItemBuilder.buildSingleWithQuality(template,2)); + } + + private FoodItem food(String id,String name) { + MemoryConfiguration config=new MemoryConfiguration(); config.set("name",name); config.set("food",2); config.set("nutrition",2); + FoodItem food=new FoodItem(id,config); food.setCategory(id); food.setModel(new FoodModel(new ItemStack(Material.BREAD))); + FoodLoader.oList.add(food); return food; + } + private static TagTrack track(String id,String step,String name,double food,double nutrition,double craft) { + return new TagTrack(id,false,List.of(new TagStep(step,name,0,food,nutrition,0,craft))); + } +} diff --git a/src/test/java/net/tfminecraft/cooking/utils/RuntimeUtilitiesCoverageTest.java b/src/test/java/net/tfminecraft/cooking/utils/RuntimeUtilitiesCoverageTest.java new file mode 100644 index 0000000..6effcb7 --- /dev/null +++ b/src/test/java/net/tfminecraft/cooking/utils/RuntimeUtilitiesCoverageTest.java @@ -0,0 +1,193 @@ +package net.tfminecraft.cooking.utils; + +import static org.junit.jupiter.api.Assertions.*; +import static org.mockito.ArgumentMatchers.*; +import static org.mockito.Mockito.*; + +import java.util.ArrayList; +import java.util.Arrays; +import java.util.HashMap; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; +import net.tfminecraft.cooking.Cooking; +import net.tfminecraft.cooking.item.FoodItem; +import net.tfminecraft.cooking.item.tag.TagStep; +import net.tfminecraft.cooking.item.tag.TagTrack; +import net.tfminecraft.cooking.loader.FoodLoader; +import net.tfminecraft.cooking.loader.TrackLoader; +import net.tfminecraft.cooking.util.LegacyModelData; +import net.tfminecraft.interactiblefurniture.furniture.Furniture; +import net.tfminecraft.interactiblefurniture.furniture.PlacedSlot; +import org.bukkit.Material; +import org.bukkit.NamespacedKey; +import org.bukkit.configuration.MemoryConfiguration; +import org.bukkit.entity.Player; +import org.bukkit.inventory.ItemStack; +import org.bukkit.inventory.PlayerInventory; +import org.bukkit.persistence.PersistentDataType; +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; + +class RuntimeUtilitiesCoverageTest { + ServerMock server; + Cooking plugin; + List foods; + List tracks; + @BeforeEach void setup() { + server=MockBukkit.mock(); plugin=Cooking.plugin; Cooking.plugin=mock(Cooking.class); + when(Cooking.plugin.getName()).thenReturn("Cooking"); when(Cooking.plugin.namespace()).thenReturn("cooking"); + foods=new ArrayList<>(FoodLoader.oList); FoodLoader.oList.clear(); tracks=new ArrayList<>(TrackLoader.oList); TrackLoader.oList.clear(); + } + @AfterEach void cleanup() { + FoodLoader.oList.clear(); FoodLoader.oList.addAll(foods); TrackLoader.oList.clear(); TrackLoader.oList.addAll(tracks); + MockBukkit.unmock(); Cooking.plugin=plugin; + } + + @Test void inventoryAdditionMergesEquivalentFoodsAndReturnsUnstoredRemainders() { + Player player=mock(Player.class); PlayerInventory inventory=mock(PlayerInventory.class); when(player.getInventory()).thenReturn(inventory); + when(inventory.getSize()).thenReturn(6); when(inventory.getMaxStackSize()).thenReturn(64); + FoodItem bread=food("bread"); ItemStack full=stack(bread,Material.BREAD,64), partial=stack(bread,Material.BREAD,62); + when(inventory.getItem(0)).thenReturn(new ItemStack(Material.STONE)); when(inventory.getItem(1)).thenReturn(full); + when(inventory.getItem(2)).thenReturn(stack(food("other"),Material.BREAD,1)); when(inventory.getItem(4)).thenReturn(partial); + ItemStack input=stack(bread,Material.BREAD,4); when(inventory.addItem(any(ItemStack.class))).thenAnswer(call->{ + ItemStack remaining=call.getArgument(0); assertEquals(2,remaining.getAmount()); return new HashMap<>(Map.of(0,remaining)); }); + assertEquals(2,InventoryAdder.addItem(player,input).getAmount()); assertEquals(64,partial.getAmount()); assertEquals(4,input.getAmount()); + partial.setAmount(60); assertNull(InventoryAdder.addItem(player,input)); assertEquals(64,partial.getAmount()); + when(inventory.addItem(any(ItemStack.class))).thenReturn(new HashMap<>()); + assertNull(InventoryAdder.addItem(player,input)); + ItemStack ordinary=new ItemStack(Material.STONE); assertNull(InventoryAdder.addItem(player,ordinary)); + when(inventory.addItem(ordinary)).thenReturn(new HashMap<>(Map.of(0,ordinary))); assertSame(ordinary,InventoryAdder.addItem(player,ordinary)); + assertNotNull(new InventoryAdder()); + } + + @Test void foodMergingRespectsTheItemsMaximumStackSize() { + var player=server.addPlayer(); FoodItem milk=food("milk"); + player.getInventory().setItem(0,stack(milk,Material.POTION,1)); + assertNull(InventoryAdder.addItem(player,stack(milk,Material.POTION,1))); + assertEquals(1,player.getInventory().getItem(0).getAmount()); assertEquals(1,player.getInventory().getItem(1).getAmount()); + } + + @Test void foodEqualityRejectsDifferentRecipesTagsOriginsQualityAndSauces() { + FoodItem first=food("bread"), second=new FoodItem(first); assertTrue(InventoryAdder.equalsFood(first,second)); + second.setCategory("other"); assertFalse(InventoryAdder.equalsFood(first,second)); second.setCategory(first.getCategory()); + assertFalse(InventoryAdder.equalsFood(first,food("different"))); + second.setOrigin("Rye"); assertFalse(InventoryAdder.equalsFood(first,second)); first.setOrigin("RYE"); assertTrue(InventoryAdder.equalsFood(first,second)); + second.setOrigin("Wheat"); assertFalse(InventoryAdder.equalsFood(first,second)); second.setOrigin("rye"); + second.setQualityRange(5,5); assertFalse(InventoryAdder.equalsFood(first,second)); second.setQualityRange(2,2); + first.addOrModifyTrack(track("freshness","fresh",false)); assertFalse(InventoryAdder.equalsFood(first,second)); + second.addOrModifyTrack(track("freshness","rotten",false)); assertFalse(InventoryAdder.equalsFood(first,second)); + second.addOrModifyTrack(track("freshness","fresh",false)); assertTrue(InventoryAdder.equalsFood(first,second)); + second.setSauce(food("sauce")); assertFalse(InventoryAdder.equalsFood(first,second)); + second.setSauce(null); first.setSauce(food("sauce")); assertFalse(InventoryAdder.equalsFood(first,second)); + } + + @Test void differentCarveProgressOrFoodBudgetsCannotMerge() { + var player=server.addPlayer(); + FoodItem first=food("roast"), second=new FoodItem(first); + first.setCarveState("roast",0,3); second.setCarveState("roast",1,2); + player.getInventory().setItem(0,stack(first,Material.BEEF,1)); + assertNull(InventoryAdder.addItem(player,stack(second,Material.BEEF,1))); + assertEquals(1,player.getInventory().getItem(0).getAmount()); assertEquals(1,player.getInventory().getItem(1).getAmount()); + assertEquals(3,FoodItem.fromItem(player.getInventory().getItem(0)).getCarveRemaining()); + assertEquals(2,FoodItem.fromItem(player.getInventory().getItem(1)).getCarveRemaining()); + player.getInventory().clear(); + FoodItem meal=food("meal"), other=new FoodItem(meal); meal.setBaseFood(10); other.setBaseFood(1); + player.getInventory().setItem(0,stack(meal,Material.BREAD,1)); + assertNull(InventoryAdder.addItem(player,stack(other,Material.BREAD,1))); + assertEquals(1,player.getInventory().getItem(0).getAmount()); assertEquals(1,player.getInventory().getItem(1).getAmount()); + } + + @Test void slotEncodingRoundTripsResourceLocationsAndSkipsMalformedEntries() { + Furniture furniture=mock(Furniture.class); Map slots=new LinkedHashMap<>(); when(furniture.getActiveSlots()).thenReturn(slots); + var meta=mock(org.bukkit.inventory.meta.ItemMeta.class); + var component=mock(org.bukkit.inventory.meta.components.CustomModelDataComponent.class); + when(meta.getCustomModelDataComponent()).thenReturn(component); when(component.getFloats()).thenReturn(List.of(23f)); + when(meta.hasItemModel()).thenReturn(true); when(meta.getItemModel()).thenReturn(new NamespacedKey("cooking","dish")); + ItemStack model=mock(ItemStack.class); when(model.getType()).thenReturn(Material.PAPER); when(model.getItemMeta()).thenReturn(meta); + PlacedSlot first=mock(PlacedSlot.class), empty=mock(PlacedSlot.class), air=mock(PlacedSlot.class); + when(first.getCurrentItem()).thenReturn(model); when(air.getCurrentItem()).thenReturn(new ItemStack(Material.AIR)); + slots.put("first",first); slots.put("empty",empty); slots.put("air",air); + String encoded=Encoder.getEncodedSlots(furniture); assertEquals("first.PAPER.23.cooking~dish:air.AIR.0",encoded); + when(component.getFloats()).thenReturn(List.of()); assertTrue(Encoder.getEncodedSlots(furniture).startsWith("first.PAPER.0")); + when(meta.hasItemModel()).thenReturn(false); assertTrue(Encoder.getEncodedSlots(furniture).startsWith("first.PAPER.0:")); + try(var stacks=mockConstruction(ItemStack.class,(stack,context)->{ + Material material=(Material)context.arguments().getFirst(); when(stack.getType()).thenReturn(material); + if(material.isAir()) return; + var decodedMeta=mock(org.bukkit.inventory.meta.ItemMeta.class); + var decodedComponent=mock(org.bukkit.inventory.meta.components.CustomModelDataComponent.class); + when(decodedMeta.getCustomModelDataComponent()).thenReturn(decodedComponent); + var itemModel=new java.util.concurrent.atomic.AtomicReference(); + doAnswer(call->{ itemModel.set(call.getArgument(0)); return null; }).when(decodedMeta).setItemModel(any()); + when(decodedMeta.getItemModel()).thenAnswer(call->itemModel.get()); when(stack.getItemMeta()).thenReturn(decodedMeta); + })) { + Map decoded=Encoder.decodeSlots(encoded); + assertEquals(new NamespacedKey("cooking","dish"),decoded.get("first").getItemMeta().getItemModel()); assertEquals(Material.AIR,decoded.get("air").getType()); + verify(decoded.get("first").getItemMeta().getCustomModelDataComponent()).setFloats(List.of(23f)); + assertTrue(Encoder.decodeSlots(null).isEmpty()); assertTrue(Encoder.decodeSlots("").isEmpty()); + decoded=Encoder.decodeSlots(":bad:.PAPER.2:slot.NOT_A_MATERIAL.2:good.PAPER.bad.INVALID~KEY"); + assertEquals(1,decoded.size()); assertEquals(Material.PAPER,decoded.get("good").getType()); + } + assertNotNull(new Encoder()); assertNotNull(new Keys()); + assertEquals("custom_model",Encoder.parseSlot("slot.PAPER.0.custom_model").itemModel()); + assertFalse(LegacyModelData.has(meta)); assertThrows(IllegalStateException.class,()->LegacyModelData.get(meta)); + LegacyModelData.set(meta,null); verify(meta).setCustomModelDataComponent(null); + LegacyModelData.set(meta,7); verify(component).setFlags(List.of()); verify(component).setStrings(List.of()); verify(component).setColors(List.of()); + } + + @Test void qualityHelpersUseFoodAndSeedMetadataWithSafeDefaults() { + assertEquals(1,QualityUtils.average((int[])null)); assertEquals(1,QualityUtils.average()); assertEquals(3,QualityUtils.average(2,3)); + assertEquals(5,QualityUtils.fromStack(null,10)); assertEquals(1,QualityUtils.fromStack(new ItemStack(Material.STONE),-1)); + assertEquals(2,QualityUtils.fromStack(stack(food("bread"),Material.BREAD,1),1)); + assertEquals(1,QualityUtils.fromSeedStack(null)); assertEquals(1,QualityUtils.fromSeedStack(new ItemStack(Material.AIR))); + assertEquals(1,QualityUtils.fromSeedStack(new ItemStack(Material.WHEAT_SEEDS))); + ItemStack seeds=new ItemStack(Material.WHEAT_SEEDS); var meta=seeds.getItemMeta(); meta.getPersistentDataContainer().set(Keys.QUALITY,PersistentDataType.INTEGER,4); seeds.setItemMeta(meta); + assertEquals(4,QualityUtils.fromSeedStack(seeds)); assertEquals(2,QualityUtils.fromSeedStack(stack(food("seed"),Material.WHEAT_SEEDS,1))); + } + + @Test void normalizationHandlesSaucesEmptyTracksAndItemsThatNeverAge() { + StackNormalizer.normalize(null); assertFalse(StackNormalizer.needsNormalize(null)); + MemoryConfiguration fixed=new MemoryConfiguration(); fixed.set("update",false); FoodItem salt=new FoodItem("salt",fixed); + StackNormalizer.normalize(salt); assertFalse(StackNormalizer.needsNormalize(salt)); + FoodItem main=food("main"), sauce=food("sauce"); main.setSauce(sauce); + main.addOrModifyTrack(new TagTrack("empty",true,List.of())); main.addOrModifyTrack(track("cooked","cooked",false)); + sauce.addOrModifyTrack(track("freshness","fresh",true)); sauce.getTagTrack("freshness").forceSetValue(5); + assertTrue(StackNormalizer.needsNormalize(main)); StackNormalizer.normalize(main); assertFalse(StackNormalizer.needsNormalize(main)); + assertEquals(0,sauce.getTagTrack("freshness").getValue()); + DoughRising.ensureUnrisen(null); DoughRising.ensureUnrisen(main); + FoodItem dough=food("dough"); DoughRising.ensureUnrisen(dough); assertFalse(dough.hasTagTrack("rising")); + TrackLoader.oList.add(track("rising","unrisen",true)); DoughRising.ensureUnrisen(dough); assertTrue(dough.hasTagTrack("rising")); + dough.getTagTrack("rising").forceSetValue(10); DoughRising.ensureUnrisen(dough); assertEquals(10,dough.getTagTrack("rising").getValue()); + assertFalse(WarmthUtils.applyHot(main)); TrackLoader.oList.add(track("warmth","hot",true)); + assertFalse(WarmthUtils.applyHot(null)); assertTrue(WarmthUtils.applyHot(main)); + assertFalse(WarmthUtils.expireIfRoomTemp(null)); assertFalse(WarmthUtils.expireIfRoomTemp(sauce)); assertFalse(WarmthUtils.expireIfRoomTemp(main)); + main.getTagTrack("warmth").forceSetValue(20); assertTrue(WarmthUtils.expireIfRoomTemp(main)); + main.addOrModifyTrack(new TagTrack("warmth",true,List.of())); main.getTagTrack("warmth").forceSetValue(20); assertTrue(WarmthUtils.expireIfRoomTemp(main)); + assertTrue(WarmthUtils.isHeated(main,0)); assertFalse(WarmthUtils.isHeated(main,1)); assertFalse(WarmthUtils.isHeated(sauce,0)); + } + + @Test void displayHelpersPresentPositiveNegativeAndNeutralModifiers() { + assertEquals("Fresh",DisplayUtils.getDisplayString("Fresh",1,1)); + assertTrue(DisplayUtils.getDisplayString("Fresh",.5,1.5,.1,2).contains("-50% Food")); + assertTrue(DisplayUtils.getDisplayString("Fresh",1.5,.5,0,-1).contains("-1★")); + assertTrue(DisplayUtils.getDisplayString("Fresh",1,1,.2).contains("Craft Quality +20%")); + assertTrue(DisplayUtils.getDisplayString("Fresh",1,1,0,2).contains("+2★")); + assertEquals("#000000",DisplayUtils.getMergedColour(List.of())); assertEquals("#abcdef",DisplayUtils.getMergedColour(List.of("#abcdef"))); + assertEquals("#7f7f7f",DisplayUtils.getMergedColour(List.of("000000","ffffff"))); + assertEquals("&f",DisplayUtils.getNameColour(null)); assertEquals("&f",DisplayUtils.getNameColour("#000000")); assertEquals("#ffffff",DisplayUtils.getNameColour("#ffffff")); + assertEquals("§7No effect",DisplayUtils.getSauceStatString(0,0)); + assertEquals("§a+1.0 Food§7, §c-2.0 Nutrition",DisplayUtils.getSauceStatString(1,-2)); + assertEquals("§c-1.0 Food§7, §a+2.0 Nutrition",DisplayUtils.getSauceStatString(-1,2)); + assertEquals("§a+2.0 Nutrition",DisplayUtils.getSauceStatString(0,2)); assertNotNull(new DisplayUtils()); + } + + private FoodItem food(String id) { FoodItem item=new FoodItem(id,new MemoryConfiguration()); item.setCategory("food"); item.setQualityRange(2,2); FoodLoader.oList.add(item); return item; } + private ItemStack stack(FoodItem food,Material material,int amount) { + ItemStack stack=ItemBuilder.stamp(new ItemStack(material,amount),food,food.getId()); + net.tfminecraft.cooking.carve.CarvableRoastUtils.writeCarveState(stack,food); return stack; + } + private TagTrack track(String id,String step,boolean ageable) { return new TagTrack(id,ageable,List.of(new TagStep(step,step,0,1,1))); } +} From e3ebfdc06aaeb74948a3bc50e34bcb139cf2395d Mon Sep 17 00:00:00 2001 From: Ryan Barlow <7389646+ryanbarlow97@users.noreply.github.com> Date: Tue, 6 Oct 2026 20:29:57 +0000 Subject: [PATCH 2/6] fix: cook new soup ingredients and explain blocked mill breaks --- .../cooking/cooking/PotReference.java | 2 +- .../cooking/husbandry/HusbandryInspectGui.java | 2 +- .../husbandry/HusbandryInspectListener.java | 2 +- .../cooking/milling/MillingStoneHandler.java | 5 +++++ .../cooking/PotReferenceCoverageTest.java | 17 +++++++++++++++++ .../milling/MillingStoneCoverageTest.java | 1 + 6 files changed, 26 insertions(+), 3 deletions(-) diff --git a/src/main/java/net/tfminecraft/cooking/cooking/PotReference.java b/src/main/java/net/tfminecraft/cooking/cooking/PotReference.java index e885cc7..e9343cb 100644 --- a/src/main/java/net/tfminecraft/cooking/cooking/PotReference.java +++ b/src/main/java/net/tfminecraft/cooking/cooking/PotReference.java @@ -257,7 +257,7 @@ private void handleCookingSlots() { FoodItem item = entry.getValue(); CookData data = item.getCookData(); if (!item.canBeCooked()) continue; - if (isSoup()) { + if (item.hasTag(Tag.MASHED)) { if (data.isBeingCooked()) data.stop(); continue; } diff --git a/src/main/java/net/tfminecraft/cooking/husbandry/HusbandryInspectGui.java b/src/main/java/net/tfminecraft/cooking/husbandry/HusbandryInspectGui.java index 130e053..ad008a9 100644 --- a/src/main/java/net/tfminecraft/cooking/husbandry/HusbandryInspectGui.java +++ b/src/main/java/net/tfminecraft/cooking/husbandry/HusbandryInspectGui.java @@ -21,7 +21,7 @@ public final class HusbandryInspectGui { private static final long MILLIS_PER_HOUR = 3_600_000L; private static final int INVENTORY_SIZE = 54; private static final int PRODUCTS_SLOT = 3; - private static final int REMOVE_SLOT = 8; + static final int REMOVE_SLOT = 8; private static final int CARE_BAR_START = 20; private static final int GENETICS_BAR_START = 38; private static final int MOUNT_HEALTH_SLOT = 46; diff --git a/src/main/java/net/tfminecraft/cooking/husbandry/HusbandryInspectListener.java b/src/main/java/net/tfminecraft/cooking/husbandry/HusbandryInspectListener.java index 90dcaeb..afd6ebe 100644 --- a/src/main/java/net/tfminecraft/cooking/husbandry/HusbandryInspectListener.java +++ b/src/main/java/net/tfminecraft/cooking/husbandry/HusbandryInspectListener.java @@ -74,7 +74,7 @@ public void onClick(InventoryClickEvent event) { return; } event.setCancelled(true); - if (event.getClickedInventory() != event.getInventory() || event.getRawSlot() != 8) { + if (event.getClickedInventory() != event.getInventory() || event.getRawSlot() != HusbandryInspectGui.REMOVE_SLOT) { return; } if (!(event.getWhoClicked() instanceof Player player)) { diff --git a/src/main/java/net/tfminecraft/cooking/milling/MillingStoneHandler.java b/src/main/java/net/tfminecraft/cooking/milling/MillingStoneHandler.java index 5c24e25..3ff8b3c 100644 --- a/src/main/java/net/tfminecraft/cooking/milling/MillingStoneHandler.java +++ b/src/main/java/net/tfminecraft/cooking/milling/MillingStoneHandler.java @@ -83,6 +83,11 @@ public void onBreak(FurnitureBreakEvent event) { || (stage == MillingStoneStage.READY && !dropFlour(furniture, recipe)) || (stage == MillingStoneStage.LOADED && !dropWheatRefund(furniture, recipe)))) { event.setCancelled(true); + org.bukkit.Bukkit.getLogger().warning("[Cooking] Cannot return contents from milling stone '" + + furniture.getId() + "' (recipe=" + (recipe == null ? "missing" : recipe.getId()) + + "). Break cancelled to preserve its contents."); + if (event.getPlayer() != null) event.getPlayer().sendMessage( + "§cThe mill's contents could not be returned. Please ask staff to check its recipe."); return; } diff --git a/src/test/java/net/tfminecraft/cooking/cooking/PotReferenceCoverageTest.java b/src/test/java/net/tfminecraft/cooking/cooking/PotReferenceCoverageTest.java index bb53b03..301cfc8 100644 --- a/src/test/java/net/tfminecraft/cooking/cooking/PotReferenceCoverageTest.java +++ b/src/test/java/net/tfminecraft/cooking/cooking/PotReferenceCoverageTest.java @@ -130,6 +130,23 @@ void mashedSoupThickensWithoutRestartingTheIngredientCookingClock() { assertFalse(carrot.getCookData().isBeingCooked(), "Mashed soup must stay off the raw-ingredient cooking clock"); } + @Test + void aRawIngredientAddedToExistingSoupStillCooksWhileMashedIngredientsStayStopped() { + PotReference pot = preparedSoup(); + FoodItem mashed = pot.getMain(); + FoodItem raw = env.food("potato", "vegetable", "Potato", Method.POT); + env.place("input_2", env.stack(raw, Material.POTATO, 1)); + pot.slots.put("input_2", raw); + env.heated = true; + pot.tick(); + assertTrue(raw.getCookData().isBeingCooked()); + assertEquals(1, raw.getCookData().getCurrentTime()); + assertFalse(mashed.getCookData().isBeingCooked()); + pot.tick(); + assertEquals(2, raw.getCookData().getCurrentTime()); + assertFalse(mashed.getCookData().isBeingCooked()); + } + @Test void additionsRequireBoilingWaterAndAppropriateFoodAndKeepTheFiveMainLimit() { PotReference pot = pot(); diff --git a/src/test/java/net/tfminecraft/cooking/milling/MillingStoneCoverageTest.java b/src/test/java/net/tfminecraft/cooking/milling/MillingStoneCoverageTest.java index f9a9ef8..5e294c8 100644 --- a/src/test/java/net/tfminecraft/cooking/milling/MillingStoneCoverageTest.java +++ b/src/test/java/net/tfminecraft/cooking/milling/MillingStoneCoverageTest.java @@ -331,6 +331,7 @@ void failedBreakRefundKeepsTheBatchAndCancelsFurnitureRemoval(boolean ground) { var event = new FurnitureBreakEvent(station.furniture, env.player); handler.onBreak(event); assertTrue(event.isCancelled(), "A failed refund must not silently destroy the paid ingredients"); + verify(env.player).sendMessage("§cThe mill's contents could not be returned. Please ask staff to check its recipe."); assertEquals(saved, station.variables); assertTrue(env.dropped.isEmpty()); assertEquals(ground ? MillingStoneStage.READY : MillingStoneStage.LOADED, MillingStoneState.getStage(station.furniture)); } From 41e7df8849b7e7318d1abee6340e993483318839 Mon Sep 17 00:00:00 2001 From: Ryan Barlow <7389646+ryanbarlow97@users.noreply.github.com> Date: Tue, 6 Oct 2026 20:43:40 +0000 Subject: [PATCH 3/6] fix: preserve reviewed cooking outputs and soup state --- .../cooking/cooking/PotReference.java | 6 ++++ .../cooking/mixing/MixingBowlHandler.java | 7 +++- .../cooking/utils/ItemBuilder.java | 15 ++++++-- .../churn/ButterChurnCoverageTest.java | 4 ++- .../cooking/PotReferenceCoverageTest.java | 36 +++++++++++++++++++ .../husbandry/HusbandryOwnedMountTest.java | 18 ++++++++++ .../mixing/MixingBowlCoverageTest.java | 17 +++++++++ .../utils/ItemBuilderCoverageTest.java | 17 +++++++++ 8 files changed, 115 insertions(+), 5 deletions(-) diff --git a/src/main/java/net/tfminecraft/cooking/cooking/PotReference.java b/src/main/java/net/tfminecraft/cooking/cooking/PotReference.java index e9343cb..20b598a 100644 --- a/src/main/java/net/tfminecraft/cooking/cooking/PotReference.java +++ b/src/main/java/net/tfminecraft/cooking/cooking/PotReference.java @@ -266,6 +266,12 @@ private void handleCookingSlots() { } boolean progressed = data.tick(); if (!progressed) continue; + // The displayed main item and its running cooking state are decoded separately. + // Preserve freshly accumulated thickness when rendering a cooking transition. + if (main != null && main.hasTagTrack("soup_thickness") + && f.getActiveSlots().get(slot) == firstFoodSlot()) { + item.addOrModifyTrack(new TagTrack(main.getTagTrack("soup_thickness"))); + } applySlotUpdate(slot, item); } } diff --git a/src/main/java/net/tfminecraft/cooking/mixing/MixingBowlHandler.java b/src/main/java/net/tfminecraft/cooking/mixing/MixingBowlHandler.java index 906596e..b014dd4 100644 --- a/src/main/java/net/tfminecraft/cooking/mixing/MixingBowlHandler.java +++ b/src/main/java/net/tfminecraft/cooking/mixing/MixingBowlHandler.java @@ -267,6 +267,12 @@ private void handleIngredientAdd(Furniture furniture, Player player, FurnitureIn boolean waterCup = MixingBowlSlots.WATER.equals(expectedSlot) && ItemCache.isCupOfWater(hand); + ItemStack empty = waterCup ? CupItems.emptyCup() : null; + if (waterCup && (empty == null || empty.getType().isAir())) { + player.sendMessage("§cCould not return an empty cup."); + event.setCancelled(true); + return; + } if (!MixingBowlDisplay.showLayer(furniture, expectedSlot)) { event.setCancelled(true); @@ -276,7 +282,6 @@ private void handleIngredientAdd(Furniture furniture, Player player, FurnitureIn hand.setAmount(hand.getAmount() - 1); if (waterCup) { - ItemStack empty = CupItems.emptyCup(); if (hand.getAmount() <= 0) { player.getInventory().setItemInMainHand(empty); } else { diff --git a/src/main/java/net/tfminecraft/cooking/utils/ItemBuilder.java b/src/main/java/net/tfminecraft/cooking/utils/ItemBuilder.java index c631e16..8c26010 100644 --- a/src/main/java/net/tfminecraft/cooking/utils/ItemBuilder.java +++ b/src/main/java/net/tfminecraft/cooking/utils/ItemBuilder.java @@ -35,12 +35,15 @@ public static List build(FoodItem template, boolean unique, ItemStack int amount = template.getAmount(); if (unique) { - for (int i = 0; i < amount; i++) - list.add(buildSingle(template, base)); + for (int i = 0; i < amount; i++) { + ItemStack stack = buildSingle(template, base); + if (stack != null) list.add(stack); + } return list; } ItemStack stack = buildSingle(template, base); + if (stack == null) return list; stack.setAmount(amount); list.add(stack); @@ -398,6 +401,10 @@ public static void buildFromString(Player p, String string, ItemStack base) { } else { stacks = buildWithOriginQuality(p, template, unique, base); } + if (stacks.isEmpty()) { + p.sendMessage("§cInvalid item string!"); + return; + } boolean sound = true; for (ItemStack is : stacks) { @@ -433,13 +440,15 @@ private static List buildWithOriginQuality(Player player, FoodItem te if (unique) { for (int i = 0; i < amount; i++) { int quality = OriginQualityResolver.resolve(player, template); - list.add(buildSingleWithQuality(template, base, quality)); + ItemStack stack = buildSingleWithQuality(template, base, quality); + if (stack != null) list.add(stack); } return list; } int quality = OriginQualityResolver.resolve(player, template); ItemStack stack = buildSingleWithQuality(template, base, quality); + if (stack == null) return list; stack.setAmount(amount); list.add(stack); return list; diff --git a/src/test/java/net/tfminecraft/cooking/churn/ButterChurnCoverageTest.java b/src/test/java/net/tfminecraft/cooking/churn/ButterChurnCoverageTest.java index 70e01f7..c910148 100644 --- a/src/test/java/net/tfminecraft/cooking/churn/ButterChurnCoverageTest.java +++ b/src/test/java/net/tfminecraft/cooking/churn/ButterChurnCoverageTest.java @@ -381,6 +381,7 @@ public static final class KitchenFixture implements AutoCloseable { public final Map placed = new LinkedHashMap<>(); public final Map templates = new HashMap<>(); public final MockedStatic cache; + public final MockedStatic cups; public final MockedStatic foods; public final MockedStatic updater; public final MockedStatic converter; @@ -423,7 +424,8 @@ public KitchenFixture() { snapshot.when(() -> MilkBucketSnapshot.readOrigin(any(), any())).thenAnswer(call -> { FoodItem f = resolve(call.getArgument(1)); return f == null ? "Cow" : f.getOrigin(); }); snapshot.when(() -> MilkBucketSnapshot.readDairyFreshness(any(), any())).thenAnswer(call -> ChurnExtras.readFreshness(resolve(call.getArgument(1)))); scoped(BucketItems.class).when(BucketItems::empty).thenAnswer(call -> new ItemStack(Material.BUCKET)); - scoped(CupItems.class).when(CupItems::emptyCup).thenAnswer(call -> new ItemStack(Material.GLASS_BOTTLE)); + cups = scoped(CupItems.class); + cups.when(CupItems::emptyCup).thenAnswer(call -> new ItemStack(Material.GLASS_BOTTLE)); scoped(TLibs.class).when(TLibs::getItemAPI).thenReturn(itemApi); when(itemApi.getCreator().getItemFromPath(anyString())).thenAnswer(call -> new ItemStack(Material.STONE)); cache = scoped(ItemCache.class); diff --git a/src/test/java/net/tfminecraft/cooking/cooking/PotReferenceCoverageTest.java b/src/test/java/net/tfminecraft/cooking/cooking/PotReferenceCoverageTest.java index 301cfc8..bd6a376 100644 --- a/src/test/java/net/tfminecraft/cooking/cooking/PotReferenceCoverageTest.java +++ b/src/test/java/net/tfminecraft/cooking/cooking/PotReferenceCoverageTest.java @@ -147,6 +147,42 @@ void aRawIngredientAddedToExistingSoupStillCooksWhileMashedIngredientsStayStoppe assertFalse(mashed.getCookData().isBeingCooked()); } + @Test + void cookingTheFirstRawIngredientPreservesSoupThicknessOnTheDisplayedFood() { + // Serialized food reads and writes create independent values in production. + env.foodCodec.when(() -> FoodItem.fromItem(any())).thenAnswer(invocation -> { + FoodItem stored = env.resolve(invocation.getArgument(0)); + return stored == null ? null : new FoodItem(stored); + }); + env.updater.when(() -> ItemUpdater.applyItemUpdate(any(), any(), any())).thenAnswer(invocation -> { + ItemStack source = invocation.getArgument(0); + FoodItem food = invocation.getArgument(1); + return env.stack(new FoodItem(food), source.getType(), source.getAmount()); + }); + FoodItem raw = env.food("potato", "vegetable", "Potato", Method.POT); + PlacedSlot first = env.place("input_1", env.stack(raw, Material.POTATO, 1)); + env.place("input_2", env.stack(mashed("carrot"), Material.CARROT, 1)); + env.place("liquid", new ItemStack(Material.GLASS)); + PotReference pot = pot(); + pot.rebuildFromFurniture(); + assertTrue(pot.isSoup()); + assertNotSame(pot.getSlot("input_1"), pot.getMain()); + + env.heated = true; + pot.tick(); + assertEquals(1, pot.getSlot("input_1").getCookData().getCurrentTime()); + assertTrue(env.resolve(first.getCurrentItem()).hasTagTrack("soup_thickness")); + pot.tick(); + + FoodItem displayed = env.resolve(first.getCurrentItem()); + assertEquals(2, pot.getSlot("input_1").getCookData().getCurrentTime()); + assertEquals(1, displayed.getTagTrack("cooked").getValue()); + assertTrue(displayed.hasTagTrack("soup_thickness"), + "Rendering the cooking transition must preserve the thickness accumulated by the soup"); + assertEquals(1, displayed.getTagTrack("soup_thickness").getValue()); + assertFalse(pot.getSlot("input_2").getCookData().isBeingCooked()); + } + @Test void additionsRequireBoilingWaterAndAppropriateFoodAndKeepTheFiveMainLimit() { PotReference pot = pot(); diff --git a/src/test/java/net/tfminecraft/cooking/husbandry/HusbandryOwnedMountTest.java b/src/test/java/net/tfminecraft/cooking/husbandry/HusbandryOwnedMountTest.java index 5b9f760..0ee9bdd 100644 --- a/src/test/java/net/tfminecraft/cooking/husbandry/HusbandryOwnedMountTest.java +++ b/src/test/java/net/tfminecraft/cooking/husbandry/HusbandryOwnedMountTest.java @@ -10,6 +10,7 @@ import java.util.Optional; import java.util.UUID; +import net.tfminecraft.cooking.Cooking; import net.kyori.adventure.text.Component; import org.bukkit.Material; import org.bukkit.entity.Cow; @@ -21,11 +22,28 @@ import org.bukkit.event.entity.EntityTameEvent; import org.bukkit.inventory.HorseInventory; import org.bukkit.inventory.ItemStack; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.Test; import org.mockito.ArgumentCaptor; import org.mockito.MockedStatic; class HusbandryOwnedMountTest { + private Cooking previousPlugin; + + @BeforeEach + void providePluginForStateDisplayKeys() { + previousPlugin = Cooking.plugin; + Cooking.plugin = mock(Cooking.class); + when(Cooking.plugin.getName()).thenReturn("Cooking"); + when(Cooking.plugin.namespace()).thenReturn("cooking"); + } + + @AfterEach + void restorePlugin() { + Cooking.plugin = previousPlugin; + } + @Test void tamedNamedSaddledHorseWithoutCookingOwnerIsRemoved() { Horse horse = keptLookingHorse(); diff --git a/src/test/java/net/tfminecraft/cooking/mixing/MixingBowlCoverageTest.java b/src/test/java/net/tfminecraft/cooking/mixing/MixingBowlCoverageTest.java index c48e47a..0b69d79 100644 --- a/src/test/java/net/tfminecraft/cooking/mixing/MixingBowlCoverageTest.java +++ b/src/test/java/net/tfminecraft/cooking/mixing/MixingBowlCoverageTest.java @@ -137,6 +137,23 @@ class MixingBowlCoverageTest { assertEquals(MixingBowlStage.EMPTY, MixingBowlState.getStage(s.f)); assertTrue(s.active.isEmpty()); assertTrue(s.variables.isEmpty()); } + @Test void missingCupReturnPreservesWaterAndDoesNotDisplayOrAdvanceTheLayer() { + var cups = e.cups; + for (ItemStack unavailable : Arrays.asList(null, new ItemStack(Material.AIR))) { + cups.when(net.tfminecraft.cooking.cup.CupItems::emptyCup).thenReturn(unavailable); + for (int amount : List.of(1,2)) { + resetBowl(); addFlour(); + e.hold(new ItemStack(Material.HONEY_BOTTLE, amount)); + var event = s.interact(); handler.onInteract(event); + assertTrue(event.isCancelled()); + assertEquals(Material.HONEY_BOTTLE, e.player.getInventory().getItemInMainHand().getType()); + assertEquals(amount, e.player.getInventory().getItemInMainHand().getAmount()); + assertEquals(MixingBowlStage.HAS_FLOUR, MixingBowlState.getStage(s.f)); + assertEquals(Set.of("flour"), s.active.keySet()); + } + } + } + @Test void aPersistedStageBehindItsVisibleLayerCannotConsumeTheSameIngredientTwice() { // Preserve an already loaded layer when saved stage metadata lags the visible model. MixingBowlState.setStage(s.f, MixingBowlStage.EMPTY); diff --git a/src/test/java/net/tfminecraft/cooking/utils/ItemBuilderCoverageTest.java b/src/test/java/net/tfminecraft/cooking/utils/ItemBuilderCoverageTest.java index b1c51bc..bb6fa53 100644 --- a/src/test/java/net/tfminecraft/cooking/utils/ItemBuilderCoverageTest.java +++ b/src/test/java/net/tfminecraft/cooking/utils/ItemBuilderCoverageTest.java @@ -194,6 +194,23 @@ class ItemBuilderCoverageTest { assertNull(ItemBuilder.buildSingleString("bread(type=missing)",null)); } + @Test void missingModelsDoNotProduceNullBulkItemsOrCrashBuildCommands() { + FoodItem template=food("missing_model","Missing model"); + template.setModel(null); template.setAmount(2); + Player player=mock(Player.class); + try (MockedStatic adder=mockStatic(InventoryAdder.class)) { + for (boolean unique : List.of(false,true)) { + assertTrue(ItemBuilder.build(template,unique,null).isEmpty()); + for (String quality : List.of("", ";quality=3")) { + ItemBuilder.buildFromString(player,"missing_model(type=missing_model;amount=2;unique="+unique+quality+")",null); + } + } + adder.verifyNoInteractions(); + } + verify(player,times(4)).sendMessage("§cInvalid item string!"); + verify(player,never()).playSound(any(Location.class),any(Sound.class),anyFloat(),anyFloat()); + } + @Test void configuredRoastsStartAtTheirFirstCarveStage() throws Exception { Path sequences=temporary.resolve("carve.yml"); Files.writeString(sequences,"roast:\n start-remaining: 2\n cuts:\n - output: bread\n food: 2\n nutrition: 1\n - item: v.bone\n"); From 89e9fc8f5e9c8f7a07d1c19f245dc4745646d158 Mon Sep 17 00:00:00 2001 From: Ryan Barlow <7389646+ryanbarlow97@users.noreply.github.com> Date: Tue, 6 Oct 2026 20:49:35 +0000 Subject: [PATCH 4/6] fix: retain soup thickness when mashing again --- .../cooking/cooking/PotReference.java | 20 +++++-- .../cooking/PotReferenceCoverageTest.java | 56 +++++++++++++++---- 2 files changed, 60 insertions(+), 16 deletions(-) diff --git a/src/main/java/net/tfminecraft/cooking/cooking/PotReference.java b/src/main/java/net/tfminecraft/cooking/cooking/PotReference.java index 20b598a..e8312e1 100644 --- a/src/main/java/net/tfminecraft/cooking/cooking/PotReference.java +++ b/src/main/java/net/tfminecraft/cooking/cooking/PotReference.java @@ -266,16 +266,24 @@ private void handleCookingSlots() { } boolean progressed = data.tick(); if (!progressed) continue; - // The displayed main item and its running cooking state are decoded separately. - // Preserve freshly accumulated thickness when rendering a cooking transition. - if (main != null && main.hasTagTrack("soup_thickness") - && f.getActiveSlots().get(slot) == firstFoodSlot()) { - item.addOrModifyTrack(new TagTrack(main.getTagTrack("soup_thickness"))); - } applySlotUpdate(slot, item); } } + @Override + protected void applySlotUpdate(String slot, FoodItem item) { + // The display and running cooking state are decoded separately. Both cooking + // transitions and mashing must retain the thickness already on the main item. + PlacedSlot mainSlot = firstFoodSlot(); + if (mainSlot != null && f.getActiveSlots().get(slot) == mainSlot) { + FoodItem displayed = FoodItem.fromItem(mainSlot.getCurrentItem()); + if (displayed.hasTagTrack("soup_thickness")) { + item.addOrModifyTrack(new TagTrack(displayed.getTagTrack("soup_thickness"))); + } + } + super.applySlotUpdate(slot, item); + } + public boolean canAdd(Player p, ItemStack i) { if(!isBoiling()) { p.sendMessage("§cWater isn't boiling yet..."); diff --git a/src/test/java/net/tfminecraft/cooking/cooking/PotReferenceCoverageTest.java b/src/test/java/net/tfminecraft/cooking/cooking/PotReferenceCoverageTest.java index bd6a376..8dca7df 100644 --- a/src/test/java/net/tfminecraft/cooking/cooking/PotReferenceCoverageTest.java +++ b/src/test/java/net/tfminecraft/cooking/cooking/PotReferenceCoverageTest.java @@ -149,16 +149,7 @@ void aRawIngredientAddedToExistingSoupStillCooksWhileMashedIngredientsStayStoppe @Test void cookingTheFirstRawIngredientPreservesSoupThicknessOnTheDisplayedFood() { - // Serialized food reads and writes create independent values in production. - env.foodCodec.when(() -> FoodItem.fromItem(any())).thenAnswer(invocation -> { - FoodItem stored = env.resolve(invocation.getArgument(0)); - return stored == null ? null : new FoodItem(stored); - }); - env.updater.when(() -> ItemUpdater.applyItemUpdate(any(), any(), any())).thenAnswer(invocation -> { - ItemStack source = invocation.getArgument(0); - FoodItem food = invocation.getArgument(1); - return env.stack(new FoodItem(food), source.getType(), source.getAmount()); - }); + useIndependentFoodSnapshots(); FoodItem raw = env.food("potato", "vegetable", "Potato", Method.POT); PlacedSlot first = env.place("input_1", env.stack(raw, Material.POTATO, 1)); env.place("input_2", env.stack(mashed("carrot"), Material.CARROT, 1)); @@ -183,6 +174,38 @@ void cookingTheFirstRawIngredientPreservesSoupThicknessOnTheDisplayedFood() { assertFalse(pot.getSlot("input_2").getCookData().isBeingCooked()); } + @ParameterizedTest + @ValueSource(booleans = {false, true}) + void remashingSoupPreservesItsAccumulatedThickness(boolean rawFirst) { + useIndependentFoodSnapshots(); + FoodItem raw = env.food("potato", "vegetable", "Potato", Method.POT); + FoodItem soup = mashed("carrot"); + PlacedSlot first = env.place("input_1", env.stack(rawFirst ? raw : soup, Material.CARROT, 1)); + env.place("input_2", env.stack(rawFirst ? soup : raw, Material.POTATO, 1)); + env.place("liquid", new ItemStack(Material.GLASS)); + PotReference pot = pot(); + pot.rebuildFromFurniture(); + assertTrue(pot.isSoup()); + env.heated = true; + for (int tick = 0; tick < 3; tick++) pot.tick(); + assertEquals(2, env.resolve(first.getCurrentItem()).getTagTrack("soup_thickness").getValue()); + + pot.mash(env.player); + + FoodItem displayed = env.resolve(first.getCurrentItem()); + assertTrue(displayed.hasTagTrack("soup_thickness"), + "Re-mashing must preserve the soup's accumulated thickness"); + assertEquals(2, displayed.getTagTrack("soup_thickness").getValue()); + for (FoodItem ingredient : pot.getSlots().values()) { + assertTrue(ingredient.hasTag(Tag.MASHED)); + assertEquals(3, ingredient.getTagTrack("cooked").getValue()); + assertFalse(ingredient.getCookData().isBeingCooked()); + } + pot.tick(); + pot.mash(env.player); + assertEquals(3, env.resolve(first.getCurrentItem()).getTagTrack("soup_thickness").getValue()); + } + @Test void additionsRequireBoilingWaterAndAppropriateFoodAndKeepTheFiveMainLimit() { PotReference pot = pot(); @@ -509,6 +532,19 @@ void clearingPlainWaterPreservesTheFoodUntilThePlayerTakesIt() { verify(input, never()).clearModel(); verify(liquid).clearModel(); } + private void useIndependentFoodSnapshots() { + // Serialized food reads and writes create independent values in production. + env.foodCodec.when(() -> FoodItem.fromItem(any())).thenAnswer(invocation -> { + FoodItem stored = env.resolve(invocation.getArgument(0)); + return stored == null ? null : new FoodItem(stored); + }); + env.updater.when(() -> ItemUpdater.applyItemUpdate(any(), any(), any())).thenAnswer(invocation -> { + ItemStack source = invocation.getArgument(0); + FoodItem food = invocation.getArgument(1); + return env.stack(new FoodItem(food), source.getType(), source.getAmount()); + }); + } + private PotReference pot() { return new PotReference(env.furniture, Method.POT); } private void boil(PotReference pot) { env.heated = true; pot.secondaries.put("liquid", -1); From 744c1d1941e5a9769507818f8f3657b5ddc2d39e Mon Sep 17 00:00:00 2001 From: Ryan Barlow <7389646+ryanbarlow97@users.noreply.github.com> Date: Tue, 6 Oct 2026 21:11:31 +0000 Subject: [PATCH 5/6] fix: preserve soup and dough when output rendering fails --- .../cooking/cooking/PotReference.java | 4 ++ .../cooking/mixing/MixingBowlHandler.java | 9 +-- .../cooking/PotReferenceCoverageTest.java | 25 +++++++ .../mixing/MixingBowlCoverageTest.java | 66 ++++++++++++++++--- 4 files changed, 92 insertions(+), 12 deletions(-) diff --git a/src/main/java/net/tfminecraft/cooking/cooking/PotReference.java b/src/main/java/net/tfminecraft/cooking/cooking/PotReference.java index e8312e1..fc1b75d 100644 --- a/src/main/java/net/tfminecraft/cooking/cooking/PotReference.java +++ b/src/main/java/net/tfminecraft/cooking/cooking/PotReference.java @@ -218,6 +218,10 @@ public void scoop(Player p, ItemStack ladle) { int quality = composed.getFinalQuality(); ItemStack output = ItemBuilder.buildSingleWithQuality(soup, ladle, quality); + if (output == null) { + p.sendMessage("§cThis soup cannot be served right now."); + return; + } String displayName = applyNameTemplate(soup, DisplayUtils.getMergedColour(colours), "Soup"); ItemMeta m = output.getItemMeta(); diff --git a/src/main/java/net/tfminecraft/cooking/mixing/MixingBowlHandler.java b/src/main/java/net/tfminecraft/cooking/mixing/MixingBowlHandler.java index b014dd4..e7763fd 100644 --- a/src/main/java/net/tfminecraft/cooking/mixing/MixingBowlHandler.java +++ b/src/main/java/net/tfminecraft/cooking/mixing/MixingBowlHandler.java @@ -198,12 +198,13 @@ public void onBreak(FurnitureBreakEvent event) { ItemStack dough = buildDough(furniture, null); - if (dough != null) { - - furniture.getLoc().getWorld().dropItemNaturally(furniture.getLoc(), dough); - + if (dough == null) { + event.setCancelled(true); + return; } + furniture.getLoc().getWorld().dropItemNaturally(furniture.getLoc(), dough); + MixingBowlDisplay.clearLayer(furniture, MixingBowlSlots.DOUGH); } diff --git a/src/test/java/net/tfminecraft/cooking/cooking/PotReferenceCoverageTest.java b/src/test/java/net/tfminecraft/cooking/cooking/PotReferenceCoverageTest.java index 8dca7df..dd5e82c 100644 --- a/src/test/java/net/tfminecraft/cooking/cooking/PotReferenceCoverageTest.java +++ b/src/test/java/net/tfminecraft/cooking/cooking/PotReferenceCoverageTest.java @@ -31,6 +31,7 @@ import net.tfminecraft.cooking.enums.Tag; import net.tfminecraft.cooking.item.FoodItem; import net.tfminecraft.cooking.loader.TrackLoader; +import net.tfminecraft.cooking.utils.ItemBuilder; import net.tfminecraft.cooking.utils.ItemUpdater; import net.tfminecraft.cooking.utils.Keys; import net.tfminecraft.interactiblefurniture.furniture.PlacedSlot; @@ -320,6 +321,30 @@ void soupScoopKeepsTemplateFoodAndPersistsEachRemainingServingAndHeight() { pot.scoop(env.player, ladle); } + @Test + void unavailableSoupOutputKeepsTheLadleAndRemainingServingsAndExplainsTheFailure() { + PotReference pot = preparedSoup(); + env.variables.put("pot.soupServings", 2); + ItemStack ladle = new ItemStack(Material.PAPER); + env.player.getInventory().setItemInMainHand(ladle); + Map saved = Map.copyOf(env.variables); + PlacedSlot ingredient = env.active.get("input_1"); + ItemStack displayed = ingredient.getCurrentItem().clone(); + when(ItemBuilder.buildSingleWithQuality(any(), any(), anyInt())).thenReturn(null); + + assertDoesNotThrow(() -> pot.scoop(env.player, ladle)); + + assertEquals(ladle, env.player.getInventory().getItemInMainHand()); + assertEquals(saved, env.variables); + assertTrue(pot.isSoup()); + assertEquals(displayed, ingredient.getCurrentItem()); + verify(ingredient, never()).clearModel(); + verify(env.active.get("liquid"), never()).applyDisplayData(any()); + verify(env.player, never()).swingMainHand(); + verify(env.player).sendMessage(argThat((String message) -> + message != null && message.toLowerCase(java.util.Locale.ROOT).contains("soup"))); + } + @ParameterizedTest @ValueSource(strings = {"2", "invalid", "-4"}) void restoredServingValuesAreParsedOrFallBackToConfiguredPortions(String count) { diff --git a/src/test/java/net/tfminecraft/cooking/mixing/MixingBowlCoverageTest.java b/src/test/java/net/tfminecraft/cooking/mixing/MixingBowlCoverageTest.java index 0b69d79..0eb63d4 100644 --- a/src/test/java/net/tfminecraft/cooking/mixing/MixingBowlCoverageTest.java +++ b/src/test/java/net/tfminecraft/cooking/mixing/MixingBowlCoverageTest.java @@ -10,6 +10,7 @@ import net.tfminecraft.cooking.churn.ButterChurnCoverageTest.KitchenFixture; import net.tfminecraft.cooking.churn.ButterChurnCoverageTest.Station; import net.tfminecraft.cooking.item.FoodItem; +import net.tfminecraft.cooking.item.model.FoodModel; import net.tfminecraft.cooking.item.IngredientLineage; import net.tfminecraft.cooking.utils.IngredientConverter; import net.tfminecraft.interactiblefurniture.events.FurnitureBreakEvent; @@ -215,14 +216,63 @@ class MixingBowlCoverageTest { } @Test void takingAndBreakingOnlyReleaseCompletedDoughAndHandleMissingTemplates() { - handler.onTake(e.station("other").take(null, "dough")); handler.onTake(s.take(null, "flour")); - var unready = s.take(null, "dough"); handler.onTake(unready); assertTrue(unready.isCancelled()); assertTrue(e.messages.getLast().contains("Knead")); - MixingBowlState.setStage(s.f, MixingBowlStage.DOUGH_READY); e.templates.remove("dough"); - var missing = s.take(null, "dough"); handler.onTake(missing); assertTrue(missing.isCancelled()); assertEquals(MixingBowlStage.DOUGH_READY, MixingBowlState.getStage(s.f)); - handler.onBreak(new FurnitureBreakEvent(s.f, e.player)); assertTrue(s.variables.isEmpty()); verify(e.world, never()).dropItemNaturally(any(), any()); - e.templates.put("dough", e.food("dough", "grain", "Wheat", 3, 0)); MixingBowlState.setStage(s.f, MixingBowlStage.DOUGH_READY); s.place("dough", new ItemStack(Material.STONE)); - handler.onBreak(new FurnitureBreakEvent(s.f, e.player)); verify(e.world).dropItemNaturally(eq(s.f.getLoc()), argThat(item -> item.getType() == Material.PAPER)); assertFalse(s.active.containsKey("dough")); assertTrue(s.variables.isEmpty()); - handler.onBreak(new FurnitureBreakEvent(s.f, e.player)); handler.onBreak(new FurnitureBreakEvent(e.station("other").f, e.player)); + handler.onTake(e.station("other").take(null, "dough")); + handler.onTake(s.take(null, "flour")); + var unready = s.take(null, "dough"); + handler.onTake(unready); + assertTrue(unready.isCancelled()); + assertTrue(e.messages.getLast().contains("Knead")); + + MixingBowlState.setStage(s.f, MixingBowlStage.DOUGH_READY); + MixingBowlState.setFlourQuality(s.f, 4); + PlacedSlot layer = s.place("dough", new ItemStack(Material.STONE)); + Map saved = Map.copyOf(s.variables); + e.templates.remove("dough"); + var missing = s.take(null, "dough"); + handler.onTake(missing); + assertTrue(missing.isCancelled()); + assertEquals(MixingBowlStage.DOUGH_READY, MixingBowlState.getStage(s.f)); + + FurnitureBreakEvent failedBreak = new FurnitureBreakEvent(s.f, e.player); + handler.onBreak(failedBreak); + assertTrue(failedBreak.isCancelled(), "A missing dough template must not discard a finished batch"); + assertEquals(saved, s.variables); + assertSame(layer, s.active.get("dough")); + verify(layer, never()).clearModel(); + verify(e.world, never()).dropItemNaturally(any(), any()); + + e.templates.put("dough", e.food("dough", "grain", "Wheat", 3, 0)); + FurnitureBreakEvent repairedBreak = new FurnitureBreakEvent(s.f, e.player); + handler.onBreak(repairedBreak); + assertFalse(repairedBreak.isCancelled()); + verify(e.world).dropItemNaturally(eq(s.f.getLoc()), argThat(item -> item.getType() == Material.PAPER)); + assertFalse(s.active.containsKey("dough")); + assertTrue(s.variables.isEmpty()); + handler.onBreak(new FurnitureBreakEvent(s.f, e.player)); + handler.onBreak(new FurnitureBreakEvent(e.station("other").f, e.player)); + } + + @Test void breakingReadyDoughWithAnAirModelPreservesItsSavedBatchAndVisibleLayer() { + prepare(); + MixingBowlState.setMixCount(s.f, ItemCache.mixingStirCount - 1); + e.hold(null); + handler.onInteract(s.interact()); + assertEquals(MixingBowlStage.DOUGH_READY, MixingBowlState.getStage(s.f)); + PlacedSlot layer = s.active.get("dough"); + ItemStack displayed = layer.getCurrentItem().clone(); + Map saved = Map.copyOf(s.variables); + e.templates.get("dough").setModel(new FoodModel(new ItemStack(Material.AIR))); + e.builder.close(); // Use the real renderer: an AIR model produces no output item. + + FurnitureBreakEvent event = new FurnitureBreakEvent(s.f, e.player); + handler.onBreak(event); + + assertTrue(event.isCancelled(), "Failed dough rendering must leave the bowl and its batch recoverable"); + assertEquals(saved, s.variables); + assertSame(layer, s.active.get("dough")); + assertEquals(displayed, layer.getCurrentItem()); + verify(layer, never()).clearModel(); + verify(e.world, never()).dropItemNaturally(any(), any()); } @Test void collectedDoughCarriesAllIngredientSnapshotsAndSweetFruitTags() { From be03aca766f94f3e8d517affac0ba98123f92d63 Mon Sep 17 00:00:00 2001 From: Ryan Barlow <7389646+ryanbarlow97@users.noreply.github.com> Date: Tue, 6 Oct 2026 21:19:51 +0000 Subject: [PATCH 6/6] fix: retain sauce when serving output cannot be rendered --- .../cooking/cooking/SauceReference.java | 5 +- .../cooking/mixing/MixingBowlHandler.java | 3 + .../cooking/SauceReferenceCoverageTest.java | 85 +++++++++++++++++++ .../mixing/MixingBowlCoverageTest.java | 9 ++ 4 files changed, 101 insertions(+), 1 deletion(-) create mode 100644 src/test/java/net/tfminecraft/cooking/cooking/SauceReferenceCoverageTest.java diff --git a/src/main/java/net/tfminecraft/cooking/cooking/SauceReference.java b/src/main/java/net/tfminecraft/cooking/cooking/SauceReference.java index 55182ac..ab2a94a 100644 --- a/src/main/java/net/tfminecraft/cooking/cooking/SauceReference.java +++ b/src/main/java/net/tfminecraft/cooking/cooking/SauceReference.java @@ -307,6 +307,10 @@ public void scoop(Player p, ItemStack ladle) { int quality = composed.getFinalQuality(); ItemStack output = ItemBuilder.buildSingleWithQuality(sauce, ladle, quality); + if (output == null) { + p.sendMessage("§cThis sauce cannot be served right now."); + return; + } @@ -506,4 +510,3 @@ private static void consumeSaucepanPour(Player player, ItemStack hand) { } } - diff --git a/src/main/java/net/tfminecraft/cooking/mixing/MixingBowlHandler.java b/src/main/java/net/tfminecraft/cooking/mixing/MixingBowlHandler.java index e7763fd..ae6ebf8 100644 --- a/src/main/java/net/tfminecraft/cooking/mixing/MixingBowlHandler.java +++ b/src/main/java/net/tfminecraft/cooking/mixing/MixingBowlHandler.java @@ -200,6 +200,9 @@ public void onBreak(FurnitureBreakEvent event) { if (dough == null) { event.setCancelled(true); + if (event.hasPlayer()) { + event.getPlayer().sendMessage("§cThis dough cannot be collected right now."); + } return; } diff --git a/src/test/java/net/tfminecraft/cooking/cooking/SauceReferenceCoverageTest.java b/src/test/java/net/tfminecraft/cooking/cooking/SauceReferenceCoverageTest.java new file mode 100644 index 0000000..2c38be8 --- /dev/null +++ b/src/test/java/net/tfminecraft/cooking/cooking/SauceReferenceCoverageTest.java @@ -0,0 +1,85 @@ +package net.tfminecraft.cooking.cooking; + +import static org.junit.jupiter.api.Assertions.*; +import static org.mockito.ArgumentMatchers.*; +import static org.mockito.Mockito.*; + +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Locale; +import java.util.Map; +import net.tfminecraft.cooking.enums.Method; +import net.tfminecraft.cooking.events.DishCookedEvent; +import net.tfminecraft.cooking.item.FoodItem; +import net.tfminecraft.cooking.utils.ItemBuilder; +import net.tfminecraft.cooking.utils.Keys; +import org.bukkit.Material; +import org.bukkit.inventory.ItemStack; +import org.bukkit.persistence.PersistentDataType; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; + +class SauceReferenceCoverageTest { + private CookingReferencesTest.Environment env; + + @BeforeEach void setUp() { env = new CookingReferencesTest.Environment(); } + @AfterEach void tearDown() { env.close(); } + + @Test + void unavailableSauceOutputPreservesTheBatchAndLadleUntilRenderingRecovers() { + SauceReference sauce = new SauceReference(env.furniture, Method.SAUCEPAN); + env.player.getInventory().setItemInMainHand( + env.stack(env.food("milk", "milk", "Milk"), Material.WHITE_DYE, 1)); + sauce.interact(env.interaction()); + env.player.getInventory().setItemInMainHand( + env.stack(env.food("salt", "salt", "Salt"), Material.SUGAR, 2)); + sauce.interact(env.interaction()); + + Map ingredients = Map.copyOf(sauce.getSlots()); + Map liquid = Map.copyOf(sauce.getSecondaries()); + List colours = List.copyOf(sauce.getColours()); + Map saved = Map.copyOf(env.variables); + Map displayed = new LinkedHashMap<>(); + env.active.forEach((id, slot) -> displayed.put(id, slot.getCurrentItem().clone())); + assertEquals(1, ingredients.size()); + assertEquals(Map.of("liquid", -1), liquid); + ItemStack ladle = new ItemStack(Material.PAPER); + env.player.getInventory().setItemInMainHand(ladle); + clearInvocations(env.player, env.world); + when(ItemBuilder.buildSingleWithQuality(any(), any(), anyInt())).thenReturn(null); + + assertDoesNotThrow(() -> sauce.scoop(env.player, ladle)); + + assertEquals(ladle, env.player.getInventory().getItemInMainHand()); + assertEquals(ingredients, sauce.getSlots()); + assertEquals(liquid, sauce.getSecondaries()); + assertEquals(colours, sauce.getColours()); + assertEquals(saved, env.variables); + assertEquals(displayed.keySet(), env.active.keySet()); + displayed.forEach((id, item) -> assertEquals(item, env.active.get(id).getCurrentItem())); + assertFalse(sauce.isEmpty()); + assertEquals(0, cookedEvents()); + verify(env.player, never()).swingMainHand(); + verify(env.player).sendMessage(argThat((String message) -> + message != null && message.toLowerCase(Locale.ROOT).contains("sauce"))); + + // A saucepan contains one complete batch; a failed scoop must leave it collectable. + when(ItemBuilder.buildSingleWithQuality(any(), any(), anyInt())).thenAnswer(invocation -> + env.stack(invocation.getArgument(0), Material.PAPER, 1)); + sauce.scoop(env.player, ladle); + + assertTrue(sauce.isEmpty()); + assertTrue(env.active.isEmpty()); + assertTrue(env.player.getInventory().getItemInMainHand().getItemMeta() + .getPersistentDataContainer().has(Keys.SAUCE_COLOUR, PersistentDataType.STRING)); + assertEquals(1, cookedEvents()); + sauce.scoop(env.player, ladle); + assertEquals(1, cookedEvents(), "An empty saucepan must not produce a second serving"); + } + + private long cookedEvents() { + return env.server.getPluginManager().getFiredEvents() + .filter(DishCookedEvent.class::isInstance).count(); + } +} diff --git a/src/test/java/net/tfminecraft/cooking/mixing/MixingBowlCoverageTest.java b/src/test/java/net/tfminecraft/cooking/mixing/MixingBowlCoverageTest.java index 0eb63d4..1c1b05a 100644 --- a/src/test/java/net/tfminecraft/cooking/mixing/MixingBowlCoverageTest.java +++ b/src/test/java/net/tfminecraft/cooking/mixing/MixingBowlCoverageTest.java @@ -233,14 +233,21 @@ class MixingBowlCoverageTest { assertTrue(missing.isCancelled()); assertEquals(MixingBowlStage.DOUGH_READY, MixingBowlState.getStage(s.f)); + e.messages.clear(); FurnitureBreakEvent failedBreak = new FurnitureBreakEvent(s.f, e.player); handler.onBreak(failedBreak); assertTrue(failedBreak.isCancelled(), "A missing dough template must not discard a finished batch"); + assertEquals(List.of("§cThis dough cannot be collected right now."), e.messages); assertEquals(saved, s.variables); assertSame(layer, s.active.get("dough")); verify(layer, never()).clearModel(); verify(e.world, never()).dropItemNaturally(any(), any()); + FurnitureBreakEvent automatedBreak = new FurnitureBreakEvent(s.f, null); + assertDoesNotThrow(() -> handler.onBreak(automatedBreak)); + assertTrue(automatedBreak.isCancelled()); + assertEquals(saved, s.variables); + e.templates.put("dough", e.food("dough", "grain", "Wheat", 3, 0)); FurnitureBreakEvent repairedBreak = new FurnitureBreakEvent(s.f, e.player); handler.onBreak(repairedBreak); @@ -264,10 +271,12 @@ class MixingBowlCoverageTest { e.templates.get("dough").setModel(new FoodModel(new ItemStack(Material.AIR))); e.builder.close(); // Use the real renderer: an AIR model produces no output item. + e.messages.clear(); FurnitureBreakEvent event = new FurnitureBreakEvent(s.f, e.player); handler.onBreak(event); assertTrue(event.isCancelled(), "Failed dough rendering must leave the bowl and its batch recoverable"); + assertEquals(List.of("§cThis dough cannot be collected right now."), e.messages); assertEquals(saved, s.variables); assertSame(layer, s.active.get("dough")); assertEquals(displayed, layer.getCurrentItem());