From 77082205c8ce4a7097e10b34825af3a62713e19a Mon Sep 17 00:00:00 2001 From: Nowko Date: Sat, 10 Oct 2026 02:05:04 +0200 Subject: [PATCH] fix: require a configurable permission to hatch pet eggs Egg recipes are restricted to certain roles, but anyone holding a traded egg could still hatch it. hatching.permission sets a global requirement; species and pets override it with hatch-permission (false or "" clears it). The permission is checked when hatching starts and again at confirmation, and a refused player keeps the egg. Co-Authored-By: Claude Opus 5.5 --- .../companionpets/config/CompanionConfig.java | 17 +++++++-- .../companionpets/config/PetTypeDef.java | 9 ++++- .../companionpets/runtime/PetHatching.java | 10 ++++++ src/main/resources/config.yml | 10 +++++- .../config/CompanionConfigCoverageTest.java | 20 +++++++++++ .../config/ConfigExamplesTest.java | 4 +++ .../runtime/PetHatchingCoverageTest.java | 35 +++++++++++++++++++ 7 files changed, 100 insertions(+), 5 deletions(-) diff --git a/src/main/java/net/tfminecraft/companionpets/config/CompanionConfig.java b/src/main/java/net/tfminecraft/companionpets/config/CompanionConfig.java index a956229..6277ad8 100644 --- a/src/main/java/net/tfminecraft/companionpets/config/CompanionConfig.java +++ b/src/main/java/net/tfminecraft/companionpets/config/CompanionConfig.java @@ -223,7 +223,8 @@ public static CompanionConfig load(JavaPlugin plugin, FileConfiguration config) boolean mythic = plugin.getServer().getPluginManager().isPluginEnabled("MythicMobs"); Map custom = CustomTrick.read(config.getConfigurationSection("custom-tricks"), logger); List defaultTricks = readDefaultTricks(config, "training.default-tricks", List.of(Trick.FOLLOW), custom); - Map types = readTypes(config.getConfigurationSection("pets"), config.getConfigurationSection("species"), mythic, plugin.getLogger(), custom, interactionItems, defaultTricks); + String hatchPermission = readPermission(config, "hatching.permission", null); + Map types = readTypes(config.getConfigurationSection("pets"), config.getConfigurationSection("species"), mythic, plugin.getLogger(), custom, interactionItems, defaultTricks, hatchPermission); return new CompanionConfig( careSettings, playSettings, @@ -260,7 +261,16 @@ private static List readDefaultTricks(ConfigurationSection section, Strin return List.copyOf(result); } - private static Map readTypes(ConfigurationSection pets, ConfigurationSection species, boolean mythic, Logger logger, Map custom, PetItems globalItems, List globalDefaults) { + /** Reads a permission node; false or a blank string clears an inherited one. */ + private static String readPermission(ConfigurationSection section, String key, String inherited) { + if (!section.contains(key)) return inherited; + Object raw = section.get(key); + if (Boolean.FALSE.equals(raw)) return null; + if (!(raw instanceof String node)) throw new IllegalArgumentException(section.getCurrentPath() + "." + key + " must be a permission node or false"); + return node.isBlank() ? null : node.trim(); + } + + private static Map readTypes(ConfigurationSection pets, ConfigurationSection species, boolean mythic, Logger logger, Map custom, PetItems globalItems, List globalDefaults, String globalHatchPermission) { Map types = new LinkedHashMap<>(); if (pets == null) { return types; @@ -321,6 +331,7 @@ private static Map readTypes(ConfigurationSection pets, Conf SexMode sexMode = "choose".equalsIgnoreCase(section.getString("sex", "random")) ? SexMode.CHOOSE : SexMode.RANDOM; Set tricks = new java.util.LinkedHashSet<>(resolved.tricks()); List defaults = readDefaultTricks(section, "default-tricks", globalDefaults, custom); + String hatchPermission = readPermission(section, "hatch-permission", globalHatchPermission); tricks.addAll(defaults); PetAppearance appearance; try { @@ -353,7 +364,7 @@ private static Map readTypes(ConfigurationSection pets, Conf sexMode, appearance, petItems, tricks, defaults, resolved.behavior(), - PetSounds.read(section, entity, logger), section.getBoolean("native-combat", false), resolved.species())); + PetSounds.read(section, entity, logger), section.getBoolean("native-combat", false), resolved.species(), hatchPermission)); } return Collections.unmodifiableMap(types); } diff --git a/src/main/java/net/tfminecraft/companionpets/config/PetTypeDef.java b/src/main/java/net/tfminecraft/companionpets/config/PetTypeDef.java index 2b1f45e..763513b 100644 --- a/src/main/java/net/tfminecraft/companionpets/config/PetTypeDef.java +++ b/src/main/java/net/tfminecraft/companionpets/config/PetTypeDef.java @@ -9,6 +9,7 @@ import org.bukkit.entity.EntityType; import org.bukkit.inventory.ItemStack; import org.bukkit.inventory.meta.ItemMeta; +import org.bukkit.permissions.Permissible; import net.tfminecraft.companionpets.pet.SexMode; import net.tfminecraft.companionpets.pet.Trick; @@ -27,7 +28,8 @@ public record PetTypeDef( BehaviorProfile behavior, PetSounds sounds, boolean nativeCombat, - String species) { + String species, + String hatchPermission) { public PetTypeDef { tricks = Set.copyOf(tricks); @@ -35,6 +37,11 @@ public record PetTypeDef( java.util.Objects.requireNonNull(behavior); } + /** A null hatch permission lets anyone hatch this type's egg. */ + public boolean canHatch(Permissible player) { + return hatchPermission == null || player.hasPermission(hatchPermission); + } + public Set behaviors() { return behavior.behaviors(); } public boolean behaves(PetBehavior behavior) { return behaviors().contains(behavior); } diff --git a/src/main/java/net/tfminecraft/companionpets/runtime/PetHatching.java b/src/main/java/net/tfminecraft/companionpets/runtime/PetHatching.java index 008e08f..a001d26 100644 --- a/src/main/java/net/tfminecraft/companionpets/runtime/PetHatching.java +++ b/src/main/java/net/tfminecraft/companionpets/runtime/PetHatching.java @@ -21,9 +21,14 @@ final class PetHatching { private static final long PROMPT_MILLIS = 60_000L; + private static final String NOT_ALLOWED = "You don't know how to hatch this egg"; private final PetRuntime runtime; PetHatching(PetRuntime runtime) { this.runtime = runtime; } void begin(Player player, PetTypeDef type) { + if (!type.canHatch(player)) { + PetFx.bar(player, NOT_ALLOWED); + return; + } if (!Quota.canAdopt(runtime.store().countPets(player.getUniqueId()), runtime.config().limits().maxPets())) { PetFx.bar(player, PetTexts.refusal("", PetSex.FEMALE, "full-total")); return; @@ -103,6 +108,11 @@ private void finishHatch(Player player, PetTypeDef type, String name, PetSex sex PetFx.tell(player, "The egg left your hand, so nothing hatched. Use it again to start over."); return; } + // Permissions can be revoked or the config reloaded while the player answers in chat. + if (!type.canHatch(player)) { + PetFx.tell(player, NOT_ALLOWED + ". Your egg is safe."); + return; + } if (!Quota.canAdopt(runtime.store().countPets(player.getUniqueId()), runtime.config().limits().maxPets())) { PetFx.tell(player, PetTexts.refusal(name, sex, "full-total")); return; diff --git a/src/main/resources/config.yml b/src/main/resources/config.yml index 3b62c9b..a6f374a 100644 --- a/src/main/resources/config.yml +++ b/src/main/resources/config.yml @@ -1,6 +1,7 @@ # CompanionPets: hatch, care for, train and play with persistent companion pets. # Start with pets/species, custom-tricks and items; then tune care/training/play. -# presence/orders/roaming/moments/social control daily behavior; limits set quotas. +# presence/orders/roaming/moments/social control daily behavior; limits set quotas; +# hatching restricts who can use eggs. # Item IDs: STICK (vanilla), mmoitems:TYPE:ID, itemsadder:namespace:id. # Inheritance: global settings -> species. -> pets.; omitted options inherit. # Nested maps merge deeply; simple values and lists replace inherited values. @@ -127,6 +128,7 @@ pets: # egg: EGG # Vanilla, MMOItems or ItemsAdder selector. # egg-custom-model-data: 12002 # Nonnegative integer; only valid for vanilla eggs. # sex: choose # random assigns sex at hatching; choose asks the owner. + # hatch-permission: companionpets.hatch.fox # Replaces hatching.permission for this type; false clears it. # native-combat: false # true allows native targeting/combat; default false suppresses it. # behavior: cat # Character independent of body, model and voice. # items: # Present categories replace inherited lists, never append. @@ -424,3 +426,9 @@ social: limits: max-pets: 20 # Maximum living pets per owner, counting those inside the Pet House and outside. max-out: 4 # Maximum pets simultaneously out in the world per owner. + +# Hatching: permission node required to use any egg; omit it so everyone can hatch. +# Match it to the egg recipes' crafting condition so traded eggs stay restricted too. +# Pets/species override it with hatch-permission; false or "" lets anyone hatch that type. +# hatching: +# permission: companionpets.hatch # Players without it keep the egg and cannot start hatching. diff --git a/src/test/java/net/tfminecraft/companionpets/config/CompanionConfigCoverageTest.java b/src/test/java/net/tfminecraft/companionpets/config/CompanionConfigCoverageTest.java index 638f13c..8a2c197 100644 --- a/src/test/java/net/tfminecraft/companionpets/config/CompanionConfigCoverageTest.java +++ b/src/test/java/net/tfminecraft/companionpets/config/CompanionConfigCoverageTest.java @@ -88,6 +88,26 @@ class CompanionConfigCoverageTest { assertTrue(warnings.stream().anyMatch(s -> s.contains("max-out exceeds limits.max-pets") && s.endsWith("2"))); } + @Test void hatchPermissionInheritsThroughSpeciesAndFalseOrBlankClearsIt() throws Exception { + var config = load(""" + hatching: {permission: " roles.pet_master "} + species: {fox: {entity: WOLF, hatch-permission: roles.fox_keeper}} + pets: + wolf: {species: dog, egg: WOLF_SPAWN_EGG} + fox: {species: fox, egg: FOX_SPAWN_EGG} + frog: {species: fox, egg: FROG_SPAWN_EGG, hatch-permission: false} + cat: {species: cat, egg: CAT_SPAWN_EGG, hatch-permission: ""} + """); + assertEquals("roles.pet_master", config.type("wolf").hatchPermission()); + assertEquals("roles.fox_keeper", config.type("fox").hatchPermission()); + assertNull(config.type("frog").hatchPermission()); + assertNull(config.type("cat").hatchPermission()); + assertNull(load("pets: {wolf: {species: dog, egg: WOLF_SPAWN_EGG}}").type("wolf").hatchPermission()); + var error = assertThrows(IllegalArgumentException.class, () -> load("hatching: {permission: [a, b]}")); + assertTrue(error.getMessage().contains("hatching.permission"), error.getMessage()); + assertThrows(IllegalArgumentException.class, () -> load("pets: {wolf: {species: dog, egg: WOLF_SPAWN_EGG, hatch-permission: true}}")); + } + @Test void numericFallbacksWarnAndPreserveValidNeighbours() throws Exception { var defaults = load(""); var config = load(""" diff --git a/src/test/java/net/tfminecraft/companionpets/config/ConfigExamplesTest.java b/src/test/java/net/tfminecraft/companionpets/config/ConfigExamplesTest.java index 6230d48..8403177 100644 --- a/src/test/java/net/tfminecraft/companionpets/config/ConfigExamplesTest.java +++ b/src/test/java/net/tfminecraft/companionpets/config/ConfigExamplesTest.java @@ -71,6 +71,7 @@ private String uncomment(String text, String first, String last) { assertNull(config.type("rabbit")); assertNull(config.type("fox_example")); assertNull(config.customTrick(Trick.valueOf("wave"))); + assertTrue(config.types().values().stream().allMatch(type -> type.hatchPermission() == null)); assertEquals(config.items(), config.type("wolf").items()); assertEquals(List.of(Trick.FOLLOW), config.type("wolf").defaultTricks()); } @@ -133,6 +134,7 @@ private String uncomment(String text, String first, String last) { text = uncomment(text, "fox_example:", " eat: false"); text = uncomment(text, "dog:", " voice: wolf"); text = uncomment(text, "wave:", " at: [0, 1]"); + text = uncomment(text, "hatching:", " permission: companionpets.hatch"); var config = loadWithoutWarnings(text); assertEquals(22, config.types().size()); assertEquals(EntityType.WOLF, config.type("rabbit").entity()); @@ -146,6 +148,8 @@ private String uncomment(String text, String first, String last) { assertEquals(BehaviorProfile.CAT, fox.behavior()); assertEquals(SexMode.CHOOSE, fox.sexMode()); assertEquals(12002, fox.eggCustomModelData()); + assertEquals("companionpets.hatch.fox", fox.hatchPermission()); + assertEquals("companionpets.hatch", config.type("rabbit").hatchPermission()); assertEquals(List.of(ItemRef.vanilla(Material.SALMON)), fox.treats()); assertEquals(java.util.Map.of(ItemRef.vanilla(Material.COOKED_CHICKEN), 35.0), fox.foods()); assertEquals(List.of(Trick.FOLLOW), fox.defaultTricks()); diff --git a/src/test/java/net/tfminecraft/companionpets/runtime/PetHatchingCoverageTest.java b/src/test/java/net/tfminecraft/companionpets/runtime/PetHatchingCoverageTest.java index 6513a22..d85e28f 100644 --- a/src/test/java/net/tfminecraft/companionpets/runtime/PetHatchingCoverageTest.java +++ b/src/test/java/net/tfminecraft/companionpets/runtime/PetHatchingCoverageTest.java @@ -288,6 +288,41 @@ private void reload(String key, Object value) { assertNull(lastSpawned); } + @Test void hatchingRequiresTheConfiguredPermissionAndKeepsTheEggOtherwise() { + reload("hatching.permission", "test.hatch"); + var egg = new ItemStack(Material.WOLF_SPAWN_EGG, 2); + player.getInventory().setItemInMainHand(egg); + var event = new PlayerInteractEvent(player, Action.RIGHT_CLICK_AIR, egg, null, BlockFace.SELF, EquipmentSlot.HAND); + server.getPluginManager().callEvent(event); + assertTrue(event.isCancelled(), "refused eggs must not fall back to vanilla spawning"); + assertNull(runtime.sessions().hatch(player.getUniqueId())); + assertEquals(egg, player.getInventory().getItemInMainHand()); + assertTrue(net.kyori.adventure.text.serializer.plain.PlainTextComponentSerializer.plainText() + .serialize(player.nextActionBar()).contains("don't know how to hatch")); + + player.addAttachment(runtime.plugin(), "test.hatch", true); + begin(); namePet(); chat("yes"); + assertEquals("Luna", onlyPet().name()); + } + + @Test void aPetOverrideCanClearTheGlobalHatchPermission() { + reload("hatching.permission", "test.hatch"); + reload("pets.wolf.hatch-permission", false); + begin(); namePet(); chat("yes"); + assertEquals("Luna", onlyPet().name()); + } + + @Test void losingTheHatchPermissionDuringTheDialogueKeepsTheEggAtConfirmation() { + begin(); namePet(); + reload("pets.wolf.hatch-permission", "test.hatch"); + chat("yes"); + assertNull(runtime.sessions().hatch(player.getUniqueId())); + assertTrue(runtime.store().all().isEmpty()); + assertEquals(2, player.getInventory().getItemInMainHand().getAmount()); + assertTrue(messages().contains("don't know how to hatch this egg. Your egg is safe.")); + assertNull(lastSpawned); + } + @Test void reachingTheTotalDuringTheDialogueKeepsTheEggAtConfirmation() { begin(); namePet(); fillPetHouse();