Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -223,7 +223,8 @@ public static CompanionConfig load(JavaPlugin plugin, FileConfiguration config)
boolean mythic = plugin.getServer().getPluginManager().isPluginEnabled("MythicMobs");
Map<Trick, CustomTrick> custom = CustomTrick.read(config.getConfigurationSection("custom-tricks"), logger);
List<Trick> defaultTricks = readDefaultTricks(config, "training.default-tricks", List.of(Trick.FOLLOW), custom);
Map<String, PetTypeDef> types = readTypes(config.getConfigurationSection("pets"), config.getConfigurationSection("species"), mythic, plugin.getLogger(), custom, interactionItems, defaultTricks);
String hatchPermission = readPermission(config, "hatching.permission", null);
Map<String, PetTypeDef> types = readTypes(config.getConfigurationSection("pets"), config.getConfigurationSection("species"), mythic, plugin.getLogger(), custom, interactionItems, defaultTricks, hatchPermission);
return new CompanionConfig(
careSettings,
playSettings,
Expand Down Expand Up @@ -260,7 +261,16 @@ private static List<Trick> readDefaultTricks(ConfigurationSection section, Strin
return List.copyOf(result);
}

private static Map<String, PetTypeDef> readTypes(ConfigurationSection pets, ConfigurationSection species, boolean mythic, Logger logger, Map<Trick, CustomTrick> custom, PetItems globalItems, List<Trick> 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<String, PetTypeDef> readTypes(ConfigurationSection pets, ConfigurationSection species, boolean mythic, Logger logger, Map<Trick, CustomTrick> custom, PetItems globalItems, List<Trick> globalDefaults, String globalHatchPermission) {
Map<String, PetTypeDef> types = new LinkedHashMap<>();
if (pets == null) {
return types;
Expand Down Expand Up @@ -321,6 +331,7 @@ private static Map<String, PetTypeDef> readTypes(ConfigurationSection pets, Conf
SexMode sexMode = "choose".equalsIgnoreCase(section.getString("sex", "random")) ? SexMode.CHOOSE : SexMode.RANDOM;
Set<Trick> tricks = new java.util.LinkedHashSet<>(resolved.tricks());
List<Trick> defaults = readDefaultTricks(section, "default-tricks", globalDefaults, custom);
String hatchPermission = readPermission(section, "hatch-permission", globalHatchPermission);
tricks.addAll(defaults);
PetAppearance appearance;
try {
Expand Down Expand Up @@ -353,7 +364,7 @@ private static Map<String, PetTypeDef> 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);
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -27,14 +28,20 @@ public record PetTypeDef(
BehaviorProfile behavior,
PetSounds sounds,
boolean nativeCombat,
String species) {
String species,
String hatchPermission) {

public PetTypeDef {
tricks = Set.copyOf(tricks);
defaultTricks = List.copyOf(defaultTricks);
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<PetBehavior> behaviors() { return behavior.behaviors(); }
public boolean behaves(PetBehavior behavior) { return behaviors().contains(behavior); }

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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;
Expand Down
10 changes: 9 additions & 1 deletion src/main/resources/config.yml
Original file line number Diff line number Diff line change
@@ -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.<id> -> pets.<id>; omitted options inherit.
# Nested maps merge deeply; simple values and lists replace inherited values.
Expand Down Expand Up @@ -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.
Expand Down Expand Up @@ -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.
Original file line number Diff line number Diff line change
Expand Up @@ -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("""
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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());
}
Expand Down Expand Up @@ -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());
Expand All @@ -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());
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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();
Expand Down
Loading