diff --git a/.github/dependencies.sha256 b/.github/dependencies.sha256 index c5e86e2..caba531 100644 --- a/.github/dependencies.sha256 +++ b/.github/dependencies.sha256 @@ -1,3 +1,2 @@ -a37f7789fcdcd11c9e5890a2fac742aa4501749588af23182991953e06ee1d17 libs/MMOItems-6.10.1-SNAPSHOT.jar +81d511d0830951f988615b6533cc93e82d00a83fb31e8ccbb36441ae3e8157ef libs/MMOCore-1.13.1-SNAPSHOT.jar 225aa7f75d4e6e6ae6b3ad87a7ca678c025c027f00374ab1062275d24f3a73c9 libs/MythicLib-1.7.1-SNAPSHOT.jar -4e69696892b88b41c55d49ab2fdcc21eead92bf54acc588c0050596c3b75199c libs/json-simple-1.1.1.jar diff --git a/.github/scripts/install-local-dependencies.sh b/.github/scripts/install-local-dependencies.sh index 38fe7a0..14183b5 100644 --- a/.github/scripts/install-local-dependencies.sh +++ b/.github/scripts/install-local-dependencies.sh @@ -5,11 +5,8 @@ set -euo pipefail sha256sum --check .github/dependencies.sha256 mvn -B --no-transfer-progress org.apache.maven.plugins:maven-install-plugin:3.1.4:install-file \ - -Dfile="libs/MMOItems-6.10.1-SNAPSHOT.jar" -DgroupId="local" -DartifactId="MMOItems" \ - -Dversion="6.10.1-SNAPSHOT-tfmc-a37f7789fcdc" -Dpackaging=jar -DgeneratePom=true "$@" + -Dfile="libs/MMOCore-1.13.1-SNAPSHOT.jar" -DgroupId="local" -DartifactId="MMOCore" \ + -Dversion="1.13.1-SNAPSHOT-tfmc-81d511d08309" -Dpackaging=jar -DgeneratePom=true "$@" mvn -B --no-transfer-progress org.apache.maven.plugins:maven-install-plugin:3.1.4:install-file \ -Dfile="libs/MythicLib-1.7.1-SNAPSHOT.jar" -DgroupId="local" -DartifactId="MythicLib" \ -Dversion="1.7.1-SNAPSHOT-tfmc-225aa7f75d4e" -Dpackaging=jar -DgeneratePom=true "$@" -mvn -B --no-transfer-progress org.apache.maven.plugins:maven-install-plugin:3.1.4:install-file \ - -Dfile="libs/json-simple-1.1.1.jar" -DgroupId="local" -DartifactId="json-simple" \ - -Dversion="1.1.1-tfmc-4e69696892b8" -Dpackaging=jar -DgeneratePom=true "$@" diff --git a/.github/scripts/prepare-release.sh b/.github/scripts/prepare-release.sh index 5bb3e67..c7446c3 100644 --- a/.github/scripts/prepare-release.sh +++ b/.github/scripts/prepare-release.sh @@ -3,7 +3,6 @@ set -euo pipefail : "${GH_TOKEN:?Set DEPS_TOKEN with Contents read access to TF-Minecraft/ServerAssets}" ref=4b80431398e4ff35d703cad915b7ee4e5924a763 mkdir -p libs -curl --fail --location --silent --show-error --retry 3 -H "Authorization: Bearer $GH_TOKEN" -H "Accept: application/vnd.github.raw+json" "https://api.github.com/repos/TF-Minecraft/ServerAssets/contents/jars/a37f7789fcdc/MMOItems-6.10.1-SNAPSHOT.jar?ref=$ref" > "libs/MMOItems-6.10.1-SNAPSHOT.jar" +curl --fail --location --silent --show-error --retry 3 -H "Authorization: Bearer $GH_TOKEN" -H "Accept: application/vnd.github.raw+json" "https://api.github.com/repos/TF-Minecraft/ServerAssets/contents/jars/81d511d08309/MMOCore-1.13.1-SNAPSHOT.jar?ref=$ref" > "libs/MMOCore-1.13.1-SNAPSHOT.jar" curl --fail --location --silent --show-error --retry 3 -H "Authorization: Bearer $GH_TOKEN" -H "Accept: application/vnd.github.raw+json" "https://api.github.com/repos/TF-Minecraft/ServerAssets/contents/jars/225aa7f75d4e/MythicLib-1.7.1-SNAPSHOT.jar?ref=$ref" > "libs/MythicLib-1.7.1-SNAPSHOT.jar" -curl --fail --location --silent --show-error --retry 3 -H "Authorization: Bearer $GH_TOKEN" -H "Accept: application/vnd.github.raw+json" "https://api.github.com/repos/TF-Minecraft/ServerAssets/contents/jars/4e69696892b8/json-simple-1.1.1.jar?ref=$ref" > "libs/json-simple-1.1.1.jar" bash .github/scripts/install-local-dependencies.sh "$@" diff --git a/README.md b/README.md index c0405d5..aa759eb 100644 --- a/README.md +++ b/README.md @@ -10,18 +10,30 @@ while breeding connects the next generation to the animals they have raised. - **Animal ownership** — track owned animals and share responsibility through co-ownership. -- **Daily care** — feeding and tending animals increases friendship, with - daily records of the attention each animal has received. +- **Daily care** — feeding hungry animals, brushing dirty ones, and daily + petting or riding earns trust, displayed as five hearts. - **Inherited traits** — offspring genetics reflect their parents' genetics - and friendship, with variation between generations. + and trust, with variation between generations. - **Mount development** — pass health, movement speed, and jump characteristics from parent mounts to their offspring. -- **Reward bundles** — collect tiered bundles from happy animals, with better - tiers for higher friendship and genetics. -- **Stable chunks** — animals should spend the night in claimed stable chunks - with water and room, or they eventually escape. -- **Farm management** — inspect animal information and farm reports, with - ownership checks around breeding and interactions. +- **Animal products** — collect milk, wool, manure, and other species-specific + products, with care and genetics affecting yields. +- **Farm management** — inspect animal information, share ownership, and find + animals with `/animals`, with character ownership and estate inheritance. + +## Upgrading to 1.2 + +Requires Cooking **0.5.1** and TLibs **2.6.0**. Before starting the new version, +set `enabled: false` in `plugins/Cooking/husbandry.yml`, install BreedingBuddies, +and create its writable `plugins/BreedingBuddies/Data` directory if the host +does not allow the server to create plugin folders. Activate the switch with a +server restart. + +The first start copies Cooking's `Data/husbandry.db` and its journal, preserving +the originals as a backup. Existing Cooking animal and ownership keys remain +compatible. Settings start from BreedingBuddies' own `config.yml` defaults; +review them before activation. Staff need `breedingbuddies.admin` for +`/bb reload`, `/bb spawn`, and `/bb save`. ## Documentation @@ -33,8 +45,8 @@ Technical documentation is maintained in [TF-Minecraft/Docs](https://github.com/ Run `mvn clean verify` with Java 21 after preparing the pinned plugin dependencies, as the Build workflow does. JUnit 5, Mockito, and MockBukkit cover -ownership, taming, care, breeding, bundles, stable chunks, mount stats, day -changes, saved data, and the admin commands. Surefire test results are in +ownership, taming, trust, breeding, products, mount stats, simulation, +database migration, saved data, and the admin commands. Surefire test results are in `target/surefire-reports/`; JaCoCo's HTML and XML reports are in `target/site/jacoco/`. The build enforces **100% line coverage** across all production classes, with no exclusions, and CI uploads both reports. Branch and diff --git a/pom.xml b/pom.xml index 0d7e89c..337e469 100644 --- a/pom.xml +++ b/pom.xml @@ -13,6 +13,7 @@ 2.6.0 + 0.5.1 21 UTF-8 @@ -23,6 +24,10 @@ me.pluginstlibs${tlibs.version}provided ** + + net.tfminecraftcooking${cooking.version}provided + ** + io.papermc.paper paper-api @@ -31,14 +36,8 @@ local - json-simple - 1.1.1-tfmc-4e69696892b8 - provided - - - local - MMOItems - 6.10.1-SNAPSHOT-tfmc-a37f7789fcdc + MMOCore + 1.13.1-SNAPSHOT-tfmc-81d511d08309 provided @@ -65,6 +64,24 @@ 4.95.0 test + + com.googlecode.json-simple + json-simple + 1.1.1 + test + + + org.xerial + sqlite-jdbc + 3.49.1.0 + test + + + org.slf4j + slf4j-simple + 2.0.17 + test + @@ -115,10 +132,8 @@ maven-surefire-plugin 3.5.4 - @{argLine} -javaagent:${org.mockito:mockito-core:jar} -Xshare:off + @{argLine} -javaagent:${org.mockito:mockito-core:jar} -Xshare:off -Duser.language=en -Duser.country=US true - - ${project.build.directory}/test-runtime diff --git a/src/main/java/net/tfminecraft/breedingbuddies/AnimalCodec.java b/src/main/java/net/tfminecraft/breedingbuddies/AnimalCodec.java deleted file mode 100644 index e8d418e..0000000 --- a/src/main/java/net/tfminecraft/breedingbuddies/AnimalCodec.java +++ /dev/null @@ -1,37 +0,0 @@ -package net.tfminecraft.breedingbuddies; - -import org.json.simple.JSONArray; -import org.json.simple.JSONObject; -import net.tfminecraft.tlibs.character.CharacterRef; -import java.util.*; - -final class AnimalCodec { - private AnimalCodec() {} - static JSONObject patch(JSONObject raw, FarmAnimal animal) { - JSONObject row = raw == null ? new JSONObject() : AnimalFiles.copy(raw); - row.put("uuid", animal.getUuid().toString()); row.put("name", animal.getName()); - JSONArray slots = new JSONArray(); - for (CharacterRef owner : animal.getOwnerRefs().values()) { - JSONObject slot = AnimalOwnershipStore.ref(owner); - if (raw != null) for (Object existing : (JSONArray) raw.get("characterOwners")) if (owner.characterId().toString().equals(((JSONObject) existing).get("character"))) slot = AnimalFiles.copy((JSONObject) existing); - slots.add(slot); - } - row.put("characterOwners", slots); - if (!row.containsKey("heldOwners")) row.put("heldOwners", new JSONArray()); - row.put("stableNeeded", animal.isStableNeeded()); row.put("friendshipPoints", (long) animal.getFriendshipPoints()); row.put("geneticPoints", (long) animal.getGeneticPoints()); - row.put("state", animal.getState().toString()); row.put("fed", animal.getFed()); row.put("cared", animal.getCared()); row.put("sleptInStable", animal.isSleptInStable()); - row.put("spaceNeeded", animal.isSpaceNeeded()); row.put("waterNeeded", animal.isWaterNeeded()); row.put("daysOut", (long) animal.getDaysOut()); - row.put("lastRewardCollected", animal.getLastRewardCollected() == null ? null : animal.getLastRewardCollected().getTime()); - row.put("lastUpdated", animal.getLastUpdated() == null ? null : animal.getLastUpdated().getTime()); return row; - } - static FarmAnimal load(JSONObject row) { - FarmAnimal animal = new FarmAnimal(UUID.fromString((String) row.get("uuid")), (String) row.get("name"), new HashSet<>()); - for (CharacterRef owner : AnimalOwnershipStore.owners(row)) { animal.getOwnerRefs().put(owner.characterId(), owner); animal.getOwnersUuids().add(owner.characterId()); } - animal.setStableNeeded((boolean) row.get("stableNeeded")); animal.setFriendshipPoints(((Number) row.get("friendshipPoints")).intValue()); - animal.setGeneticPoints(((Number) row.get("geneticPoints")).intValue()); animal.setState(AnimalStates.valueOf((String) row.get("state"))); - animal.setFed((boolean) row.get("fed")); animal.setCared((boolean) row.get("cared")); animal.setSleptInStable((boolean) row.get("sleptInStable")); - animal.setSpaceNeeded((boolean) row.get("spaceNeeded")); animal.setWaterNeeded((boolean) row.get("waterNeeded")); animal.setDaysOut(((Number) row.get("daysOut")).intValue()); - if (row.get("lastRewardCollected") != null) animal.setLastRewardCollected(new Date(((Number) row.get("lastRewardCollected")).longValue())); - if (row.get("lastUpdated") != null) animal.setLastUpdated(new Date(((Number) row.get("lastUpdated")).longValue())); return animal; - } -} diff --git a/src/main/java/net/tfminecraft/breedingbuddies/AnimalEstates.java b/src/main/java/net/tfminecraft/breedingbuddies/AnimalEstates.java deleted file mode 100644 index 4f1040c..0000000 --- a/src/main/java/net/tfminecraft/breedingbuddies/AnimalEstates.java +++ /dev/null @@ -1,117 +0,0 @@ -package net.tfminecraft.breedingbuddies; - -import net.tfminecraft.tlibs.character.CharacterRef; -import net.tfminecraft.tlibs.character.estate.*; -import org.bukkit.Bukkit; -import org.bukkit.plugin.Plugin; -import org.bukkit.plugin.ServicePriority; -import org.json.simple.JSONArray; -import org.json.simple.JSONObject; -import java.io.IOException; -import java.nio.file.Files; -import java.util.*; - -/** Optional estate API; never loaded until the capability probe succeeds. */ -final class AnimalEstates implements EstateParticipant { - @Override public String id() { return "breedingbuddies-animals"; } - static boolean guard(JSONObject journal) { - try { - var stored = (JSONObject) journal.get("estate"); - return Estates.decision(UUID.fromString((String) stored.get("death"))) - .filter(d -> d.revision() == ((Number) stored.get("revision")).longValue() && d.toString().equals(stored.get("decision")) && AnimalOwnershipStore.ref(d.testator()).equals(stored.get("from")) - && d.property().character().map(AnimalOwnershipStore::ref).equals(Optional.ofNullable(stored.get("to")))) - .filter(Estates::guardHolds).isPresent(); - } catch (RuntimeException failure) { return false; } - } - @Override public Holdings describe(UUID character) { - AnimalOwnershipStore store = PluginData.ownership(); - if (store == null) return Holdings.NONE; - List labels = new ArrayList<>(); - for (var entry : store.rows.entrySet()) { - try { if (AnimalOwnershipStore.owners(entry.getValue()).stream().anyMatch(ref -> ref.characterId().equals(character))) labels.add(entry.getKey().toString() + (store.held(entry.getKey()) ? " (held)" : "")); } - catch (RuntimeException failure) { labels.add(entry.getKey() + " (invalid ownership)"); } - } - return new Holdings(Map.of("animals", labels.size()), labels); - } - @Override public Outcome settle(EstateDecision decision) { - AnimalOwnershipStore store = PluginData.ownership(); - if (store == null) return Outcome.retry("Animal store unavailable"); - if (!Estates.guardHolds(decision)) return Outcome.retry("Estate guard changed"); - try { - Set moved = new TreeSet<>(); - for (var path : store.files("ownership/journals")) { - JSONObject journal = AnimalFiles.object(Files.readString(path)); - if (!(journal.get("estate") instanceof JSONObject previous)) continue; - if (!decision.deathId().toString().equals(previous.get("death"))) continue; - store.validateReceipt(path, journal); - if (!AnimalOwnershipStore.ref(decision.testator()).equals(previous.get("from"))) return Outcome.blocked("Estate testator conflicts with receipt"); - if (((Number) previous.get("revision")).longValue() == decision.revision() - && (!decision.toString().equals(previous.get("decision")) - || !decision.property().character().map(AnimalOwnershipStore::ref).equals(Optional.ofNullable(previous.get("to"))))) return Outcome.blocked("Same-revision animal decision conflicts"); - if (!Boolean.TRUE.equals(journal.get("done"))) { - if (!guard(journal)) return Outcome.blocked("Stale or incomplete animal estate fanout"); - store.publish(path, journal); store.load(); store.ready = true; PluginData.rebuildAnimals(); - } - for (Object id : (JSONArray) journal.get("animals")) moved.add((String) id); - } - if (!store.ready) return Outcome.blocked("Animal store requires migration/recovery"); - for (var entry : store.rows.entrySet()) { - if (moved.contains(entry.getKey().toString()) && AnimalOwnershipStore.owners(entry.getValue()).contains(decision.testator())) return Outcome.blocked("Completed animal restored to testator " + entry.getKey()); - } - if (decision.property().kind() != Recipient.Kind.CHARACTER) return done(moved); - CharacterRef target = decision.property().character().orElseThrow(); - if (target.equals(decision.testator())) return done(moved); - boolean hasSource = false; - for (var entry : store.rows.entrySet()) { - Set owners = AnimalOwnershipStore.owners(entry.getValue()); - if (!owners.contains(decision.testator())) continue; - if (store.held(entry.getKey())) return Outcome.blocked("Held animal " + entry.getKey()); - if (owners.stream().anyMatch(owner -> owner.characterId().equals(target.characterId()) && !owner.equals(target))) return Outcome.blocked("Recipient account conflicts " + entry.getKey()); - hasSource = true; - } - if (hasSource) { - PluginData.saveAnimals(); - if (!Estates.guardHolds(decision)) return Outcome.retry("Estate guard changed after care flush"); - } - Map original = new LinkedHashMap<>(store.rows); - JSONArray current = new JSONArray(); JSONObject canonical = new JSONObject(); - Map planned = new LinkedHashMap<>(); - for (var entry : store.rows.entrySet()) { - Set owners = AnimalOwnershipStore.owners(entry.getValue()); - if (!owners.contains(decision.testator())) continue; - if (store.held(entry.getKey())) return Outcome.blocked("Held animal " + entry.getKey()); - if (owners.stream().anyMatch(owner -> owner.characterId().equals(target.characterId()) && !owner.equals(target))) return Outcome.blocked("Recipient account conflicts " + entry.getKey()); - FarmAnimal live = OwnershipManager.getAnimal(entry.getKey()); - JSONObject row = live == null ? AnimalFiles.copy(entry.getValue()) : AnimalCodec.patch(entry.getValue(), live); - row.put("characterOwners", entry.getValue().get("characterOwners")); - JSONArray slots = new JSONArray(); - for (Object value : (JSONArray) row.get("characterOwners")) if (!AnimalOwnershipStore.parseRef((JSONObject) value).equals(decision.testator())) slots.add(value); - if (!owners.contains(target)) slots.add(AnimalOwnershipStore.ref(target)); - row.put("characterOwners", slots); planned.put(entry.getKey(), row); current.add(entry.getKey().toString()); canonical.put(entry.getKey().toString(), row); - } - if (!current.isEmpty()) { - JSONObject estate = new JSONObject(); estate.put("death", decision.deathId().toString()); estate.put("revision", decision.revision()); estate.put("decision", decision.toString()); - estate.put("from", AnimalOwnershipStore.ref(decision.testator())); estate.put("to", AnimalOwnershipStore.ref(target)); - JSONObject metadata = new JSONObject(); metadata.put("kind", "estate"); metadata.put("estate", estate); metadata.put("animals", current); metadata.put("canonical", canonical); - try { store.rows.putAll(planned); store.commit(store.fanout(), metadata); store.load(); PluginData.rebuildAnimals(); } - catch (IOException | RuntimeException failure) { store.rows.clear(); store.rows.putAll(original); throw failure; } - for (Object id : current) moved.add((String) id); - } - return done(moved); - } catch (Exception failure) { - store.ready = false; - return (failure instanceof IOException || failure.getCause() instanceof IOException) && store.blocked() == null ? Outcome.retry("Animal estate I/O: " + failure.getMessage()) : Outcome.blocked(store.blocked() == null ? "Invalid animal estate metadata: " + failure.getMessage() : store.blocked()); - } - } - private static Outcome done(Set moved) { return Outcome.done(Map.of("animals", moved.size()), List.copyOf(moved)); } - static void start(Plugin plugin) { - AnimalEstates participant = new AnimalEstates(); - Bukkit.getServicesManager().register(EstateParticipant.class, participant, plugin, ServicePriority.Normal); - Bukkit.getScheduler().runTaskTimer(plugin, () -> { - for (EstateDecision decision : Estates.decided()) { - try { Estates.report(decision.deathId(), participant.id(), decision.revision(), participant.settle(decision)); } - catch (RuntimeException failure) { plugin.getLogger().warning("Animal estate " + decision.deathId() + " revision " + decision.revision() + ": " + failure.getMessage()); } - } - }, 3L, 20L * 60 * 60); - } -} diff --git a/src/main/java/net/tfminecraft/breedingbuddies/AnimalFiles.java b/src/main/java/net/tfminecraft/breedingbuddies/AnimalFiles.java deleted file mode 100644 index 113d50d..0000000 --- a/src/main/java/net/tfminecraft/breedingbuddies/AnimalFiles.java +++ /dev/null @@ -1,52 +0,0 @@ -package net.tfminecraft.breedingbuddies; - -import org.json.simple.JSONObject; -import org.json.simple.parser.JSONParser; -import org.json.simple.parser.ContentHandler; -import org.json.simple.parser.ParseException; -import java.util.*; -import java.io.IOException; -import java.nio.ByteBuffer; -import java.nio.channels.FileChannel; -import java.nio.charset.StandardCharsets; -import java.nio.file.*; -import static java.nio.file.StandardOpenOption.*; - -final class AnimalFiles { - private AnimalFiles() {} - static JSONObject object(String text) throws IOException { - try { - new JSONParser().parse(text, new ContentHandler() { - final Deque> keys = new ArrayDeque<>(); - public void startJSON() {} public void endJSON() {} - public boolean startObject() { keys.push(new HashSet<>()); return true; } - public boolean endObject() { keys.pop(); return true; } - public boolean startObjectEntry(String key) throws ParseException { - if (!keys.peek().add(key)) throw new ParseException(ParseException.ERROR_UNEXPECTED_TOKEN, "Duplicate object key " + key); - return true; - } - public boolean endObjectEntry() { return true; } - public boolean startArray() { return true; } public boolean endArray() { return true; } - public boolean primitive(Object value) { return true; } - }); - return (JSONObject) new JSONParser().parse(text); - } - catch (Exception failure) { throw new IOException("Invalid animal document", failure); } - } - static JSONObject copy(JSONObject value) { try { return object(value.toJSONString()); } catch (IOException impossible) { throw new IllegalStateException(impossible); } } - static String read(Path path) throws IOException { return Files.exists(path) ? Files.readString(path) : null; } - static void write(Path path, String text) throws IOException { - Files.createDirectories(path.getParent()); - for (Path parent = path.toAbsolutePath().getParent(); parent != null; parent = parent.getParent()) { - try (FileChannel directory = FileChannel.open(parent, READ)) { directory.force(true); } - } - Path temporary = path.resolveSibling(path.getFileName() + ".next"); - try (FileChannel file = FileChannel.open(temporary, CREATE, TRUNCATE_EXISTING, WRITE)) { - ByteBuffer bytes = StandardCharsets.UTF_8.encode(text); - while (bytes.hasRemaining()) file.write(bytes); - file.force(true); - } - Files.move(temporary, path, StandardCopyOption.ATOMIC_MOVE, StandardCopyOption.REPLACE_EXISTING); - try (FileChannel directory = FileChannel.open(path.getParent(), READ)) { directory.force(true); } - } -} diff --git a/src/main/java/net/tfminecraft/breedingbuddies/AnimalHeirs.java b/src/main/java/net/tfminecraft/breedingbuddies/AnimalHeirs.java deleted file mode 100644 index 3536f6e..0000000 --- a/src/main/java/net/tfminecraft/breedingbuddies/AnimalHeirs.java +++ /dev/null @@ -1,8 +0,0 @@ -package net.tfminecraft.breedingbuddies; -import net.tfminecraft.tlibs.character.Characters; -import java.io.IOException; -/** Linked only after the heir API capability probe. */ -final class AnimalHeirs { - private AnimalHeirs() {} - static void open(AnimalOwnershipStore store) throws IOException { store.open(Characters.legacyHeirsReady(), Characters::legacyHeir); } -} diff --git a/src/main/java/net/tfminecraft/breedingbuddies/AnimalIdentity.java b/src/main/java/net/tfminecraft/breedingbuddies/AnimalIdentity.java deleted file mode 100644 index 746042c..0000000 --- a/src/main/java/net/tfminecraft/breedingbuddies/AnimalIdentity.java +++ /dev/null @@ -1,51 +0,0 @@ -package net.tfminecraft.breedingbuddies; - -import net.tfminecraft.tlibs.character.*; -import org.bukkit.Bukkit; -import org.bukkit.entity.Player; -import org.bukkit.event.EventHandler; -import org.bukkit.event.Listener; -import org.bukkit.plugin.Plugin; -import java.util.*; - -public final class AnimalIdentity implements Listener { - private AnimalIdentity() {} - public static Optional session(Player player) { return player == null ? Optional.empty() : Characters.currentSession(player).filter(s -> current(s, player)); } - public static boolean current(CharacterSession session, Player player) { return player != null && session != null && session.ref().ownerId().equals(player.getUniqueId()) && Characters.isCurrent(session); } - public static Optional session(UUID account) { return session(Bukkit.getPlayer(account)); } - public static boolean supports(ClassLoader loader, boolean estates) { - try { - Class api = Class.forName("net.tfminecraft.tlibs.character.Characters", false, loader); - api.getMethod("legacyHeirsReady"); api.getMethod("legacyHeir", UUID.class); - if (estates) for (String name : new String[]{"Estates", "EstateParticipant", "EstateDecision", "Outcome", "Holdings"}) Class.forName("net.tfminecraft.tlibs.character.estate." + name, false, loader); - return true; - } catch (ReflectiveOperationException | LinkageError failure) { return false; } - } - static void open(AnimalOwnershipStore store) { - try { - if (supports(AnimalIdentity.class.getClassLoader(), true)) store.guard = AnimalEstates::guard; - if (supports(AnimalIdentity.class.getClassLoader(), false)) AnimalHeirs.open(store); - else store.open(false, ignored -> Optional.empty()); - if (store.ready && !store.held.isEmpty()) Bukkit.getLogger().warning("[BreedingBuddies] Held animal records: " + store.held); - if (!store.ready) Bukkit.getLogger().warning("[BreedingBuddies] Animal ownership held: " + store.blocked()); - } catch (Exception failure) { store.ready = false; Bukkit.getLogger().warning("[BreedingBuddies] Animal ownership held: " + failure.getMessage()); } - } - static void start(Plugin plugin) { - Bukkit.getPluginManager().registerEvents(new AnimalIdentity(), plugin); - if (supports(AnimalIdentity.class.getClassLoader(), true)) AnimalEstates.start(plugin); - Bukkit.getScheduler().runTaskTimer(plugin, () -> { - AnimalOwnershipStore store = PluginData.ownership(); - if (store != null && !store.ready) { open(store); PluginData.rebuildAnimals(); } - }, 2L, 20L * 60 * 60); - } - @EventHandler public void ended(CharacterSessionEndEvent event) { - Player player = event.getPlayer(); - if (!event.getSession().ref().ownerId().equals(player.getUniqueId())) return; - if (player.getVehicle() != null) player.leaveVehicle(); - if (player.getOpenInventory().getTopInventory().getHolder() instanceof AnimalInventoryHolder holder && event.getSession().equals(holder.session())) player.closeInventory(); - } - @EventHandler public void started(CharacterSessionStartEvent event) { - Player player = event.getPlayer(); - if (player.getVehicle() != null && (AnimalUtils.isFarmAnimal(player.getVehicle()) || PluginData.knowsAnimal(player.getVehicle().getUniqueId())) && OwnershipManager.hasOwner(player.getVehicle().getUniqueId()) && !OwnershipManager.isOwner(player.getUniqueId(), player.getVehicle().getUniqueId())) player.leaveVehicle(); - } -} diff --git a/src/main/java/net/tfminecraft/breedingbuddies/AnimalInventoryHolder.java b/src/main/java/net/tfminecraft/breedingbuddies/AnimalInventoryHolder.java deleted file mode 100644 index aa0a273..0000000 --- a/src/main/java/net/tfminecraft/breedingbuddies/AnimalInventoryHolder.java +++ /dev/null @@ -1,32 +0,0 @@ -package net.tfminecraft.breedingbuddies; - -import org.bukkit.Bukkit; -import org.bukkit.inventory.Inventory; -import org.bukkit.inventory.InventoryHolder; - -import java.util.UUID; - -public class AnimalInventoryHolder implements InventoryHolder { - private final UUID entityUUID; - private final net.tfminecraft.tlibs.character.CharacterSession session; - public net.tfminecraft.tlibs.character.CharacterSession session() { return session; } - private final Inventory inventory; - - public AnimalInventoryHolder(UUID entityUUID, int size, String title) { - this(entityUUID, size, title, null); - } - - public AnimalInventoryHolder(UUID entityUUID, int size, String title, net.tfminecraft.tlibs.character.CharacterSession session) { - this.entityUUID = entityUUID; - this.session = session; - this.inventory = Bukkit.createInventory(this, size, title); - } - - public UUID getEntityUUID() { - return entityUUID; - } - @Override - public Inventory getInventory() { - return inventory; - } -} diff --git a/src/main/java/net/tfminecraft/breedingbuddies/AnimalOwnershipStore.java b/src/main/java/net/tfminecraft/breedingbuddies/AnimalOwnershipStore.java deleted file mode 100644 index 17bf871..0000000 --- a/src/main/java/net/tfminecraft/breedingbuddies/AnimalOwnershipStore.java +++ /dev/null @@ -1,281 +0,0 @@ -package net.tfminecraft.breedingbuddies; - -import net.tfminecraft.tlibs.character.CharacterRef; -import org.json.simple.JSONArray; -import org.json.simple.JSONObject; -import java.io.IOException; -import java.nio.file.*; -import java.util.*; -import java.util.function.Function; -import java.util.function.Predicate; - -/** Character file fanout; journals are recovery evidence, not a second animal database. */ -final class AnimalOwnershipStore { - final Path root; - final Map rows = new LinkedHashMap<>(); - final Map held = new LinkedHashMap<>(); - final Map documents = new LinkedHashMap<>(); - final Map expected = new LinkedHashMap<>(); - Predicate guard = ignored -> false; - boolean ready; - String tokenEffectId, lastJournalId, lastJournalToken; - private String blocked; - AnimalOwnershipStore(Path root) { this.root = root; } - Path path(String relative) throws IOException { - Path result = root.resolve(relative).normalize(); - if (!result.startsWith(root.normalize()) || Path.of(relative).isAbsolute()) throw new IOException("Unsafe journal path"); - return result; - } - List files(String directory) throws IOException { - Path folder = path(directory); - if (!Files.exists(folder)) return List.of(); - try (var stream = Files.list(folder)) { return stream.filter(p -> p.getFileName().toString().endsWith(".json")).sorted().toList(); } - } - String relative(Path path) { return root.relativize(path).toString(); } - void open(boolean heirsReady, Function> heirs) throws IOException { - ready = false; blocked = null; - verifySources(); - recover(); - if (!Files.exists(path("ownership/migration.json"))) { - if (!heirsReady) { blocked = "Reviewed legacy heirs are unavailable"; return; } - migrate(heirs); - } - load(); - ready = true; - } - void recover() throws IOException { - for (Path journalPath : files("ownership/journals")) { - JSONObject journal = AnimalFiles.object(Files.readString(journalPath)); - if (!Boolean.TRUE.equals(journal.get("done"))) publish(journalPath, journal); - } - } - String blocked() { return blocked; } - void verifySources() throws IOException { - Path archive = path("ownership/source.json"); - if (!Files.exists(archive)) return; - JSONObject sources = AnimalFiles.object(Files.readString(archive)); - Set actual = new HashSet<>(); - for (Path file : files("PlayerData")) actual.add(relative(file)); - if (Files.exists(path("unownedanimals.json"))) actual.add("unownedanimals.json"); - if (!actual.equals(sources.keySet())) throw new IOException("Legacy file inventory changed after archive"); - for (Object key : sources.keySet()) if (!Objects.equals(AnimalFiles.read(path((String) key)), sources.get(key))) throw new IOException("Legacy source changed after plan: " + key); - } - void validateReceipt(Path journalPath, JSONObject journal) throws IOException { - try { AnimalReplay.validate(this, journalPath, journal); } - catch (Exception failure) { blocked = "Invalid animal replay: " + failure.getMessage(); throw new IOException(blocked, failure); } - } - void publish(Path journalPath, JSONObject journal) throws IOException { - validateReceipt(journalPath, journal); - verifySources(); - JSONArray outputs = (JSONArray) journal.get("outputs"); - for (Object item : outputs) { - JSONObject output = (JSONObject) item; - String actual = AnimalFiles.read(path((String) output.get("path"))); - if (!Objects.equals(actual, output.get("before")) && !Objects.equals(actual, output.get("after"))) { - blocked = "Conflicting animal output " + output.get("path"); throw new IOException(blocked); - } - } - for (Object item : outputs) { - if (journal.containsKey("estate") && !guard.test(journal)) { - blocked = "Stale or unavailable estate decision " + journal.get("id"); throw new IOException(blocked); - } - JSONObject output = (JSONObject) item; - Path destination = path((String) output.get("path")); - String actual = AnimalFiles.read(destination); - if (!Objects.equals(actual, output.get("before")) && !Objects.equals(actual, output.get("after"))) { - blocked = "Conflicting animal output " + output.get("path"); throw new IOException(blocked); - } - if (!Objects.equals(actual, output.get("after"))) AnimalFiles.write(destination, (String) output.get("after")); - } - if (journal.containsKey("estate") && !guard.test(journal)) { blocked = "Estate guard changed before receipt"; throw new IOException(blocked); } - journal.put("done", true); - AnimalFiles.write(journalPath, journal.toJSONString()); - if ("gameplay".equals(journal.get("kind")) && !journal.containsKey("tokenEffect")) { - try { prune(journalPath); } - catch (IOException ignored) { /* Durable completion is already acknowledged; an inert receipt may remain. */ } - } - } - void prune(Path journalPath) throws IOException { - Files.deleteIfExists(journalPath); - try (var directory = java.nio.channels.FileChannel.open(journalPath.getParent(), StandardOpenOption.READ)) { directory.force(true); } - } - void commit(Map outputs, JSONObject metadata) throws IOException { - if (blocked != null) throw new IOException(blocked); - JSONObject journal = AnimalFiles.copy(metadata); - journal.put("format", 1L); - journal.putIfAbsent("kind", "gameplay"); - JSONObject canonical = new JSONObject(); - rows.forEach((id, row) -> { if (!held.containsKey(id)) canonical.put(id.toString(), AnimalFiles.copy(row)); }); - journal.put("canonical", canonical); - boolean unfinishedToken = false; - if (!"migration".equals(journal.get("kind"))) { - JSONObject holding = new JSONObject();held.forEach((id, reason) -> holding.put(id.toString(), reason));journal.put("held", holding); - JSONObject tokens = new JSONObject(); - for (Path receipt : files("ownership/token-effects")) { - JSONObject effect = AnimalFiles.object(Files.readString(receipt)); - if (!"done".equals(effect.get("status"))) unfinishedToken = true; - if (held.containsKey(UUID.fromString((String) effect.get("animal"))) && !"done".equals(effect.get("status"))) { - if (!receipt.getFileName().toString().equals(effect.get("id") + ".json")) throw new IOException("Token effect ID/path conflict"); - tokens.put(effect.get("id"), effect); - } - } - journal.put("tokenHolds", tokens); - } - if (tokenEffectId != null) journal.put("tokenEffect", tokenEffectId); - String id = String.format("%013d", System.currentTimeMillis()) + "-" + UUID.randomUUID(); - journal.put("id", id); journal.put("done", false); - JSONArray writes = new JSONArray(); - for (var entry : outputs.entrySet()) { - String before = AnimalFiles.read(path(entry.getKey())); - if (expected.containsKey(entry.getKey()) && !Objects.equals(before, expected.get(entry.getKey()))) throw new IOException("Post-load animal file edit " + entry.getKey()); - JSONObject output = new JSONObject(); output.put("path", entry.getKey()); output.put("before", before); output.put("after", entry.getValue().toJSONString()); writes.add(output); - } - journal.put("outputs", writes); - Path receipt = path("ownership/journals/" + id + ".json"); - if ("gameplay".equals(journal.get("kind")) && tokenEffectId == null && !unfinishedToken - && writes.stream().allMatch(value -> Objects.equals(((JSONObject) value).get("before"), ((JSONObject) value).get("after")))) { - verifySources(); - if (!outputs.keySet().equals(expected.keySet())) throw new IOException("Unchanged animal output inventory differs from loaded files"); - Set inventory = new HashSet<>(); - for (Path current : files("CharacterData")) inventory.add(relative(current)); - if (Files.exists(path("character-unowned.json"))) inventory.add("character-unowned.json"); - if (!inventory.equals(expected.keySet())) throw new IOException("Post-load animal file inventory changed"); - validateReceipt(receipt, journal); - documents.clear(); documents.putAll(outputs); - return; - } - AnimalFiles.write(receipt, journal.toJSONString()); - ready = false; - publish(receipt, journal); - lastJournalId = id; - lastJournalToken = tokenEffectId; - outputs.forEach((name, document) -> expected.put(name, document.toJSONString())); - ready = true; - } - void migrate(Function> heirs) throws IOException { - if (!files("CharacterData").isEmpty() || Files.exists(path("character-unowned.json"))) throw new IOException("Unjournaled character files require review"); - Map> fixed = new HashMap<>(); - Path heirPlan = path("ownership/legacy-heirs.json"); - if (Files.exists(heirPlan)) { - JSONObject prior = AnimalFiles.object(Files.readString(heirPlan)); - prior.forEach((account, ref) -> fixed.put(UUID.fromString((String) account), Optional.ofNullable(ref).map(value -> parseRef((JSONObject) value)))); - heirs = account -> fixed.getOrDefault(account, Optional.empty()); - } - Function> lookup = heirs; - Function> reviewed = account -> fixed.computeIfAbsent(account, lookup); - List sources = new ArrayList<>(files("PlayerData")); - Path unowned = path("unownedanimals.json"); if (Files.exists(unowned)) sources.add(unowned); - JSONObject backup = new JSONObject(), inventory = new JSONObject(); - Map> copies = new LinkedHashMap<>(); - for (Path source : sources) { - String name = relative(source), text = Files.readString(source); backup.put(name, text); - JSONObject document = AnimalFiles.object(text); - if (document.containsKey("owner") || document.containsKey("format")) throw new IOException("Reserved character envelope fields in legacy source " + name); - JSONArray animals = (JSONArray) document.get("animals"); - if (animals == null) throw new IOException("Missing animal inventory " + name); - UUID filename = source.equals(unowned) ? null : UUID.fromString(source.getFileName().toString().replace(".json", "")); - if (filename == null) { - JSONObject destination = AnimalFiles.copy(document);destination.put("format",1L);destination.put("animals",new JSONArray());documents.put("character-unowned.json",destination); - } - if (filename != null) { - var heir = reviewed.apply(filename).filter(ref -> ref.ownerId().equals(filename)); - if (heir.isPresent()) { - JSONObject destination = AnimalFiles.copy(document); destination.put("format", 1L); destination.put("owner", ref(heir.get())); destination.put("animals", new JSONArray()); - documents.put("CharacterData/" + heir.get().characterId() + ".json", destination); - } - } - for (Object value : animals) { - JSONObject row = AnimalFiles.copy((JSONObject) value); - if (row.containsKey("characterOwners") || row.containsKey("heldOwners")) throw new IOException("Reserved character fields in legacy animal " + row.get("uuid")); - UUID animal = UUID.fromString((String) row.get("uuid")); - Set owners = new TreeSet<>(); - for (Object owner : (JSONArray) row.get("ownersUuids")) owners.add(UUID.fromString((String) owner).toString()); - if (filename != null) owners.add(filename.toString()); - JSONArray effective = new JSONArray(); effective.addAll(owners); row.put("ownersUuids", effective); - copies.computeIfAbsent(animal, ignored -> new ArrayList<>()).add(row); - } - } - Path archived = path("ownership/source.json"); - if (Files.exists(archived) && !AnimalFiles.object(Files.readString(archived)).equals(backup)) throw new IOException("Legacy source changed after archive"); - if (!Files.exists(archived)) AnimalFiles.write(archived, backup.toJSONString()); - JSONObject canonical = new JSONObject(), heldPlan = new JSONObject(); - for (var entry : copies.entrySet()) { - JSONObject row = entry.getValue().getFirst(); - if (entry.getValue().stream().anyMatch(copy -> !copy.equals(row))) { heldPlan.put(entry.getKey().toString(), "Conflicting legacy copies"); continue; } - JSONObject migrated = AnimalFiles.copy(row); JSONArray owners = new JSONArray(), heldOwners = new JSONArray(); - for (Object value : (JSONArray) row.get("ownersUuids")) { - UUID account = UUID.fromString((String) value); Optional heir = reviewed.apply(account); - if (heir.isPresent() && account.equals(heir.get().ownerId())) owners.add(ref(heir.get())); - else { JSONObject slot = new JSONObject(); slot.put("account", value); slot.put("reason", "No reviewed heir or wrong account"); heldOwners.add(slot); } - } - migrated.put("characterOwners", owners); migrated.put("heldOwners", heldOwners); - rows.put(entry.getKey(), migrated); canonical.put(entry.getKey().toString(), migrated); - } - JSONObject mappings = new JSONObject();fixed.forEach((account, heir) -> mappings.put(account.toString(), heir.filter(value -> value.ownerId().equals(account)).map(AnimalOwnershipStore::ref).orElse(null))); - if (!Files.exists(heirPlan)) AnimalFiles.write(heirPlan, mappings.toJSONString()); - inventory.put("format", 1L); inventory.put("canonical", canonical); inventory.put("held", heldPlan); inventory.put("sources", backup); - Map outputs = fanout(); outputs.put("ownership/migration.json", inventory); - JSONObject metadata = new JSONObject(); metadata.put("kind", "migration"); metadata.put("canonical", canonical); metadata.put("held", heldPlan); - commit(outputs, metadata); - } - void load() throws IOException { - rows.clear(); held.clear(); documents.clear(); expected.clear(); - JSONObject migration = AnimalFiles.object(Files.readString(path("ownership/migration.json"))); - ((JSONObject) migration.get("held")).forEach((id, reason) -> held.put(UUID.fromString((String) id), (String) reason)); - List sources = new ArrayList<>(files("CharacterData")); - Path unowned = path("character-unowned.json"); if (Files.exists(unowned)) sources.add(unowned); - for (Path source : sources) { - String text = Files.readString(source), name = relative(source); JSONObject document = AnimalFiles.object(text); - documents.put(name, document); expected.put(name, text); - CharacterRef filename = source.equals(unowned) ? null : parseRef((JSONObject) document.get("owner")); - if (filename != null && !source.getFileName().toString().equals(filename.characterId() + ".json")) throw new IOException("Character filename disagrees with ref"); - for (Object value : (JSONArray) document.get("animals")) { - JSONObject row = (JSONObject) value; UUID animal = UUID.fromString((String) row.get("uuid")); - JSONObject existing = rows.putIfAbsent(animal, row); - if ((existing != null && !existing.equals(row)) || (filename != null && !owners(row).contains(filename)) || (filename == null && !owners(row).isEmpty())) held.put(animal, "Conflicting character fanout"); - } - } - for (Path receipt : files("ownership/token-effects")) { - JSONObject effect = AnimalFiles.object(Files.readString(receipt)); - if (!"done".equals(effect.get("status"))) held.put(UUID.fromString((String) effect.get("animal")), "Unfinished token effect " + effect.get("id")); - } - } - Map fanout() throws IOException { - Map output = new LinkedHashMap<>(); - for (var entry : documents.entrySet()) { - JSONObject document = AnimalFiles.copy(entry.getValue()); JSONArray preserved = new JSONArray(); - for (Object row : (JSONArray) document.get("animals")) if (held.containsKey(UUID.fromString((String) ((JSONObject) row).get("uuid")))) preserved.add(row); - document.put("animals", preserved); output.put(entry.getKey(), document); - } - output.computeIfAbsent("character-unowned.json", ignored -> document(null)); - for (var entry : rows.entrySet()) { - if (held.containsKey(entry.getKey())) continue; - JSONObject row = entry.getValue(); Set owners = owners(row); - if (owners.isEmpty()) ((JSONArray) output.get("character-unowned.json").get("animals")).add(row); - for (CharacterRef owner : owners) { - String name = "CharacterData/" + owner.characterId() + ".json"; - JSONObject document = output.computeIfAbsent(name, ignored -> document(owner)); - if (!owner.equals(parseRef((JSONObject) document.get("owner")))) throw new IOException("Conflicting character account " + name); - ((JSONArray) document.get("animals")).add(row); - } - } - return output; - } - void save(Collection animals) throws IOException { - if (!ready) throw new IOException("Animal ownership unavailable"); - for (FarmAnimal animal : animals) if (!held.containsKey(animal.getUuid())) rows.put(animal.getUuid(), AnimalCodec.patch(rows.get(animal.getUuid()), animal)); - Map outputs = fanout(); - JSONObject metadata = new JSONObject(); metadata.put("kind", "gameplay"); commit(outputs, metadata); - documents.clear(); documents.putAll(outputs); - } - void forget(UUID animal) throws IOException { if (!ready || held.containsKey(animal)) throw new IOException("Held animal"); rows.remove(animal); } - boolean held(UUID animal) { return !ready || held.containsKey(animal); } - boolean hasOwner(UUID animal) { - if (held(animal)) return true; - JSONObject row = rows.get(animal); return row != null && (!owners(row).isEmpty() || !((JSONArray) row.get("heldOwners")).isEmpty()); - } - static JSONObject document(CharacterRef ref) { JSONObject doc = new JSONObject(); doc.put("format", 1L); if (ref != null) doc.put("owner", ref(ref)); doc.put("animals", new JSONArray()); return doc; } - static JSONObject ref(CharacterRef ref) { JSONObject value = new JSONObject(); value.put("account", ref.ownerId().toString()); value.put("character", ref.characterId().toString()); return value; } - static CharacterRef parseRef(JSONObject value) { return new CharacterRef(UUID.fromString((String) value.get("account")), UUID.fromString((String) value.get("character"))); } - static Set owners(JSONObject row) { Set refs = new LinkedHashSet<>(); for (Object value : (JSONArray) row.get("characterOwners")) refs.add(parseRef((JSONObject) value)); return refs; } -} diff --git a/src/main/java/net/tfminecraft/breedingbuddies/AnimalReplay.java b/src/main/java/net/tfminecraft/breedingbuddies/AnimalReplay.java deleted file mode 100644 index c4d9b91..0000000 --- a/src/main/java/net/tfminecraft/breedingbuddies/AnimalReplay.java +++ /dev/null @@ -1,184 +0,0 @@ -package net.tfminecraft.breedingbuddies; - -import net.tfminecraft.tlibs.character.CharacterRef; -import org.json.simple.*; -import java.io.IOException; -import java.nio.file.*; -import java.util.*; - -/** Validate semantics from captured source bytes before trusting any replay output. */ -final class AnimalReplay { - private AnimalReplay() {} - private static void require(boolean valid, String cause) throws IOException { if (!valid) throw new IOException(cause); } - static void validate(AnimalOwnershipStore store, Path path, JSONObject journal) throws IOException { - require(Long.valueOf(1).equals(journal.get("format")),"Unsupported animal journal version"); - String kind=(String)journal.get("kind");require(Set.of("migration","gameplay","estate").contains(kind),"Unsupported journal kind"); - require(path.getFileName().toString().equals(journal.get("id")+".json"),"Journal ID/path conflict"); - boolean estate=kind.equals("estate"); - require(estate==journal.containsKey("estate")&&estate==journal.containsKey("animals"),"Journal kind/envelope conflict"); - JSONObject canonical=(JSONObject)journal.get("canonical"),holding=(JSONObject)journal.get("held"); - Map before=new LinkedHashMap<>(),after=new LinkedHashMap<>();JSONObject migration=null; - for(Object value:(JSONArray)journal.get("outputs")) { - JSONObject output=(JSONObject)value;String name=(String)output.get("path");store.path(name); - require(!after.containsKey(name),"Duplicate output path"); - if(name.equals("ownership/migration.json")) { - require(kind.equals("migration")&&migration==null&&output.get("before")==null,"Unexpected migration output"); - migration=AnimalFiles.object((String)output.get("after"));continue; - } - CharacterRef owner=owner(name,AnimalFiles.object((String)output.get("after"))); - JSONObject desired=AnimalFiles.object((String)output.get("after"));after.put(name,desired); - if(output.get("before")!=null) { - JSONObject prior=AnimalFiles.object((String)output.get("before"));require(Objects.equals(owner,owner(name,prior)),"Document owner changed");before.put(name,prior); - JSONObject first=AnimalFiles.copy(prior),second=AnimalFiles.copy(desired);first.remove("animals");second.remove("animals");require(first.equals(second),"Envelope fields changed"); - } else if(!kind.equals("migration")) { - JSONObject blank=AnimalOwnershipStore.document(owner);blank.put("animals",desired.get("animals"));require(blank.equals(desired),"Unexpected new envelope fields"); - } - } - if(!kind.equals("migration")) validateHolding(store,before,holding,(JSONObject)journal.get("tokenHolds")); - Map planned=rows(canonical),original=collect(before,holding); - require(Collections.disjoint(planned.keySet(),holding.keySet()),"Held animal in canonical plan"); - if(kind.equals("estate")) estate(journal,original,planned); - if(kind.equals("migration")) { - require(before.isEmpty()&&migration!=null,"Migration overwrites character documents"); - migration(store,migration,canonical,holding,after); - } else require(migration==null,"Unexpected migration manifest"); - Set expected=new HashSet<>(before.keySet());expected.add("character-unowned.json"); - if(kind.equals("migration")) { - JSONObject sources=(JSONObject)migration.get("sources"),heirs=AnimalFiles.object(Files.readString(store.path("ownership/legacy-heirs.json"))); - for(Object key:sources.keySet())if(!key.equals("unownedanimals.json")) { - String account=Path.of((String)key).getFileName().toString().replace(".json",""); - if(heirs.get(account) instanceof JSONObject ref)expected.add("CharacterData/"+AnimalOwnershipStore.parseRef(ref).characterId()+".json"); - } - } - for(JSONObject row:planned.values())for(CharacterRef ref:AnimalOwnershipStore.owners(row))expected.add("CharacterData/"+ref.characterId()+".json"); - require(expected.equals(after.keySet()),"Unexpected/missing output roster path"); - for(var entry:after.entrySet()) { - CharacterRef owner=owner(entry.getKey(),entry.getValue());Map actual=animalRows(entry.getValue());Set wanted=new HashSet<>(); - for(var row:planned.entrySet()) { - Set owners=AnimalOwnershipStore.owners(row.getValue());boolean belongs=owner==null?owners.isEmpty():owners.contains(owner); - if(belongs){wanted.add(row.getKey());require(row.getValue().equals(actual.get(row.getKey())),"Canonical animal output changed "+row.getKey());} - } - JSONObject prior=before.get(entry.getKey());Map priorRows=prior==null?Map.of():animalRows(prior); - for(Object id:holding.keySet())if(priorRows.containsKey(id)){wanted.add((String)id);require(priorRows.get(id).equals(actual.get(id)),"Held animal copy changed");} - require(wanted.equals(actual.keySet()),"Extra/missing animal in output roster"); - } - } - private static CharacterRef owner(String path, JSONObject document) throws IOException { - require(Long.valueOf(1).equals(document.get("format")),"Unsupported character document version"); - require(document.get("animals") instanceof JSONArray,"Missing animal array"); - if(path.equals("character-unowned.json")){require(!document.containsKey("owner"),"Unowned document has an owner");return null;} - CharacterRef ref=AnimalOwnershipStore.parseRef((JSONObject)document.get("owner")); - require(path.equals("CharacterData/"+ref.characterId()+".json"),"Character output path/owner mismatch");return ref; - } - private static Map rows(JSONObject canonical) throws IOException { - Map rows=new LinkedHashMap<>(); - for(Object key:canonical.keySet()) { - UUID id=UUID.fromString((String)key);JSONObject row=(JSONObject)canonical.get(key);require(id.toString().equals(row.get("uuid")),"Canonical UUID changed"); - Set characters=new HashSet<>();for(Object value:(JSONArray)row.get("characterOwners")){CharacterRef ref=AnimalOwnershipStore.parseRef((JSONObject)value);require(characters.add(ref.characterId()),"Duplicate character slot");} - require(row.get("heldOwners") instanceof JSONArray,"Missing held slots");rows.put((String)key,row); - } - return rows; - } - private static Map animalRows(JSONObject document) throws IOException { - Map rows=new LinkedHashMap<>();for(Object value:(JSONArray)document.get("animals")){JSONObject row=(JSONObject)value;String id=(String)row.get("uuid");UUID.fromString(id);require(rows.put(id,row)==null,"Duplicate animal in document");}return rows; - } - private static void validateHolding(AnimalOwnershipStore store, Map documents, JSONObject holding, JSONObject tokens) throws IOException { - Set evidence=new HashSet<>(),required=new HashSet<>(); - JSONObject migration=AnimalFiles.object(Files.readString(store.path("ownership/migration.json"))); - evidence.addAll(((JSONObject)migration.get("held")).keySet());required.addAll(evidence); - Map first=new HashMap<>(); - for(var entry:documents.entrySet()) { - CharacterRef owner=owner(entry.getKey(),entry.getValue()); - for(var row:animalRows(entry.getValue()).entrySet()) { - JSONObject previous=first.putIfAbsent(row.getKey(),row.getValue()); - boolean invalid=previous!=null&&!previous.equals(row.getValue()); - try { - Set owners=AnimalOwnershipStore.owners(row.getValue()); - invalid|=owner==null?!owners.isEmpty():!owners.contains(owner); - AnimalCodec.load(row.getValue()); - } catch(RuntimeException failure) { invalid=true; } - if(invalid){evidence.add(row.getKey());required.add(row.getKey());} - } - } - for(Object key:tokens.keySet()) { - UUID id=UUID.fromString((String)key);JSONObject snapshot=(JSONObject)tokens.get(key); - require(id.toString().equals(snapshot.get("id"))&&Set.of("prepared","debit-prepared").contains(snapshot.get("status")),"Invalid captured token hold identity/status"); - UUID animal=UUID.fromString((String)snapshot.get("animal")); - AnimalOwnershipStore.parseRef((JSONObject)snapshot.get("actor"));AnimalOwnershipStore.parseRef((JSONObject)snapshot.get("recipient")); - require(snapshot.get("actorSession") instanceof Long&&snapshot.get("recipientSession") instanceof Long - &&snapshot.get("amountAfter") instanceof Long&&((Long)snapshot.get("amountAfter"))>=0 - &&Base64.getDecoder().decode((String)snapshot.get("itemBefore")).length>0,"Invalid captured token payload/session"); - require(snapshot.get("animalBefore")==null||animal.toString().equals(((JSONObject)snapshot.get("animalBefore")).get("uuid")),"Captured token animal conflicts"); - JSONObject current=AnimalFiles.object(Files.readString(store.path("ownership/token-effects/"+id+".json"))); - for(String field:List.of("id","animal","actor","recipient","actorSession","recipientSession","itemBefore","amountAfter","animalBefore")) - require(snapshot.containsKey(field)&&Objects.equals(snapshot.get(field),current.get(field)),"Captured token fixed evidence changed "+field); - if("debit-prepared".equals(snapshot.get("status")))require(snapshot.get("ownerJournal") instanceof String - &&snapshot.get("animalAfter") instanceof JSONObject&&animal.toString().equals(((JSONObject)snapshot.get("animalAfter")).get("uuid")),"Captured armed token lacks owner publication"); - for(String field:List.of("ownerJournal","animalAfter"))if(snapshot.containsKey(field))require(Objects.equals(snapshot.get(field),current.get(field)),"Captured token publication evidence changed "+field); - evidence.add(animal.toString()); - } - require(evidence.containsAll(holding.keySet())&&holding.keySet().containsAll(required),"Held set lacks captured preservation evidence"); - } - private static Map collect(Map documents, JSONObject holding) throws IOException { - Map rows=new LinkedHashMap<>(); - for(var entry:documents.entrySet()) { - CharacterRef owner=owner(entry.getKey(),entry.getValue()); - for(var row:animalRows(entry.getValue()).entrySet()) { - if(holding.containsKey(row.getKey()))continue; - JSONObject previous=rows.putIfAbsent(row.getKey(),row.getValue());require(previous==null||previous.equals(row.getValue()),"Conflicting captured source copies"); - Set owners=AnimalOwnershipStore.owners(row.getValue());require(owner==null?owners.isEmpty():owners.contains(owner),"Captured source roster disagrees with slots"); - } - } - return rows; - } - private static void estate(JSONObject journal, Map before, Map after) throws IOException { - JSONObject header=(JSONObject)journal.get("estate");CharacterRef from=AnimalOwnershipStore.parseRef((JSONObject)header.get("from")),to=AnimalOwnershipStore.parseRef((JSONObject)header.get("to")); - require(!from.equals(to)&&header.get("decision") instanceof String&&header.get("revision") instanceof Long,"Invalid estate decision");UUID death=UUID.fromString((String)header.get("death")); - String decision=(String)header.get("decision"); - require(decision.startsWith("EstateDecision[deathId="+death+", testator="+from+", ") - &&decision.contains(", property=Character[ref="+to+"], ") - &&decision.endsWith(", revision="+header.get("revision")+"]"),"Estate header conflicts with full captured decision"); - Set moved=new HashSet<>();for(Object id:(JSONArray)journal.get("animals"))require(moved.add((String)id),"Duplicate moved animal"); - require(before.keySet().equals(after.keySet())&&before.keySet().containsAll(moved),"Estate changed animal inventory"); - Set source=new HashSet<>();before.forEach((id,row)->{if(AnimalOwnershipStore.owners(row).contains(from))source.add(id);}); - require(source.equals(moved),"Estate omits or adds testator animals"); - for(var entry:before.entrySet()) { - JSONObject expected=AnimalFiles.copy(entry.getValue()); - if(moved.contains(entry.getKey())) { - Set owners=AnimalOwnershipStore.owners(expected);require(owners.contains(from),"Estate source lacks testator slot"); - require(owners.stream().noneMatch(owner->owner.characterId().equals(to.characterId())&&!owner.equals(to)),"Estate recipient account conflict"); - JSONArray slots=new JSONArray();for(Object value:(JSONArray)expected.get("characterOwners"))if(!AnimalOwnershipStore.parseRef((JSONObject)value).equals(from))slots.add(value); - if(!owners.contains(to))slots.add(AnimalOwnershipStore.ref(to));expected.put("characterOwners",slots); - } - require(expected.equals(after.get(entry.getKey())),"Estate changed unrelated ownership/physiology/unknown state "+entry.getKey()); - } - } - private static void migration(AnimalOwnershipStore store, JSONObject manifest, JSONObject canonical, JSONObject holding, Map output) throws IOException { - JSONObject source=AnimalFiles.object(Files.readString(store.path("ownership/source.json"))),heirs=AnimalFiles.object(Files.readString(store.path("ownership/legacy-heirs.json"))); - require(source.equals(manifest.get("sources"))&&canonical.equals(manifest.get("canonical"))&&holding.equals(manifest.get("held")),"Migration manifest conflicts with plan"); - Map> copies=new LinkedHashMap<>(); - for(Object key:source.keySet()) { - String path=(String)key;JSONObject document=AnimalFiles.object((String)source.get(key));UUID filename=path.equals("unownedanimals.json")?null:UUID.fromString(Path.of(path).getFileName().toString().replace(".json","")); - for(Object value:(JSONArray)document.get("animals")) { - JSONObject row=AnimalFiles.copy((JSONObject)value);String id=(String)row.get("uuid");Set accounts=new TreeSet<>();for(Object account:(JSONArray)row.get("ownersUuids"))accounts.add(UUID.fromString((String)account).toString());if(filename!=null)accounts.add(filename.toString());JSONArray owners=new JSONArray();owners.addAll(accounts);row.put("ownersUuids",owners);copies.computeIfAbsent(id,ignored->new ArrayList<>()).add(row); - } - if(filename==null) { - JSONObject expected=AnimalFiles.copy(document),actual=AnimalFiles.copy(output.get("character-unowned.json"));expected.put("format",1L);expected.remove("animals");actual.remove("animals");require(expected.equals(actual),"Unowned legacy envelope changed"); - } - if(filename!=null&&heirs.get(filename.toString()) instanceof JSONObject mapped) { - CharacterRef ref=AnimalOwnershipStore.parseRef(mapped);JSONObject expected=AnimalFiles.copy(document);expected.put("format",1L);expected.put("owner",mapped);JSONObject actual=AnimalFiles.copy(output.get("CharacterData/"+ref.characterId()+".json"));expected.remove("animals");actual.remove("animals");require(expected.equals(actual),"Legacy envelope changed"); - } - } - Set agreed=new HashSet<>(),conflicts=new HashSet<>(); - for(var entry:copies.entrySet()) { - JSONObject row=entry.getValue().getFirst();if(entry.getValue().stream().anyMatch(copy->!copy.equals(row))){conflicts.add(entry.getKey());continue;} - agreed.add(entry.getKey());JSONObject expected=AnimalFiles.copy(row);JSONArray owners=new JSONArray(),held=new JSONArray(); - for(Object account:(JSONArray)row.get("ownersUuids")) { - if(heirs.get(account) instanceof JSONObject ref){require(account.equals(ref.get("account")),"Wrong reviewed heir account");owners.add(ref);} - else {JSONObject slot=new JSONObject();slot.put("account",account);slot.put("reason","No reviewed heir or wrong account");held.add(slot);} - } - expected.put("characterOwners",owners);expected.put("heldOwners",held);require(expected.equals(canonical.get(entry.getKey())),"Migration changed source state or reviewed slots"); - } - require(agreed.equals(canonical.keySet())&&conflicts.equals(holding.keySet()),"Migration omits/adds source animals"); - } -} diff --git a/src/main/java/net/tfminecraft/breedingbuddies/AnimalStates.java b/src/main/java/net/tfminecraft/breedingbuddies/AnimalStates.java deleted file mode 100644 index 6119ead..0000000 --- a/src/main/java/net/tfminecraft/breedingbuddies/AnimalStates.java +++ /dev/null @@ -1,11 +0,0 @@ -package net.tfminecraft.breedingbuddies; - -public enum AnimalStates { - HAPPY, - SAD, - SICK, - ESCAPED, - UNOWNED, - SPAWNED, - ABANDONED -} diff --git a/src/main/java/net/tfminecraft/breedingbuddies/AnimalTokenIntent.java b/src/main/java/net/tfminecraft/breedingbuddies/AnimalTokenIntent.java deleted file mode 100644 index c8c3c3f..0000000 --- a/src/main/java/net/tfminecraft/breedingbuddies/AnimalTokenIntent.java +++ /dev/null @@ -1,43 +0,0 @@ -package net.tfminecraft.breedingbuddies; - -import net.tfminecraft.tlibs.character.CharacterSession; -import org.bukkit.entity.Player; -import org.bukkit.inventory.ItemStack; -import org.json.simple.JSONObject; -import java.io.IOException; -import java.nio.file.Path; -import java.util.*; - -/** Durable evidence before token effects; unfinished effects are staff holds, never inventory replay. */ -public final class AnimalTokenIntent { - private final Path path; - private final JSONObject evidence; - private final CharacterSession actor, recipient; - private final ItemStack before; - private AnimalTokenIntent(Path path, JSONObject evidence, CharacterSession actor, CharacterSession recipient, ItemStack before) { - this.path=path;this.evidence=evidence;this.actor=actor;this.recipient=recipient;this.before=before; - } - public static AnimalTokenIntent prepare(Player player, Player target, FarmAnimal animal, ItemStack token) { - CharacterSession actor=AnimalIdentity.session(player).orElseThrow(),recipient=AnimalIdentity.session(target).orElseThrow(); - JSONObject evidence=new JSONObject();String id=UUID.randomUUID().toString(); - evidence.put("id",id);evidence.put("animal",animal.getUuid().toString());evidence.put("actor",AnimalOwnershipStore.ref(actor.ref()));evidence.put("recipient",AnimalOwnershipStore.ref(recipient.ref())); - evidence.put("actorSession",actor.sessionId());evidence.put("recipientSession",recipient.sessionId()); - evidence.put("itemBefore",Base64.getEncoder().encodeToString(token.serializeAsBytes()));evidence.put("amountAfter",token.getAmount()-1L); - evidence.put("animalBefore",PluginData.ownership().rows.get(animal.getUuid()));evidence.put("status","prepared"); - Path path=PluginData.ownership().root.resolve("ownership/token-effects/"+id+".json"); - AnimalTokenIntent intent=new AnimalTokenIntent(path,evidence,actor,recipient,token.clone());intent.write();PluginData.ownership().tokenEffectId=id;return intent; - } - public void finish(Player player, Player target, FarmAnimal animal, ItemStack token, Runnable entityEffect) { - if (!AnimalIdentity.current(actor,player)||!AnimalIdentity.current(recipient,target)||!before.equals(token)) { hold(animal);throw new IllegalStateException("Token session or payload changed"); } - if (!PluginData.ownership().ready || !Objects.equals(evidence.get("id"),PluginData.ownership().lastJournalToken)) { hold(animal);throw new IllegalStateException("Token ownership publication is unavailable or unbound"); } - evidence.put("ownerJournal",PluginData.ownership().lastJournalId); - evidence.put("animalAfter",AnimalFiles.copy(PluginData.ownership().rows.get(animal.getUuid())));evidence.put("status","debit-prepared");write(); - entityEffect.run();token.setAmount(token.getAmount()-1); - evidence.put("status","done");write();PluginData.ownership().tokenEffectId=null; - } - public void hold(FarmAnimal animal) { PluginData.ownership().tokenEffectId=null;PluginData.ownership().held.put(animal.getUuid(),"Unfinished token effect "+evidence.get("id")); } - private void write() { - try { AnimalFiles.write(path,evidence.toJSONString()); } - catch(IOException failure) { throw new IllegalStateException("Token effect receipt requires review",failure); } - } -} diff --git a/src/main/java/net/tfminecraft/breedingbuddies/AnimalUtils.java b/src/main/java/net/tfminecraft/breedingbuddies/AnimalUtils.java deleted file mode 100644 index 2859eb0..0000000 --- a/src/main/java/net/tfminecraft/breedingbuddies/AnimalUtils.java +++ /dev/null @@ -1,29 +0,0 @@ -package net.tfminecraft.breedingbuddies; - -import org.bukkit.Bukkit; -import org.bukkit.configuration.file.FileConfiguration; -import org.bukkit.entity.Entity; -import org.bukkit.entity.EntityType; - -import java.util.HashSet; -import java.util.Set; - -public class AnimalUtils { - private final static Set farmAnimals = new HashSet<>(); - - public AnimalUtils(FileConfiguration bundlesConfig) { - farmAnimals.clear(); - for (String key : bundlesConfig.getKeys(false)) { - try { - EntityType entityType = EntityType.valueOf(key.toUpperCase()); - farmAnimals.add(entityType); - } catch (IllegalArgumentException e) { - Bukkit.getLogger().info("Invalid entity type in configuration: " + key); - } - } - } - - public static boolean isFarmAnimal(Entity entity) { - return farmAnimals.contains(entity.getType()); - } -} diff --git a/src/main/java/net/tfminecraft/breedingbuddies/BreedingBuddies.java b/src/main/java/net/tfminecraft/breedingbuddies/BreedingBuddies.java index 1216bde..913f0d8 100644 --- a/src/main/java/net/tfminecraft/breedingbuddies/BreedingBuddies.java +++ b/src/main/java/net/tfminecraft/breedingbuddies/BreedingBuddies.java @@ -1,599 +1,143 @@ package net.tfminecraft.breedingbuddies; -import net.tfminecraft.breedingbuddies.configurables.CustomItems; -import net.tfminecraft.breedingbuddies.configurables.Messages; -import net.tfminecraft.breedingbuddies.configurables.Numbers; -import net.tfminecraft.breedingbuddies.events.DayChangeEvent; -import net.tfminecraft.breedingbuddies.listeners.*; -import org.bukkit.Bukkit; -import org.bukkit.ChatColor; -import org.bukkit.Chunk; -import org.bukkit.NamespacedKey; -import org.bukkit.attribute.Attribute; -import org.bukkit.command.Command; -import org.bukkit.command.CommandSender; -import org.bukkit.command.TabCompleter; -import org.bukkit.configuration.file.FileConfiguration; -import org.bukkit.configuration.file.YamlConfiguration; -import org.bukkit.entity.*; -import org.bukkit.persistence.PersistentDataType; -import org.bukkit.plugin.java.JavaPlugin; -import org.bukkit.scheduler.BukkitRunnable; - import java.io.File; -import java.io.FileWriter; import java.io.IOException; -import java.time.Instant; -import java.time.LocalDateTime; -import java.time.ZoneOffset; -import java.util.*; - -public class BreedingBuddies extends JavaPlugin implements TabCompleter { - private FileConfiguration bundlesConfig = null; - private static BreedingBuddies instance; - private File bundlesFile = null; - private FileConfiguration itemsConfig = null; - private File itemsFile = null; - private FileConfiguration messagesConfig = null; - private File messagesFile = null; - private FileConfiguration numbersConfig = null; - private File numbersFile = null; - private final Map subCommands = new HashMap<>(); - private DayChangeScheduler dayScheduler; - private static final String SPAWN_ANIMAL_USAGE = "Usage: /breedingbuddies spawnanimal "; - private static final String SPAWN_MOUNT_USAGE = "Usage: /breedingbuddies spawnmount "; - @Override - public void onEnable() { - instance = this; - createAndLoadConfigs(); - PluginData.loadAllData(); - AnimalIdentity.start(this); - registerListeners(); - this.dayScheduler = new DayChangeScheduler(this, Numbers.startingDayHour, Numbers.startingDayMin); - this.dayScheduler.startScheduler(); - InitSubcommands(); - getCommand("breedingbuddies").setTabCompleter(this); - startAutoSaveTask(); - } - @Override - public void onDisable() { - PluginData.saveAllData(true); - } +import org.bukkit.Bukkit; +import org.bukkit.plugin.java.JavaPlugin; - public static BreedingBuddies getInstance() { - return instance; - } - - private void startAutoSaveTask() { - new BukkitRunnable() { - @Override - public void run() { - PluginData.saveAllData(false); - } - }.runTaskTimer(this, 0, 20 * 60 * 10); - } +import net.tfminecraft.breedingbuddies.husbandry.HusbandryAnimalsCommand; +import net.tfminecraft.breedingbuddies.husbandry.HusbandryBreedListener; +import net.tfminecraft.breedingbuddies.husbandry.HusbandryCareListener; +import net.tfminecraft.breedingbuddies.husbandry.HusbandryDamageListener; +import net.tfminecraft.breedingbuddies.husbandry.HusbandryDeathListener; +import net.tfminecraft.breedingbuddies.husbandry.HusbandryHarvestListener; +import net.tfminecraft.breedingbuddies.husbandry.HusbandryIdentity; +import net.tfminecraft.breedingbuddies.husbandry.HusbandryInspectListener; +import net.tfminecraft.breedingbuddies.husbandry.HusbandryLifecycleListener; +import net.tfminecraft.breedingbuddies.husbandry.HusbandryLoader; +import net.tfminecraft.breedingbuddies.husbandry.HusbandryLocator; +import net.tfminecraft.breedingbuddies.husbandry.HusbandryMountListener; +import net.tfminecraft.breedingbuddies.husbandry.HusbandryNeuterListener; +import net.tfminecraft.breedingbuddies.husbandry.HusbandryRepository; +import net.tfminecraft.breedingbuddies.husbandry.HusbandryTamingListener; +import net.tfminecraft.breedingbuddies.husbandry.HusbandryTickTask; +import net.tfminecraft.cooking.Cooking; + +public class BreedingBuddies extends JavaPlugin { + public static BreedingBuddies plugin; + + private final HusbandryLoader husbandryLoader = new HusbandryLoader(); + private HusbandryRepository husbandryRepository; @Override - public boolean onCommand(CommandSender sender, Command command, String label, String[] args) { - if (!command.getName().equalsIgnoreCase("breedingbuddies")) return false; - if (args.length == 0) return false; - - SubCommand subCommand = subCommands.get(args[0].toLowerCase()); - if (subCommand == null) { - sender.sendMessage(ChatColor.RED + "Unknown subcommand. Use /breedingbuddies "); - return false; + public void onEnable() { + if (cookingRunsLivestock()) { + getLogger().severe("Cooking still runs livestock. Set enabled: false in plugins/Cooking/husbandry.yml" + + " and restart; BreedingBuddies stays off until then."); + Bukkit.getPluginManager().disablePlugin(this); + return; } + plugin = this; - return subCommand.execute(sender, args); - } - - private boolean handleReload(CommandSender sender, String[] args) { - createAndLoadConfigs(); - - if (this.dayScheduler != null) { - this.dayScheduler.cancel(); + ensureDirectory(new File(getDataFolder(), "Data")); + try { + CookingMigration.run(getDataFolder(), getLogger()); + } catch (IOException ex) { + throw new IllegalStateException("Could not take over Cooking's animal records", ex); } - this.dayScheduler = new DayChangeScheduler(this, Numbers.startingDayHour, Numbers.startingDayMin); - this.dayScheduler.startScheduler(); - - sender.sendMessage(ChatColor.GREEN + "[BreedingBuddies]" + ChatColor.YELLOW + " Config reloaded successfully!"); - return true; - } - - private boolean handleStableChunk(CommandSender sender, String[] args) { - if (sender instanceof Player player) { - Chunk chunk = player.getLocation().getChunk(); - boolean isStable = ChunkManager.isStableChunk(chunk); - sender.sendMessage(ChatColor.GREEN + "Stable chunk: " + isStable); - return true; + if (!new File(getDataFolder(), "config.yml").exists()) { + saveResource("config.yml", false); } - return false; - } - - private boolean handleChangeDay(CommandSender sender, String[] args) { - Bukkit.getServer().getPluginManager().callEvent(new DayChangeEvent()); - sender.sendMessage(ChatColor.GREEN + "[BreedingBuddies]" + ChatColor.YELLOW + " Day changed!"); - return true; - } - - private boolean handleSpawnAnimal(CommandSender sender, String[] args) { - if (args.length == 4 && sender instanceof Player player) { - String entityType = args[1].toUpperCase(); - int friendship; - int genetics; - try { - friendship = Integer.parseInt(args[2]); - genetics = Integer.parseInt(args[3]); - } catch (NumberFormatException e) { - sender.sendMessage(ChatColor.RED + SPAWN_ANIMAL_USAGE); - return false; - } - - if (!bundlesConfig.contains(entityType)) { - sender.sendMessage(ChatColor.RED + "Invalid entity type specified in config."); - return false; - } - - EntityType type = EntityType.valueOf(entityType); - Entity entity = player.getWorld().spawnEntity(player.getLocation(), type); - UUID entityUuid = entity.getUniqueId(); + loadConfigs(); + openDatabase(); + HusbandryLifecycleListener.applyStatsRevision(); + HusbandryIdentity.start(this, husbandryRepository); + registerListeners(); - FarmAnimal farmAnimal = new FarmAnimal(entityUuid, entityType, player.getUniqueId(), entity, genetics, friendship); - farmAnimal.setState(AnimalStates.SPAWNED); - UnownedAnimalsManager.addUnownedAnimal(farmAnimal); + Bukkit.getScheduler().runTask(this, () -> { + HusbandryLifecycleListener.resumeLoadedWorlds(); + HusbandryTickTask.start(); + HusbandryLocator.scanUnloaded(); + }); - sender.sendMessage(ChatColor.GREEN + "[BreedingBuddies]" + ChatColor.YELLOW + " Animal spawned successfully!"); - return true; - } - sender.sendMessage(ChatColor.RED + SPAWN_ANIMAL_USAGE); - return false; + BreedingBuddiesCommand admin = new BreedingBuddiesCommand(); + getCommand("breedingbuddies").setExecutor(admin); + getCommand("breedingbuddies").setTabCompleter(admin); + HusbandryAnimalsCommand animals = new HusbandryAnimalsCommand(); + getCommand("animals").setExecutor(animals); + getCommand("animals").setTabCompleter(animals); } - private void InitSubcommands(){ - subCommands.put("reload", this::handleReload); - subCommands.put("stablechunk", this::handleStableChunk); - subCommands.put("changeday", this::handleChangeDay); - subCommands.put("spawnanimal", this::handleSpawnAnimal); - subCommands.put("spawnmount", this::handleSpawnMount); - subCommands.put("savedata", this::handleSaveData); - subCommands.put("loaddata", this::handleLoadData); - subCommands.put("fix", this::handleFix); + @Override + public void onDisable() { + if (plugin != this) return; + HusbandryTickTask.stop(); + HusbandryLifecycleListener.flushLoadedForDisable(); + closeDatabase(); } - private boolean handleSpawnMount(CommandSender sender, String[] args) { - if (args.length == 6 && sender instanceof Player player) { - String entityType = args[1].toUpperCase(); - int friendship; - double health; - double speed; - double jump; - try { - friendship = Integer.parseInt(args[2]); - health = Integer.parseInt(args[3]); - speed = Double.parseDouble(args[4]); - jump = Double.parseDouble(args[5]); - } catch (NumberFormatException e) { - sender.sendMessage(ChatColor.RED + SPAWN_MOUNT_USAGE); - return false; - } - - EntityType type; - try { - type = EntityType.valueOf(entityType); - } catch (IllegalArgumentException e) { - sender.sendMessage(ChatColor.RED + "Invalid entity type."); - return false; - } - Class entityClass = type.getEntityClass(); - if (entityClass == null || !AbstractHorse.class.isAssignableFrom(entityClass)) { - sender.sendMessage(ChatColor.RED + "Entity type must be a mount (like HORSE, DONKEY, etc)."); - return false; - } - - AbstractHorse horse = (AbstractHorse) player.getWorld().spawnEntity(player.getLocation(), type); - Entity entity = horse; - - UUID entityUuid = entity.getUniqueId(); - FarmAnimal farmAnimal = new FarmAnimal(entityUuid, entityType, player.getUniqueId(), entity, 0, friendship); - farmAnimal.setState(AnimalStates.SPAWNED); - farmAnimal.setFriendshipPoints(friendship); - UnownedAnimalsManager.addUnownedAnimal(farmAnimal); - - horse.getAttribute(Attribute.MAX_HEALTH).setBaseValue(health); - horse.setHealth(health); - horse.getAttribute(Attribute.MOVEMENT_SPEED).setBaseValue(speed); - horse.setJumpStrength(jump); - - NamespacedKey key = new NamespacedKey(BreedingBuddies.getInstance(), "is_nerfed"); - entity.getPersistentDataContainer().set(key, PersistentDataType.BYTE, (byte) (true ? 1 : 0)); - - sender.sendMessage(ChatColor.GREEN + "[BreedingBuddies]" + ChatColor.YELLOW + " Animal spawned successfully!"); - return true; + /** Some hosts do not let the server create folders in plugins/; staff then create it once. */ + static void ensureDirectory(File folder) { + if (!folder.mkdirs() && !folder.isDirectory()) { + throw new IllegalStateException("Could not create " + folder.getPath() + + "; create it and let the server write to it, then restart"); } - sender.sendMessage(ChatColor.RED + SPAWN_MOUNT_USAGE); - return false; } - private boolean handleSaveData(CommandSender sender, String[] args) { - PluginData.saveAllData(false); - sender.sendMessage(ChatColor.GREEN + "[BreedingBuddies] Data saved."); - return true; + /** True while Cooking's own husbandry is on; both must never run the same animals. */ + static boolean cookingRunsLivestock() { + return Cooking.plugin != null && Cooking.plugin.isEnabled() && Cooking.plugin.isHusbandryEnabled(); } - private boolean handleLoadData(CommandSender sender, String[] args) { - PluginData.loadAllData(); - sender.sendMessage(ChatColor.GREEN + "[BreedingBuddies] Data loaded."); - return true; + public void loadConfigs() { + husbandryLoader.load(new File(getDataFolder(), "config.yml")); } - private boolean handleFix(CommandSender sender, String[] args) { - for (Set animals : PluginData.getPlayerAnimals().values()) { - for (FarmAnimal animal : animals) { - animal.setState(AnimalStates.HAPPY); - } - } - sender.sendMessage(ChatColor.GREEN + "[BreedingBuddies] Animals fixed."); - return true; - } - - private interface SubCommand { - boolean execute(CommandSender sender, String[] args); - } - - private void createAndLoadConfigs() { - createAndLoadBundleConfig(); - createAndLoadItemConfig(); - createAndLoadMessageConfig(); - createAndLoadNumbersConfig(); + public void reloadAll() { loadConfigs(); - new AnimalUtils(bundlesConfig); - } - - private void createAndLoadBundleConfig() { - bundlesFile = new File(getDataFolder(), "bundles.yaml"); - if (!bundlesFile.exists()) { - createConfigFile(bundlesFile, getDefaultBundleConfig()); - } - bundlesConfig = YamlConfiguration.loadConfiguration(bundlesFile); - } - - private void createAndLoadItemConfig() { - itemsFile = new File(getDataFolder(), "items.yaml"); - if (!itemsFile.exists()) { - createConfigFile(itemsFile, getDefaultItemsConfig()); - } - itemsConfig = YamlConfiguration.loadConfiguration(itemsFile); + HusbandryLifecycleListener.applyStatsRevision(); + HusbandryTickTask.stop(); + HusbandryTickTask.start(); } - private void createAndLoadMessageConfig() { - messagesFile = new File(getDataFolder(), "messages.yaml"); - if (!messagesFile.exists()) { - createConfigFile(messagesFile, getDefaultMessageConfig()); - } - messagesConfig = YamlConfiguration.loadConfiguration(messagesFile); + public HusbandryRepository getHusbandryRepository() { + return husbandryRepository; } - private void createAndLoadNumbersConfig() { - numbersFile = new File(getDataFolder(), "config.yaml"); - if (!numbersFile.exists()) { - createConfigFile(numbersFile, getDefaultNumbersConfig()); - } - numbersConfig = YamlConfiguration.loadConfiguration(numbersFile); - } - - public File getNumbersFile() { - return numbersFile; - } - - private void createConfigFile(File file, String content) { + private void registerListeners() { + getServer().getPluginManager().registerEvents(new HusbandryLifecycleListener(), this); + getServer().getPluginManager().registerEvents(new HusbandryCareListener(), this); + getServer().getPluginManager().registerEvents(new HusbandryTamingListener(), this); + getServer().getPluginManager().registerEvents(new HusbandryDeathListener(), this); + getServer().getPluginManager().registerEvents(new HusbandryHarvestListener(), this); + getServer().getPluginManager().registerEvents(new HusbandryBreedListener(), this); + getServer().getPluginManager().registerEvents(new HusbandryNeuterListener(), this); + getServer().getPluginManager().registerEvents(new HusbandryInspectListener(), this); + getServer().getPluginManager().registerEvents(new HusbandryMountListener(), this); + getServer().getPluginManager().registerEvents(new HusbandryDamageListener(), this); + } + + private void openDatabase() { + File dbFile = new File(new File(getDataFolder(), "Data"), "husbandry.db"); try { - file.getParentFile().mkdirs(); - try (FileWriter writer = new FileWriter(file)) { - writer.write(content); - writer.flush(); - } - } catch (IOException e) { - e.printStackTrace(); + husbandryRepository = HusbandryRepository.open(dbFile); + } catch (RuntimeException ex) { + getLogger().severe("Failed to open the animal database: " + ex.getMessage()); + throw ex; } } - private void loadConfigs() { - Numbers.loadConfig(numbersConfig); - Messages.loadConfig(messagesConfig); - CustomItems.loadConfig(itemsConfig); - BundleCollector.setBundlesConfig(bundlesConfig); - DayChangeListener.setNumbersConfig(numbersConfig); - FarmAnimal.setBundlesConfig(bundlesConfig); - BundleOpener.setBundlesConfig(bundlesConfig); - MountStats.setConfigs(bundlesConfig, numbersConfig); - } - - private void registerListeners() { - getServer().getPluginManager().registerEvents(new BreedingListener(), this); - getServer().getPluginManager().registerEvents(new TamingListener(this), this); - getServer().getPluginManager().registerEvents(new DayChangeListener(), this); - getServer().getPluginManager().registerEvents(new InventoryManager(), this); - getServer().getPluginManager().registerEvents(new AnimalCareListener(), this); - getServer().getPluginManager().registerEvents(new AnimalDeathListener(), this); - getServer().getPluginManager().registerEvents(new FarmReport(), this); - getServer().getPluginManager().registerEvents(new ChunkCreation(), this); - getServer().getPluginManager().registerEvents(new BundleOpener(this), this); - getServer().getPluginManager().registerEvents(new BundleCollector(this), this); - getServer().getPluginManager().registerEvents(new AnvilRenameListener(this), this); - getServer().getPluginManager().registerEvents(new MountListener(), this); - getServer().getPluginManager().registerEvents(new NerfMountListener(), this); - } - - // Default configurations - - private String getDefaultBundleConfig() { - return """ -# ========== HORSE CONFIGURATION ========== -HORSE: - mountStats: # Stats applied when used as a mount - minHealth: 15 # Minimum possible health - maxHealth: 30 # Maximum possible health - minSpeed: 0.1125 # Minimum movement speed - maxSpeed: 0.3375 # Maximum movement speed - minJump: 0.4 # Minimum jump height - maxJump: 1.0 # Maximum jump height - bundlesEnabled: false # Horses do not drop bundles - -# ========== COW CONFIGURATION ========== -COW: - bundlesEnabled: true # Bundles can be obtained from cows - bundles: - - # ----- Common Bundle ----- - common: - bundleItem: # The item given to the player - type: "mmoitem" # Item comes from MMOItems - mmoitemType: "PETS" - mmoitemID: "COMMON_ANIMAL_BUNDLE" - prizes: # Possible rewards from this bundle - prize1: - type: "vanilla" # Minecraft vanilla item - itemID: "BEEF" - amount: 4 - weight: 50 # Probability weight - prize2: - type: "mmoitem" - mmoitemType: "MATERIALS" - mmoitemID: "LEATHER" - amount: 4 - weight: 50 - - # ----- Rare Bundle ----- - rare: - bundleItem: - type: "mmoitem" - mmoitemType: "PETS" - mmoitemID: "RARE_ANIMAL_BUNDLE" - prizes: - prize1: - type: "vanilla" - itemID: "BEEF" - amount: 8 - weight: 45 - prize2: - type: "mmoitem" - mmoitemType: "MATERIALS" - mmoitemID: "LEATHER" - amount: 8 - weight: 45 - prize3: - type: "mmoitem" - mmoitemType: "MATERIALS" - mmoitemID: "RARE_LEATHER" - amount: 1 - weight: 5 - prize4: - type: "mmoitem" - mmoitemType: "MATERIALS" - mmoitemID: "EPIC_LEATHER" - amount: 1 - weight: 5 -"""; - } - - private String getDefaultItemsConfig() { - return """ -# Configuration of allowed items (vanilla or mmoitems IDs) used for various animal actions -items: - tamingItem: "TAMING_ITEM" # Item used to tame an animal. - coownershipItem: "COOWNERSHIP_ITEM" # Item to share ownership of an animal with another player. - universalFeed: "UNIVERSAL_FEED" # Item used to feed any kind of animal. - caringItem: "CARING_ITEM" # Item to care for or pamper an animal. - farmReportItem: "FARMREPORT_ITEM" # Item that shows a report or summary of the farm. - stableChunkItem: "STABLECHUNK_ITEM" # Item to claim a chunk as a stable (farm animals should spend the night in stable chunks). - collectorsItem: "COLLECTOR_ITEM" # Item used to collect rewards from animals, like wool, leather, etc. - mountStatsItem: "CARROT_ON_A_STICK" # Item to display mount stats or status. - neuterItem: "SHEARS" # Item to neuter or sterilize animals (e.g., shears). -"""; - } - - private String getDefaultMessageConfig() { - return """ -# Messages configuration. %s will be replaced by the animal's name. -messages: - inventoryName: "Animal Info" # Title shown on the animal information screen. - - nameFirst: "§6No name found on item: pick a name using an anvil." - # Shown when the animal item doesn't have a name yet, prompting the player to name it. - - ownerFirst: "§6This animal must be tamed before it can be co-owned." - # Displayed when trying to share ownership but the animal is not tamed yet. - - invalidAnimalOrNotFound: "§6You cannot tame this!" - # Message when the player attempts to tame an invalid or untamable animal. - - alreadyOwner: "§6Already an owner of %s." - # Notifies the player they already own the specified animal (%s). - - nowCoowner: "§6Congratulations! You are now a proud co-owner of %s!" - # Confirmation message when the player becomes a co-owner of the animal. - - ownershipShared: "§6The ownership of %s has been successfully shared." - # Indicates that the animal's ownership has been shared with another player. - - useOnOwnedAnimal: "§6Please use this item on an animal you own." - # Informs the player that the item they are trying to use requires ownership of the animal. - - onlyOwnerCanShare: "§6Only %s's owner can share its ownership." - # Warns that only the main owner can share the ownership of the animal. - - nowOwner: "§6You have successfully tamed %s and are now its proud owner!" - # Confirmation that the player tamed the animal and became its owner. - - notOwner: "§6You aren't the owner of this animal." - # Shown when a player tries to perform an owner-only action on an animal they don't own. - - alreadyFed: "§6%s has already been fed." - # Indicates the animal has already received food for this period. - - alreadyCared: "§6%s has already been taken care of." - # Indicates the animal has already received care (e.g., petting) for this period. - - animalsOut: "§7§oSome animals have slept outside the farm boundaries. Make sure to secure them." - # Warning that some animals are outside the designated farm area. - - spaceNeeded: "§7§oSome of your animals need more space. Consider expanding your farm plot." - # Suggestion to increase farm space because animals are crowded. - - waterNeeded: "§7§oThere are animals without easy access to water." - # Warning that some animals lack proper water supply. - - animalsFed: "§e-§oAnimals fed:§r %s." - # Report of how many animals have been fed. - - animalsCared: "§e-§oAnimals taken care of:§r %s." - # Report of how many animals have been cared for. - - animalsOwned: "§e-§oTotal animals owned:§r %s." - # Displays the total count of animals owned. - - lacksSpace: "§6Make space in the inventory first!" - # Warning that the player's inventory is full and space is needed. - - stableChunk: "§6This chunk is now a stable chunk." - # Confirmation that a game chunk is designated as a stable (farm) area. - - alreadyStableChunk: "§6This chunk is already a stable chunk." - # Message if trying to set a chunk as stable but it already is. - - alreadyLinked: "§6Already linked to %s." - # Indicates that the animal or object is already linked to the specified target. - - neuteredHorse: "§6Neutered horses can't breed." - # Info message that horses that are neutered cannot breed. - - tameFirst: "§6You must tame this animal before claiming it." - # Info message that untamed animals cannot be owned. - - removeOwnership: "§6You are no longer the owner of %s." - # Indicates that the animal is not longer linked to the player. -"""; - } - - private String getDefaultNumbersConfig() { - return """ -# ========== GENERAL BALANCE ========== -maxFriendshipAndGenetics: 10000 # Total points for friendship/genetics. Always shows 5 hearts (max/5 = points per heart). -initialGeneticMax: 200 # Max genetic points wild animals can start with. - -# ========== INTERACTION ========== -friendshipByFeeding: 200 # Friendship gained by feeding. -friendshipByCaring: 200 # Friendship gained by caring (e.g., petting). -friendshipLostNotFeeding: 50 # Friendship lost for not feeding regularly. -friendshipLostNotCaring: 50 # Friendship lost for not caring/interacting. - -# ========== ENVIRONMENT LIMITS ========== -maxAnimalsPerChunk: 20 # Max animals allowed per chunk. -maxIrlDaysOut: 3 # Real-life days before animal loses his ownership due to not sleeping in a farm plot. -maxIrlDaysChunkAbandoned: 5 # Number of real-life days a farm chunk can stay empty before it stops being considered a farm chunk. - -# ========== BREEDING & GENETICS ========== -geneticVarianceMultiplier: 0.4 # Modifies how much genetics can vary from parents. 1 = no effect. -friendshipInfluenceMultiplier: 0.4 # Scales how much parents' friendship affects baby genetics (base is 5% of average friendship). -geneticDivisorMultiplierToRetardProgression: 0.4 # Higher parent stats = slower baby improvement. Multiplies divisor (never set to 0!). - -# ========== REWARDS & TIME ========== -hoursBetweenRewards: 8 # Real-life hours between reward eligibility. -lastDayChangeDate: {lastDayChangeDate} # Epoch timestamp of the last change day (UTC). -startingDayHour: 21 # Hour of the day when the plugin triggers a day change (0–23) (UTC). -startingDayMin: 1 # Minute of the hour when the plugin triggers a day change (0–59) (UTC). - -# ========== MOUNT RELATED ========== -defaultMountStats: - minHealth: 15 - maxHealth: 30 - minSpeed: 0.1125 - maxSpeed: 0.3375 - minJump: 0.4 - maxJump: 1.0 - -nerfMountStats: false # Whether to nerf mount stats automatically. -mountNerfDivisor: 2.0 # Applied if nerfMountStats is true; divides the stats. -""".replace("{lastDayChangeDate}", String.valueOf(Instant.now().getEpochSecond())); - } - - @Override - public List onTabComplete(CommandSender sender, Command command, String alias, String[] args) { - if (!command.getName().equalsIgnoreCase("breedingbuddies")) return null; - - if (args.length == 1) { - // Sugerir subcomandos - return subCommands.keySet().stream() - .filter(sub -> sub.startsWith(args[0].toLowerCase(java.util.Locale.ROOT))) - .sorted() - .toList(); - } - - String sub = args[0].toLowerCase(java.util.Locale.ROOT); - - if (sub.equals("spawnanimal")) { - switch (args.length) { - case 2: // EntityType - return Arrays.stream(EntityType.values()) - .filter(EntityType::isSpawnable) - .filter(type -> type.getEntityClass() != null && Animals.class.isAssignableFrom(type.getEntityClass())) - .map(Enum::name) - .filter(name -> bundlesConfig.getKeys(false).contains(name)) - .filter(name -> name.startsWith(args[1].toUpperCase(java.util.Locale.ROOT))) - .sorted() - .toList(); - case 3: // Friendship - return matchingPresets(List.of("100", "1000", "10000"), args[args.length - 1]); - case 4: // Genetics - return matchingPresets(List.of("100", "1000", "10000"), args[args.length - 1]); - } + private void closeDatabase() { + if (husbandryRepository == null) { + return; } - - if (sub.equals("spawnmount")) { - switch (args.length) { - case 2: // EntityType - return Arrays.stream(EntityType.values()) - .filter(type -> type.getEntityClass() != null && AbstractHorse.class.isAssignableFrom(type.getEntityClass())) - .map(Enum::name) - .filter(name -> name.startsWith(args[1].toUpperCase(java.util.Locale.ROOT))) - .sorted() - .toList(); - case 3: // Friendship - return matchingPresets(List.of("100", "1000", "10000"), args[args.length - 1]); - case 4: // Health - return matchingPresets(List.of("15", "30"), args[args.length - 1]); - case 5: // Speed - return matchingPresets(List.of("0.2", "0.25", "0.3"), args[args.length - 1]); - case 6: // Jump - return matchingPresets(List.of("0.5", "0.7", "1.0"), args[args.length - 1]); - } + try { + husbandryRepository.close(); + } catch (RuntimeException ex) { + getLogger().warning("Failed to close the animal database: " + ex.getMessage()); + } finally { + husbandryRepository = null; } - - return Collections.emptyList(); - } - - private List matchingPresets(List values, String prefix) { - return values.stream().filter(value -> value.startsWith(prefix)).toList(); } } diff --git a/src/main/java/net/tfminecraft/breedingbuddies/BreedingBuddiesCommand.java b/src/main/java/net/tfminecraft/breedingbuddies/BreedingBuddiesCommand.java new file mode 100644 index 0000000..39b8ee0 --- /dev/null +++ b/src/main/java/net/tfminecraft/breedingbuddies/BreedingBuddiesCommand.java @@ -0,0 +1,153 @@ +package net.tfminecraft.breedingbuddies; + +import java.util.ArrayList; +import java.util.Collections; +import java.util.List; +import java.util.Locale; + +import org.bukkit.command.Command; +import org.bukkit.command.CommandExecutor; +import org.bukkit.command.CommandSender; +import org.bukkit.command.TabCompleter; +import org.bukkit.entity.EntityType; +import org.bukkit.entity.LivingEntity; +import org.bukkit.entity.Player; + +import net.tfminecraft.breedingbuddies.husbandry.HusbandryAnimal; +import net.tfminecraft.breedingbuddies.husbandry.HusbandryConfig; +import net.tfminecraft.breedingbuddies.husbandry.HusbandryEntities; +import net.tfminecraft.breedingbuddies.husbandry.HusbandryRepository; +import net.tfminecraft.breedingbuddies.husbandry.HusbandrySpawner; +import net.tfminecraft.breedingbuddies.husbandry.HusbandrySpecies; + +/** Staff commands: reload, spawn and save. */ +public final class BreedingBuddiesCommand implements CommandExecutor, TabCompleter { + static final String PERMISSION = "breedingbuddies.admin"; + private static final String SPAWN_USAGE = "§cUsage: /breedingbuddies spawn [genetics] [care]"; + + @Override + public boolean onCommand(CommandSender sender, Command command, String label, String[] args) { + if (!sender.hasPermission(PERMISSION)) { + sender.sendMessage("§cNo permission."); + return true; + } + String sub = args.length == 0 ? "" : args[0].toLowerCase(Locale.ROOT); + switch (sub) { + case "reload" -> { + if (BreedingBuddies.plugin == null) { + sender.sendMessage("§cBreedingBuddies is not running."); + return true; + } + BreedingBuddies.plugin.reloadAll(); + sender.sendMessage("§aBreedingBuddies config reloaded."); + } + case "save" -> save(sender); + case "spawn" -> spawn(sender, args); + default -> { + sender.sendMessage("§e/breedingbuddies reload"); + sender.sendMessage("§e/breedingbuddies spawn [genetics] [care]"); + sender.sendMessage("§e/breedingbuddies save"); + } + } + return true; + } + + private void spawn(CommandSender sender, String[] args) { + if (!(sender instanceof Player player)) { + sender.sendMessage("§cOnly players can spawn animals."); + return; + } + if (args.length < 2) { + sender.sendMessage(SPAWN_USAGE); + return; + } + EntityType type; + try { + type = EntityType.valueOf(args[1].toUpperCase(Locale.ROOT)); + } catch (IllegalArgumentException ex) { + sender.sendMessage("§cUnknown entity type."); + return; + } + HusbandrySpecies species = HusbandryConfig.species(type); + if (species == null) { + sender.sendMessage("§cThat type is not a husbandry species."); + return; + } + int genetics = HusbandryConfig.initialGeneticMax(); + int care = 0; + if (args.length >= 3) { + try { + genetics = Integer.parseInt(args[2]); + } catch (NumberFormatException ex) { + sender.sendMessage("§cGenetics must be a number."); + return; + } + } + if (args.length >= 4) { + try { + care = Integer.parseInt(args[3]); + } catch (NumberFormatException ex) { + sender.sendMessage("§cCare must be a number."); + return; + } + } + LivingEntity spawned = HusbandrySpawner.spawn(player, type, genetics, care); + if (spawned == null) { + sender.sendMessage("§cFailed to spawn animal."); + return; + } + sender.sendMessage("§aSpawned " + spawned.getName() + + " genetics " + Math.max(0, Math.min(HusbandryConfig.maxGenetics(), genetics)) + + " care " + Math.max(0, Math.min(HusbandryConfig.careMax(), care)) + "."); + } + + private void save(CommandSender sender) { + HusbandryRepository repository = BreedingBuddies.plugin == null ? null : BreedingBuddies.plugin.getHusbandryRepository(); + if (repository == null) { + sender.sendMessage("§cThe animal database is not open."); + return; + } + List loaded = HusbandryEntities.snapshotLoaded(); + if (!loaded.isEmpty()) { + repository.upsertAnimals(loaded); + } + repository.checkpointWal(false); + sender.sendMessage("§aSaved " + loaded.size() + " loaded animals."); + } + + @Override + public List onTabComplete(CommandSender sender, Command command, String alias, String[] args) { + if (!sender.hasPermission(PERMISSION)) { + return Collections.emptyList(); + } + if (args.length == 1) { + return matching(List.of("reload", "spawn", "save"), args[0].toLowerCase(Locale.ROOT)); + } + if (!args[0].equalsIgnoreCase("spawn")) { + return Collections.emptyList(); + } + return switch (args.length) { + case 2 -> { + List types = new ArrayList<>(); + for (EntityType type : HusbandryConfig.species().keySet()) { + types.add(type.name()); + } + types.sort(String.CASE_INSENSITIVE_ORDER); + yield matching(types, args[1].toUpperCase(Locale.ROOT)); + } + case 3 -> matching(List.of("0", "20", "1000"), args[2]); + case 4 -> matching(List.of("0", "100", "200"), args[3]); + default -> Collections.emptyList(); + }; + } + + private static List matching(List options, String prefix) { + List out = new ArrayList<>(); + for (String option : options) { + if (option.startsWith(prefix)) { + out.add(option); + } + } + return out; + } +} diff --git a/src/main/java/net/tfminecraft/breedingbuddies/ChunkArea.java b/src/main/java/net/tfminecraft/breedingbuddies/ChunkArea.java deleted file mode 100644 index 07affd2..0000000 --- a/src/main/java/net/tfminecraft/breedingbuddies/ChunkArea.java +++ /dev/null @@ -1,75 +0,0 @@ -package net.tfminecraft.breedingbuddies; - - -import java.util.ArrayList; -import java.util.Collection; -import java.util.HashSet; -import java.util.Set; - -import org.bukkit.Chunk; - -public class ChunkArea { - private Set chunks = new HashSet<>(); - private int daysAbandoned = 0; - - public ChunkArea(Chunk initialChunk) { - chunks.add(initialChunk); - } - - public ChunkArea(Set chunks) { - this.chunks = chunks; - } - - public Set getChunks() { - return chunks; - } - - public int getDaysAbandoned() { - return daysAbandoned; - } - - public void increaseDaysAbandoned() { - daysAbandoned++; - } - - public void setDaysAbandoned(int daysAbandoned) { - this.daysAbandoned = daysAbandoned; - } - - public void addChunk(Chunk chunk) { - chunks.add(chunk); - } - - public void addChunks(Set chunks) { - this.chunks.addAll(chunks); - } - - public boolean containsChunk(Chunk chunk) { - return chunks.contains(chunk); - } - - public boolean isAdjacent(Chunk chunk) { - for (Chunk c : chunks) { - if (isAdjacent(c, chunk)) { - return true; - } - } - return false; - } - - private boolean isAdjacent(Chunk c1, Chunk c2) { - int dx = Math.abs(c1.getX() - c2.getX()); - int dz = Math.abs(c1.getZ() - c2.getZ()); - return (dx == 1 && dz == 0) || (dx == 0 && dz == 1); - } - - public static ChunkArea findAreaForChunk(ArrayList areas, Chunk chunk) { - if (areas == null || chunk == null) return null; - for (ChunkArea area : areas) { - if (area.containsChunk(chunk)) { - return area; - } - } - return null; - } -} \ No newline at end of file diff --git a/src/main/java/net/tfminecraft/breedingbuddies/ChunkManager.java b/src/main/java/net/tfminecraft/breedingbuddies/ChunkManager.java deleted file mode 100644 index b1bbc70..0000000 --- a/src/main/java/net/tfminecraft/breedingbuddies/ChunkManager.java +++ /dev/null @@ -1,96 +0,0 @@ -package net.tfminecraft.breedingbuddies; - -import java.util.ArrayList; -import java.util.HashSet; -import java.util.List; -import java.util.Set; - -import net.tfminecraft.breedingbuddies.configurables.Numbers; -import org.bukkit.Bukkit; -import org.bukkit.Chunk; -import org.bukkit.Material; -import org.bukkit.entity.Animals; -import org.bukkit.entity.Entity; - -public class ChunkManager { - private ChunkManager() { - } - - public static void addChunk(Chunk newChunk) { - List adjacentAreas = findAdjacentAreas(newChunk); - - if (adjacentAreas.isEmpty()) { - Set chunks = new HashSet<>(); - chunks.add(newChunk); - ChunkArea newArea = new ChunkArea(chunks); - PluginData.getChunkAreas().add(newArea); - } else { - ChunkArea combinedArea = new ChunkArea(new HashSet<>()); - for (ChunkArea area : adjacentAreas) { - combinedArea.addChunks(area.getChunks()); - PluginData.getChunkAreas().remove(area); - } - combinedArea.addChunk(newChunk); - PluginData.getChunkAreas().add(combinedArea); - } - } - - private static List findAdjacentAreas(Chunk chunk) { - List adjacentAreas = new ArrayList<>(); - for (ChunkArea area : PluginData.getChunkAreas()) { - if (area.isAdjacent(chunk)) { - adjacentAreas.add(area); - } - } - return adjacentAreas; - } - - - public static boolean isStableChunk(Chunk currentChunk) { - for (ChunkArea area : PluginData.getChunkAreas()) { - if (area.containsChunk(currentChunk)) { - return true; - } - } - return false; - } - - public static int animalsContained(ChunkArea chunkArea) { - int animalsContained = 0; - for (Chunk chunk : chunkArea.getChunks()) { - boolean wasForced = chunk.isForceLoaded(); - chunk.setForceLoaded(true); - for (Entity entity : chunk.getEntities()) { - if (entity instanceof Animals) { - animalsContained++; - } - } - if (!wasForced) { - chunk.setForceLoaded(false); - } - } - return animalsContained; - } - - public static boolean containsWater(ChunkArea chunkArea) { - for (Chunk chunk : chunkArea.getChunks()) { - boolean wasForced = chunk.isForceLoaded(); - chunk.setForceLoaded(true); - try { - if (chunk.contains(Bukkit.createBlockData(Material.WATER)) - || chunk.contains(Bukkit.createBlockData(Material.WATER_CAULDRON))) { - return true; - } - } finally { - if (!wasForced) { - chunk.setForceLoaded(false); - } - } - } - return false; - } - - public static boolean spaceNeeded(ChunkArea chunkArea) { - return animalsContained(chunkArea) > Numbers.maxAnimalsPerChunk * chunkArea.getChunks().size(); - } -} \ No newline at end of file diff --git a/src/main/java/net/tfminecraft/breedingbuddies/CookingMigration.java b/src/main/java/net/tfminecraft/breedingbuddies/CookingMigration.java new file mode 100644 index 0000000..e58e86e --- /dev/null +++ b/src/main/java/net/tfminecraft/breedingbuddies/CookingMigration.java @@ -0,0 +1,46 @@ +package net.tfminecraft.breedingbuddies; + +import java.io.File; +import java.io.IOException; +import java.nio.file.Files; +import java.nio.file.Path; +import java.nio.file.StandardCopyOption; +import java.util.logging.Logger; + +/** + * Takes over the animal records Cooking kept before BreedingBuddies ran livestock. Runs before the database + * opens; Cooking's files are copied, never moved, so they stay as a backup. Settings are not copied: + * config.yml starts from BreedingBuddies' own defaults, which changed how trust, mounts and products work. + */ +final class CookingMigration { + static final String DATABASE = "Data/husbandry.db"; + + private CookingMigration() {} + + static void run(File dataFolder, Logger logger) throws IOException { + File cooking = new File(dataFolder.getParentFile(), "Cooking"); + copyDatabase(new File(cooking, DATABASE), new File(dataFolder, DATABASE), logger); + } + + /** Publish the database atomically after its journal; failed copies remain safe to retry. */ + private static void copyDatabase(File source, File target, Logger logger) throws IOException { + if (target.exists() || !source.isFile()) { + return; + } + Path staged = Path.of(target.getPath() + ".migrating"); + try { + Files.copy(source.toPath(), staged, StandardCopyOption.REPLACE_EXISTING); + // A previous attempt may have left a journal that no longer belongs to Cooking's current database. + Files.deleteIfExists(Path.of(target.getPath() + "-wal")); + Files.deleteIfExists(Path.of(target.getPath() + "-shm")); + File wal = new File(source.getPath() + "-wal"); + if (wal.isFile()) { + Files.copy(wal.toPath(), Path.of(target.getPath() + "-wal"), StandardCopyOption.REPLACE_EXISTING); + } + Files.move(staged, target.toPath(), StandardCopyOption.ATOMIC_MOVE); + } finally { + Files.deleteIfExists(staged); + } + logger.info("Took over Cooking's animal records from " + source.getPath() + "; Cooking's copy is kept as a backup."); + } +} diff --git a/src/main/java/net/tfminecraft/breedingbuddies/DayChangeScheduler.java b/src/main/java/net/tfminecraft/breedingbuddies/DayChangeScheduler.java deleted file mode 100644 index e8c3c30..0000000 --- a/src/main/java/net/tfminecraft/breedingbuddies/DayChangeScheduler.java +++ /dev/null @@ -1,50 +0,0 @@ -package net.tfminecraft.breedingbuddies; - -import net.tfminecraft.breedingbuddies.events.DayChangeEvent; -import org.bukkit.Bukkit; -import org.bukkit.plugin.java.JavaPlugin; -import org.bukkit.scheduler.BukkitRunnable; - -import java.time.Clock; -import java.time.LocalTime; - -public class DayChangeScheduler extends BukkitRunnable { - private final JavaPlugin plugin; - private final Clock clock; - private final int targetHour; - private final int targetMinute; - private LocalTime lastCheckTime; - private int taskId; - - public DayChangeScheduler(JavaPlugin plugin, int targetHour, int targetMinute) { - this(plugin, targetHour, targetMinute, Clock.systemUTC()); - } - - DayChangeScheduler(JavaPlugin plugin, int targetHour, int targetMinute, Clock clock) { - this.plugin = plugin; - this.clock = clock; - this.targetHour = targetHour; - this.targetMinute = targetMinute; - this.lastCheckTime = LocalTime.now(clock); - } - - public void cancel() { - if (taskId != -1) { - Bukkit.getScheduler().cancelTask(taskId); - } - } - - @Override - public void run() { - LocalTime now = LocalTime.now(clock); - if (now.getHour() == targetHour && now.getMinute() == targetMinute - && (lastCheckTime.getMinute() != targetMinute)) { - plugin.getServer().getPluginManager().callEvent(new DayChangeEvent()); - } - lastCheckTime = now; - } - - public void startScheduler() { - this.taskId = this.runTaskTimer(plugin, 0L, 1200L).getTaskId(); - } -} diff --git a/src/main/java/net/tfminecraft/breedingbuddies/FarmAnimal.java b/src/main/java/net/tfminecraft/breedingbuddies/FarmAnimal.java deleted file mode 100644 index 06d32c4..0000000 --- a/src/main/java/net/tfminecraft/breedingbuddies/FarmAnimal.java +++ /dev/null @@ -1,297 +0,0 @@ -package net.tfminecraft.breedingbuddies; - -import net.tfminecraft.breedingbuddies.configurables.Numbers; -import net.tfminecraft.breedingbuddies.listeners.BundleCollector; -import org.bukkit.Bukkit; -import org.bukkit.Chunk; -import org.bukkit.configuration.ConfigurationSection; -import org.bukkit.configuration.file.FileConfiguration; -import org.bukkit.entity.AbstractHorse; -import org.bukkit.entity.Entity; - -import java.time.Instant; -import java.time.temporal.ChronoUnit; -import java.util.*; -import java.util.concurrent.TimeUnit; - - -public class FarmAnimal { - private UUID uuid; - private String name; - private Set ownersUuids = new HashSet<>(); - private final Map ownerRefs = new HashMap<>(); - public Map getOwnerRefs() { return ownerRefs; } - private int geneticPoints; - private AnimalStates state; - private boolean fed = false; - private boolean cared = false; - private int daysOut = 0; - private boolean sleptInStable = true; - private boolean spaceNeeded = false; - private boolean waterNeeded = false; - private boolean stableNeeded = true; - private Date lastRewardCollected; - private Date lastUpdated; - - private static FileConfiguration bundlesConfig; - - - public FarmAnimal(UUID uuid, String name, UUID uuidOwner, Entity entity) { - this.uuid = uuid; - this.name = name; - this.ownersUuids.add(uuidOwner); - this.geneticPoints = new Random().nextInt(Numbers.initialGeneticMax); - this.state = AnimalStates.HAPPY; - if (entity instanceof AbstractHorse) { - calcGeneticsIfIsMount(entity); - stableNeeded = false; - } - } - - public FarmAnimal(UUID uuid, String name, UUID uuidOwner, Entity entity, int genetics) { - this.uuid = uuid; - this.name = name; - this.ownersUuids.add(uuidOwner); - this.state = AnimalStates.HAPPY; - this.geneticPoints = genetics; - if (entity instanceof AbstractHorse) { - calcGeneticsIfIsMount(entity); - stableNeeded = false; - } - } - - public FarmAnimal(UUID uuid, String name, UUID uuidOwner, Entity entity, int genetics, int friendship) { - this.uuid = uuid; - this.name = "???"; - this.friendshipPoints = friendship; - this.state = AnimalStates.UNOWNED; - this.geneticPoints = genetics; - if (entity instanceof AbstractHorse) { - calcGeneticsIfIsMount(entity); - stableNeeded = false; - } - } - - public FarmAnimal(UUID uuid, int geneticPoints, Entity entity) { - this.uuid = uuid; - this.name = "???"; - this.state = AnimalStates.UNOWNED; - this.geneticPoints = geneticPoints; - if (entity instanceof AbstractHorse) { - calcGeneticsIfIsMount(entity); - stableNeeded = false; - } - } - - public FarmAnimal(UUID uuid, Entity entity) { - this.uuid = uuid; - this.name = "???"; - this.state = AnimalStates.UNOWNED; - if (entity instanceof AbstractHorse) { - calcGeneticsIfIsMount(entity); - stableNeeded = false; - } - } - - public FarmAnimal (UUID animalUUID, String name, Set owners) { - this.uuid = animalUUID; - this.name = name; - this.ownersUuids = (Set) owners; - } - - public UUID getUuid() { - return uuid; - } - - public String getName() { - return name; - } - - public void setName(String name) { - this.name = name; - } - - public Set getOwnersUuids() { - return ownersUuids; - } - public void setOwnersUuids (Set newOwners) { - this.ownersUuids = newOwners; - ownerRefs.keySet().retainAll(newOwners); - } - - public int getFriendshipPoints() { - return friendshipPoints; - } - - public void increaseFriendship(int amount) { - friendshipPoints += amount; - } - - public void changeDay(boolean waterNeeded, boolean spaceNeeded, boolean sleptInStable, int friendshipLost) { - if (lastUpdated != null) { - Instant lastInstant = lastUpdated.toInstant(); - Instant now = Instant.now(); - long hoursSinceUpdate = ChronoUnit.HOURS.between(lastInstant, now); - - if (hoursSinceUpdate < 24) { - return; // Salimos del método sin hacer nada - } - } - - if (!spaceNeeded && !waterNeeded && sleptInStable && fed && cared) - state = AnimalStates.HAPPY; - else - state = AnimalStates.SAD; - - this.spaceNeeded = spaceNeeded; - this.waterNeeded = waterNeeded; - this.sleptInStable = sleptInStable; - fed = false; - cared = false; - setFriendshipPoints(friendshipPoints - friendshipLost); - setLastUpdated(new Date()); - } - - public boolean getFed() { - return fed; - } - - public void setFed(boolean fed) { - this.fed = fed; - } - - public boolean getCared() { - return cared; - } - - public void setCared(boolean cared) { - this.cared = cared; - } - - public AnimalStates getState() { - return state; - } - - public void setState(AnimalStates state) { - this.state = state; - } - - public int getGeneticPoints() { - return geneticPoints; - } - - public int getDaysOut() { - return daysOut; - } - - public void incrementDaysOut() { - daysOut++; - } - - public Date getLastRewardCollected() { - return lastRewardCollected; - } - - public void setLastRewardCollected(Date lastRewardCollected) { - this.lastRewardCollected = lastRewardCollected; - } - - public boolean canCollectReward() { - if (state != AnimalStates.HAPPY) { - return false; - } - - if (lastRewardCollected == null) { - return true; - } - - long timeSinceLastReward = System.currentTimeMillis() - lastRewardCollected.getTime(); - double hoursSinceLastReward = (double) timeSinceLastReward / (1000 * 60 * 60); // Convertir milisegundos a horas - return hoursSinceLastReward >= Numbers.hoursBetweenRewards; - } - - public static void setBundlesConfig(FileConfiguration bundlesConfig) { - FarmAnimal.bundlesConfig = bundlesConfig; - } - - public String timeForNextReward(Entity entity) { - ConfigurationSection animalSection = bundlesConfig.getConfigurationSection(entity.getType().toString()); - if (state != AnimalStates.HAPPY || !animalSection.getBoolean("bundlesEnabled", false)) { - return "No bundle"; - } - long remainingTimeMillis = timeForNextRewardMillis(); - long hours = TimeUnit.MILLISECONDS.toHours(remainingTimeMillis); - long minutes = TimeUnit.MILLISECONDS.toMinutes(remainingTimeMillis - TimeUnit.HOURS.toMillis(hours)); - - String timeString = String.format("%02d:%02d", hours, minutes); - return timeString; - } - - private long timeForNextRewardMillis() { - if (lastRewardCollected == null) { - return 0; - } - long timeSinceLastReward = System.currentTimeMillis() - lastRewardCollected.getTime(); - long remainingTimeMillis = (long) (Numbers.hoursBetweenRewards * 3600000 - timeSinceLastReward); - return Math.max(remainingTimeMillis, 0); - } - - void calcGeneticsIfIsMount(Entity entity) - { - if (MountUtils.isFullStatMount(entity)) - this.geneticPoints = MountUtils.getGenetics(entity); - } - - private int friendshipPoints = 0; - public void setFriendshipPoints(int friendshipPoints) { - this.friendshipPoints = Math.max(friendshipPoints, 0); - } - - public void setGeneticPoints(int geneticPoints) { - this.geneticPoints = geneticPoints; - } - - public void setDaysOut(int daysOut) { - this.daysOut = daysOut; - } - - public boolean isSleptInStable() { - return sleptInStable; - } - - public void setSleptInStable(boolean sleptInStable) { - this.sleptInStable = sleptInStable; - } - - public boolean isSpaceNeeded() { - return spaceNeeded; - } - - public void setSpaceNeeded(boolean spaceNeeded) { - this.spaceNeeded = spaceNeeded; - } - - public boolean isWaterNeeded() { - return waterNeeded; - } - - public void setWaterNeeded(boolean waterNeeded) { - this.waterNeeded = waterNeeded; - } - - public boolean isStableNeeded() { - return stableNeeded; - } - - public void setStableNeeded(boolean stableNeeded) { - this.stableNeeded = stableNeeded; - } - - public Date getLastUpdated() { - return lastUpdated; - } - - public void setLastUpdated(Date lastUpdated) { - this.lastUpdated = lastUpdated; - } -} \ No newline at end of file diff --git a/src/main/java/net/tfminecraft/breedingbuddies/InventoryUtils.java b/src/main/java/net/tfminecraft/breedingbuddies/InventoryUtils.java deleted file mode 100644 index a8a47e8..0000000 --- a/src/main/java/net/tfminecraft/breedingbuddies/InventoryUtils.java +++ /dev/null @@ -1,22 +0,0 @@ -package net.tfminecraft.breedingbuddies; - -import org.bukkit.entity.Player; -import org.bukkit.inventory.ItemStack; - -public class InventoryUtils { - private InventoryUtils() { - } - - public static int countEmptySlots(Player player) { - int emptyCount = 0; - - // Armour and off-hand slots cannot receive items from addItem(). - for (ItemStack item : player.getInventory().getStorageContents()) { - if (item == null || item.getType().isAir()) { - emptyCount++; - } - } - - return emptyCount; - } -} \ No newline at end of file diff --git a/src/main/java/net/tfminecraft/breedingbuddies/ItemUtils.java b/src/main/java/net/tfminecraft/breedingbuddies/ItemUtils.java deleted file mode 100644 index 1730c8f..0000000 --- a/src/main/java/net/tfminecraft/breedingbuddies/ItemUtils.java +++ /dev/null @@ -1,87 +0,0 @@ -package net.tfminecraft.breedingbuddies; - -import io.lumine.mythic.lib.api.item.NBTItem; -import net.Indyuce.mmoitems.MMOItems; -import org.bukkit.Bukkit; -import org.bukkit.Material; -import org.bukkit.NamespacedKey; -import org.bukkit.inventory.ItemStack; -import org.bukkit.inventory.meta.ItemMeta; -import org.bukkit.persistence.PersistentDataType; -import org.bukkit.plugin.Plugin; -import org.bukkit.plugin.java.JavaPlugin; - -public class ItemUtils { - private ItemUtils() { - } - - public static String getItemId(ItemStack item) { - NBTItem nbtItem = NBTItem.get(item); - if (nbtItem.hasType()) { - return nbtItem.getString("MMOITEMS_ITEM_ID"); - } - return item.getType().toString(); - } - - public static boolean hasCustomName(ItemStack item) { - if (item != null && item.hasItemMeta()) { - ItemMeta meta = item.getItemMeta(); - return meta != null && meta.hasDisplayName(); - } - return false; - } - - public static String getCustomName(ItemStack item) { - if (hasCustomName(item)) { - ItemMeta meta = item.getItemMeta(); - return meta.getDisplayName(); - } - return null; - } - - public static void storeDataInItem(ItemStack item, String data, JavaPlugin plugin) { - if (item != null && data != null) { - ItemMeta meta = item.getItemMeta(); - if (meta != null) { - NamespacedKey key = new NamespacedKey(plugin, "bb_data"); - meta.getPersistentDataContainer().set(key, PersistentDataType.STRING, data); - meta.addItemFlags(org.bukkit.inventory.ItemFlag.HIDE_ATTRIBUTES); - item.setItemMeta(meta); - } - } - } - - public static String getDataFromItem(ItemStack item, JavaPlugin plugin) { - if (item != null) { - ItemMeta meta = item.getItemMeta(); - if (meta != null) { - NamespacedKey key = new NamespacedKey(plugin, "bb_data"); - String data = meta.getPersistentDataContainer().get(key, PersistentDataType.STRING); - if (data != null) { - return data; - } - } - } - return null; - } - - @SuppressWarnings("deprecation") - public static ItemStack generateMMOItem(String type, String id, int amount) { - if (!(pluginExists("MMOItems") && pluginExists("MythicLib"))) { - Bukkit.getLogger().info("[BreedingBuddies] ERROR! This operation requires MMOItems and MythicLib!"); - return new ItemStack(Material.DIRT, 1); - } - - // Null when MMOItems has no such item; callers fall back to a placeholder. - ItemStack item = MMOItems.plugin.getItem(MMOItems.plugin.getTypes().get(type.toUpperCase()), id.toUpperCase()); - if (item != null) { - item.setAmount(amount); - } - return item; - } - - public static boolean pluginExists(String pluginName) { - Plugin plugin = Bukkit.getPluginManager().getPlugin(pluginName); - return plugin != null && plugin.isEnabled(); - } -} \ No newline at end of file diff --git a/src/main/java/net/tfminecraft/breedingbuddies/MountStats.java b/src/main/java/net/tfminecraft/breedingbuddies/MountStats.java deleted file mode 100644 index 0e347c5..0000000 --- a/src/main/java/net/tfminecraft/breedingbuddies/MountStats.java +++ /dev/null @@ -1,84 +0,0 @@ -package net.tfminecraft.breedingbuddies; - -import org.bukkit.configuration.ConfigurationSection; -import org.bukkit.configuration.file.FileConfiguration; - -public class MountStats { - public final double minHealth, maxHealth, minSpeed, maxSpeed, minJump, maxJump; - public static final double defMinHealth = 15, defMaxHealth = 30; - public static final double defMinSpeed = 0.1125, defMaxSpeed = 0.23; - public static final double defMinJump = 0.4, defMaxJump = 0.6; private static FileConfiguration bundlesConfig; - private static FileConfiguration numbersConfig; - - public MountStats(double minHealth, double maxHealth, - double minSpeed, double maxSpeed, - double minJump, double maxJump) { - this.minHealth = minHealth; - this.maxHealth = maxHealth; - this.minSpeed = minSpeed; - this.maxSpeed = maxSpeed; - this.minJump = minJump; - this.maxJump = maxJump; - } - - public static MountStats getMountStats(String animalKey) { - // Buscar el nombre real ignorando mayúsculas - String realKey = bundlesConfig.getKeys(false).stream() - .filter(key -> key.equalsIgnoreCase(animalKey)) - .findFirst() - .orElse(null); - - // Buscar la sección de stats personalizada (si existe) - ConfigurationSection mountSection = (realKey != null) - ? bundlesConfig.getConfigurationSection(realKey + ".mountStats") - : null; - - // Sección de valores por defecto (si existe) - ConfigurationSection defaults = numbersConfig.getConfigurationSection("defaultMountStats"); - - // Cargar los valores desde mountSection > defaults > hardcoded - return new MountStats( - getStat("minHealth", mountSection, defaults, defMinHealth), - getStat("maxHealth", mountSection, defaults, defMaxHealth), - getStat("minSpeed", mountSection, defaults, defMinSpeed), - getStat("maxSpeed", mountSection, defaults, defMaxSpeed), - getStat("minJump", mountSection, defaults, defMinJump), - getStat("maxJump", mountSection, defaults, defMaxJump) - ); - } - - private static double getStat(String key, ConfigurationSection primary, ConfigurationSection fallback, double hardcodedDefault) { - if (primary != null && primary.contains(key)) return primary.getDouble(key); - if (fallback != null && fallback.contains(key)) return fallback.getDouble(key); - return hardcodedDefault; - } - - public static void setConfigs(FileConfiguration bundles, FileConfiguration numbers) { - bundlesConfig = bundles; - numbersConfig = numbers; - } - - public double getMinHealth() { - return minHealth; - } - - public double getMaxHealth() { - return maxHealth; - } - - public double getMinSpeed() { - return minSpeed; - } - - public double getMaxSpeed() { - return maxSpeed; - } - - public double getMinJump() { - return minJump; - } - - public double getMaxJump() { - return maxJump; - } -} diff --git a/src/main/java/net/tfminecraft/breedingbuddies/MountUtils.java b/src/main/java/net/tfminecraft/breedingbuddies/MountUtils.java deleted file mode 100644 index 92dc7ec..0000000 --- a/src/main/java/net/tfminecraft/breedingbuddies/MountUtils.java +++ /dev/null @@ -1,197 +0,0 @@ -package net.tfminecraft.breedingbuddies; - -import net.tfminecraft.breedingbuddies.configurables.Numbers; -import org.bukkit.NamespacedKey; -import org.bukkit.attribute.Attribute; -import org.bukkit.attribute.AttributeInstance; -import org.bukkit.entity.AbstractHorse; -import org.bukkit.entity.Entity; -import org.bukkit.entity.LivingEntity; -import org.bukkit.persistence.PersistentDataType; - -public class MountUtils { - private MountUtils() { - } - - /** - * Comprueba si una entidad es una montura con stats (vida, salto, velocidad) - */ - public static boolean isFullStatMount(Entity entity) { - return entity instanceof AbstractHorse; - } - - /** - * Establece la vida máxima y actual - */ - public static void setMaxHealth(LivingEntity entity, double health) { - AttributeInstance attr = entity.getAttribute(Attribute.MAX_HEALTH); - if (attr != null) { - attr.setBaseValue(health); - entity.setHealth(Math.min(health, entity.getHealth())); // ajusta si la salud actual es mayor que el máximo - } - } - - /** - * Establece la velocidad de movimiento - */ - public static void setSpeed(LivingEntity entity, double speed) { - AttributeInstance attr = entity.getAttribute(Attribute.MOVEMENT_SPEED); - if (attr != null) { - attr.setBaseValue(speed); - } - } - - /** - * Establece la fuerza de salto (solo para AbstractHorse y Camel) - */ - public static void setJumpStrength(Entity entity, double jumpStrength) { - if (entity instanceof AbstractHorse) { - ((AbstractHorse) entity).setJumpStrength(jumpStrength); - } - } - - /** - * Obtiene la fuerza de salto (si es aplicable) - */ - public static double getJumpStrength(Entity entity) { - if (entity instanceof AbstractHorse) { - return ((AbstractHorse) entity).getJumpStrength(); - } - return -1; - } - - /** - * Obtiene la velocidad base - */ - public static double getSpeed(LivingEntity entity) { - AttributeInstance attr = entity.getAttribute(Attribute.MOVEMENT_SPEED); - return attr != null ? attr.getBaseValue() : -1; - } - - /** - * Obtiene la vida máxima - */ - public static double getMaxHealth(LivingEntity entity) { - AttributeInstance attr = entity.getAttribute(Attribute.MAX_HEALTH); - return attr != null ? attr.getBaseValue() : -1; - } - - /** - * Convierte jumpStrength (0.4 a 1.0) a altura en bloques (Java Edition). - */ - public static double jumpStrengthToBlockHeight(double strength) { - return -0.1817584952 * Math.pow(strength, 3) + 3.689713992 * Math.pow(strength, 2) + 2.128599134 * strength - 0.343930367; - } - - /** - * Convierte velocidad interna a bloques por segundo. - */ - public static double speedToBlocksPerSecond(double internalSpeed) { - return internalSpeed * 42.16; - } - - /** - * Convierte los puntos de vida a corazones (enteros). - * Por ejemplo, 30 puntos de vida => 15 corazones. - */ - public static int healthPointsToHearts(double healthPoints) { - return (int) (healthPoints / 2); - } - - // Herencia con aleatoriedad controlada - public static double breedAttribute(double parent1, double parent2, double min, double max) { - double normP1 = (parent1 - min) / (max - min); - double normP2 = (parent2 - min) / (max - min); - - double minVal = Math.min(normP1, normP2); - double maxVal = Math.max(normP1, normP2); - - double offset = Math.pow(maxVal - minVal, 3) * 3.5; - double adjMax = maxVal + ((1 - maxVal - offset) / 4.0); - double adjMin = minVal - ((1 - minVal) / 5.5); - - double result = Math.random() * (adjMax - adjMin) + adjMin; - result = Math.min(Math.max(result, 0), 1); - - return result * (max - min) + min; - } - - public static void applyInheritedStats(LivingEntity child, LivingEntity parent1, LivingEntity parent2) { - MountStats mountStats = MountStats.getMountStats(child.getType().name()); - double minHealth = mountStats.getMinHealth(); - double maxHealth = mountStats.getMaxHealth(); - - double minSpeed = mountStats.getMinSpeed(); - double maxSpeed = mountStats.getMaxSpeed(); - - double minJump = mountStats.getMinJump(); - double maxJump = mountStats.getMaxJump(); - - if (!isFullStatMount(child) || !isFullStatMount(parent1) || !isFullStatMount(parent2)) return; - - double speed1 = Math.min(getSpeed(parent1), maxSpeed); - double speed2 = Math.min(getSpeed(parent2), maxSpeed); - double speed = breedAttribute(speed1, speed2, minSpeed, maxSpeed); - setSpeed(child, speed); - - double health1 = Math.min(getMaxHealth(parent1), maxHealth); - double health2 = Math.min(getMaxHealth(parent2), maxHealth); - - double health = breedAttribute(health1, health2, minHealth, maxHealth); - setMaxHealth(child, health); - - double jump1 = Math.min(getJumpStrength(parent1), maxJump); - double jump2 = Math.min(getJumpStrength(parent2), maxJump); - double jump = (jump1 >= 0 && jump2 >= 0) ? breedAttribute(jump1, jump2, minJump, maxJump) : -1; - - if (jump >= 0) { - setJumpStrength(child, jump); - } - } - - public static int calculateGeneticsFromStats(double health, double speed, double jump, String entityName) { - MountStats mountStats = MountStats.getMountStats(entityName); - - double minHealth = mountStats.getMinHealth(); - double maxHealth = mountStats.getMaxHealth(); - - double minSpeed = mountStats.getMinSpeed(); - double maxSpeed = mountStats.getMaxSpeed(); - - double minJump = mountStats.getMinJump(); - double maxJump = mountStats.getMaxJump(); - - double normHealth = (health - minHealth) / (maxHealth - minHealth); - double normSpeed = (speed - minSpeed) / (maxSpeed - minSpeed); - double normJump = (jump - minJump) / (maxJump - minJump); - - // Asegurar que estén entre 0 y 1 - normHealth = Math.min(1, Math.max(0, normHealth)); - normSpeed = Math.min(1, Math.max(0, normSpeed)); - normJump = Math.min(1, Math.max(0, normJump)); - - double average = (normHealth + normSpeed + normJump) / 3.0; - return (int) Math.round(average * Numbers.maxFriendshipAndGenetics); - } - - public static int getGenetics(Entity entity) { - LivingEntity livingEntity = (LivingEntity) entity; - double health = MountUtils.getMaxHealth(livingEntity); - double speed = MountUtils.getSpeed(livingEntity); - double jump = MountUtils.getJumpStrength(livingEntity); - - return MountUtils.calculateGeneticsFromStats(health, speed, jump, entity.getType().name()); - } - - public static void setNeuteredFlag(Entity entity, boolean isNeutered) { - NamespacedKey key = new NamespacedKey(BreedingBuddies.getInstance(), "is_neutered"); - entity.getPersistentDataContainer().set(key, PersistentDataType.BYTE, (byte) (isNeutered ? 1 : 0)); - } - - public static boolean isNeutered(Entity entity) { - NamespacedKey key = new NamespacedKey(BreedingBuddies.getInstance(), "is_neutered"); - Byte value = entity.getPersistentDataContainer().get(key, PersistentDataType.BYTE); - return (value != null && value == 1) || entity.getScoreboardTags().contains("ho.isNeutered"); - } -} - diff --git a/src/main/java/net/tfminecraft/breedingbuddies/OwnershipManager.java b/src/main/java/net/tfminecraft/breedingbuddies/OwnershipManager.java deleted file mode 100644 index 9e5d9d3..0000000 --- a/src/main/java/net/tfminecraft/breedingbuddies/OwnershipManager.java +++ /dev/null @@ -1,52 +0,0 @@ -package net.tfminecraft.breedingbuddies; - -import net.tfminecraft.tlibs.character.CharacterRef; -import java.util.*; - -public class OwnershipManager { - private OwnershipManager() {} - public static void registerOwnership(UUID account, FarmAnimal animal) { - CharacterRef ref = AnimalIdentity.session(account).orElseThrow(() -> new IllegalStateException("Current character required")).ref(); - if (PluginData.isHeld(animal.getUuid()) || hasHeldSlots(animal.getUuid())) throw new IllegalStateException("Animal ownership requires review"); - CharacterRef existing = animal.getOwnerRefs().get(ref.characterId()); - if (existing != null && !existing.equals(ref)) throw new IllegalStateException("Character owner account conflicts"); - if (animal.getOwnerRefs().isEmpty()) animal.getOwnersUuids().clear(); - animal.getOwnerRefs().put(ref.characterId(), ref); animal.getOwnersUuids().add(ref.characterId()); - PluginData.getPlayerAnimals().computeIfAbsent(ref.characterId(), ignored -> new HashSet<>()).add(animal); - PluginData.getUnownedAnimals().remove(animal.getUuid()); - PluginData.saveAnimals(); - } - public static void removeOwnership(UUID account, FarmAnimal animal) { - AnimalIdentity.session(account).ifPresent(session -> { - if (!PluginData.isHeld(animal.getUuid()) && session.ref().equals(animal.getOwnerRefs().get(session.ref().characterId()))) { - UUID character = session.ref().characterId(); animal.getOwnerRefs().remove(character); animal.getOwnersUuids().remove(character); - PluginData.getPlayerAnimals().getOrDefault(character, new HashSet<>()).remove(animal); - if (animal.getOwnerRefs().isEmpty()) PluginData.getUnownedAnimals().put(animal.getUuid(), animal); - PluginData.saveAnimals(); - } - }); - } - public static void removeAnimal(UUID animal) { - if (PluginData.isHeld(animal)) return; - PluginData.getPlayerAnimals().values().forEach(animals -> animals.removeIf(value -> { if (!value.getUuid().equals(animal)) return false; value.getOwnersUuids().clear(); value.getOwnerRefs().clear(); return true; })); - try { if (!PluginData.getUnownedAnimals().containsKey(animal)) PluginData.ownership().forget(animal); PluginData.saveAnimals(); } - catch (java.io.IOException failure) { throw new IllegalStateException(failure); } - } - public static Set getAnimals(UUID account) { - return AnimalIdentity.session(account).map(s -> PluginData.getPlayerAnimals().getOrDefault(s.ref().characterId(), new HashSet<>())).orElse(Set.of()); - } - public static boolean isOwner(UUID account, UUID animal) { - if (PluginData.isHeld(animal)) return false; - return AnimalIdentity.session(account).map(s -> getAnimals(account).stream().anyMatch(a -> a.getUuid().equals(animal) && s.ref().equals(a.getOwnerRefs().get(s.ref().characterId())))).orElse(false); - } - public static boolean hasOwner(UUID animal) { return PluginData.ownership() == null || PluginData.ownership().hasOwner(animal); } - public static boolean hasHeldSlots(UUID animal) { - if (PluginData.isHeld(animal)) return true; - var row = PluginData.ownership().rows.get(animal); return row != null && !((org.json.simple.JSONArray) row.get("heldOwners")).isEmpty(); - } - public static FarmAnimal getAnimal(UUID animal, UUID account) { return isOwner(account, animal) ? getAnimal(animal) : null; } - public static FarmAnimal getAnimal(UUID animal) { - if (PluginData.isHeld(animal)) return null; - return PluginData.getPlayerAnimals().values().stream().flatMap(Set::stream).filter(a -> a.getUuid().equals(animal)).findFirst().orElse(null); - } -} diff --git a/src/main/java/net/tfminecraft/breedingbuddies/ParticleManager.java b/src/main/java/net/tfminecraft/breedingbuddies/ParticleManager.java deleted file mode 100644 index f778684..0000000 --- a/src/main/java/net/tfminecraft/breedingbuddies/ParticleManager.java +++ /dev/null @@ -1,32 +0,0 @@ -package net.tfminecraft.breedingbuddies; - -import org.bukkit.Particle; -import org.bukkit.entity.Entity; -import org.bukkit.entity.LivingEntity; - -public class ParticleManager { - private ParticleManager() { - } - - public static void heartParticles(Entity entity) { - if (entity instanceof LivingEntity) { - LivingEntity livingEntity = (LivingEntity) entity; - double x = livingEntity.getLocation().getX(); - double y = livingEntity.getLocation().getY() + 1.0; - double z = livingEntity.getLocation().getZ(); - - livingEntity.getWorld().spawnParticle(Particle.HEART, x, y, z, 20, 0.5, 0.5, 0.5, 0.1); - } - } - - public static void cherryParticles(Entity entity) { - if (entity instanceof LivingEntity) { - LivingEntity livingEntity = (LivingEntity) entity; - double x = livingEntity.getLocation().getX(); - double y = livingEntity.getLocation().getY() + 1.0; - double z = livingEntity.getLocation().getZ(); - - livingEntity.getWorld().spawnParticle(Particle.CHERRY_LEAVES, x, y, z, 10, 0.5, 0.5, 0.5, 0.1); - } - } -} diff --git a/src/main/java/net/tfminecraft/breedingbuddies/PluginData.java b/src/main/java/net/tfminecraft/breedingbuddies/PluginData.java deleted file mode 100644 index 0692031..0000000 --- a/src/main/java/net/tfminecraft/breedingbuddies/PluginData.java +++ /dev/null @@ -1,174 +0,0 @@ -package net.tfminecraft.breedingbuddies; - -import java.io.File; -import java.io.FileReader; -import java.io.FileWriter; -import java.util.ArrayList; -import java.util.Date; -import java.util.HashMap; -import java.util.HashSet; -import java.util.Map; -import java.util.Set; -import java.util.UUID; - -import net.tfminecraft.breedingbuddies.listeners.DayChangeListener; -import org.bukkit.Bukkit; -import org.bukkit.Chunk; -import org.bukkit.World; -import org.json.simple.JSONArray; -import org.json.simple.JSONObject; -import org.json.simple.parser.JSONParser; - - - -public class PluginData { - - private static Map unownedAnimals = new HashMap<>(); - private static Map> playerAnimals = new HashMap<>(); - private static ArrayList chunkAreas = new ArrayList(); - - private static final String PLAYER_DATA_DIR = "plugins/BreedingBuddies/Data/PlayerData"; - private static final String UNOWNED_ANIMALS_DATA_DIR = "plugins/BreedingBuddies/Data/unownedanimals.json"; - private static final String STABLE_CHUNKS_DATA_DIR = "plugins/BreedingBuddies/Data/stablechunks.json"; - - private PluginData() { - } - - public static void saveAllData(boolean force) { - if (!force && DayChangeListener.isIsChangingDay()) return; - PluginData.saveAnimals(); - PluginData.saveStableChunks(); - Bukkit.getLogger().info("[BreedingBuddies] Saving all data..."); - } - - public static void loadAllData() { - loadStableChunks(); - loadAnimals(); - Bukkit.getLogger().info("[BreedingBuddies] Loading all data..."); - } - - public static void saveStableChunks() { - try { - File file = new File(STABLE_CHUNKS_DATA_DIR); - file.getParentFile().mkdirs(); // Ensure the directory exists - - JSONArray chunkAreasJsonArray = new JSONArray(); - for (ChunkArea chunkArea : chunkAreas) { - JSONObject chunkAreaJson = new JSONObject(); - chunkAreaJson.put("daysAbandoned", chunkArea.getDaysAbandoned()); - - JSONArray chunksJsonArray = new JSONArray(); - for (Chunk chunk : chunkArea.getChunks()) { - JSONObject chunkJson = new JSONObject(); - chunkJson.put("world", chunk.getWorld().getName()); - chunkJson.put("x", chunk.getX()); - chunkJson.put("z", chunk.getZ()); - chunksJsonArray.add(chunkJson); - } - chunkAreaJson.put("chunks", chunksJsonArray); - chunkAreasJsonArray.add(chunkAreaJson); - } - - FileWriter fileWriter = new FileWriter(file); - fileWriter.write(chunkAreasJsonArray.toJSONString()); - fileWriter.flush(); - fileWriter.close(); - } catch (Exception e) { - e.printStackTrace(); - } - } - - public static void loadStableChunks() { - try { - File file = new File(STABLE_CHUNKS_DATA_DIR); - if (!file.exists()) { - return; - } - - JSONParser parser = new JSONParser(); - FileReader reader = new FileReader(file); - JSONArray chunkAreasJsonArray = (JSONArray) parser.parse(reader); - reader.close(); - - chunkAreas.clear(); - - for (Object chunkAreaObj : chunkAreasJsonArray) { - JSONObject chunkAreaJson = (JSONObject) chunkAreaObj; - int daysAbandoned = ((Long) chunkAreaJson.get("daysAbandoned")).intValue(); - - JSONArray chunksJsonArray = (JSONArray) chunkAreaJson.get("chunks"); - Set chunks = new HashSet<>(); - for (Object chunkObj : chunksJsonArray) { - JSONObject chunkJson = (JSONObject) chunkObj; - String worldName = (String) chunkJson.get("world"); - World world = Bukkit.getWorld(worldName); - if (world != null) { - int x = ((Long) chunkJson.get("x")).intValue(); - int z = ((Long) chunkJson.get("z")).intValue(); - Chunk chunk = world.getChunkAt(x, z); - chunks.add(chunk); - } else { - System.err.println("World " + worldName + " not found."); - } - } - - ChunkArea chunkArea = new ChunkArea(chunks); - chunkArea.setDaysAbandoned(daysAbandoned); - chunkAreas.add(chunkArea); - } - - } catch (Exception e) { - e.printStackTrace(); - } - } - - private static AnimalOwnershipStore ownership; - static AnimalOwnershipStore ownership() { return ownership; } - public static boolean ownershipReady() { return ownership != null && ownership.ready; } - public static boolean isHeld(UUID id) { return ownership == null || ownership.held(id); } - public static boolean knowsAnimal(UUID id) { return ownership != null && (ownership.rows.containsKey(id) || ownership.held.containsKey(id)); } - public static void saveUnownedAnimals() { saveAnimals(); } - public static void loadUnownedAnimals() { loadAnimals(); } - static void saveAllPlayerData() { saveAnimals(); } - public static void savePlayerData(UUID account, Set ignored) { saveAnimals(); } - public static void loadPlayerData(UUID account, Map ignored) { loadAnimals(); } - public static void loadAllPlayerData() { loadAnimals(); } - public static void loadAnimals() { - ownership = new AnimalOwnershipStore(java.nio.file.Path.of("plugins/BreedingBuddies/Data")); - AnimalIdentity.open(ownership); - rebuildAnimals(); - } - static void rebuildAnimals() { - playerAnimals.clear(); unownedAnimals.clear(); - if (!ownershipReady()) return; - for (var entry : ownership.rows.entrySet()) { - if (ownership.held.containsKey(entry.getKey())) continue; - try { - FarmAnimal animal = AnimalCodec.load(entry.getValue()); - if (animal.getOwnerRefs().isEmpty()) unownedAnimals.put(animal.getUuid(), animal); - for (UUID character : animal.getOwnerRefs().keySet()) playerAnimals.computeIfAbsent(character, ignored -> new HashSet<>()).add(animal); - } catch (RuntimeException failure) { - ownership.held.put(entry.getKey(), "Unloaded animal: " + failure.getMessage()); - } - } - } - static void saveAnimals() { - if (!ownershipReady()) return; - Set animals = new HashSet<>(unownedAnimals.values()); - playerAnimals.values().forEach(animals::addAll); - try { ownership.save(animals); } - catch (Exception failure) { ownership.ready = false; throw new IllegalStateException("Animal fanout requires recovery", failure); } - } - - public static Map getUnownedAnimals() { - return unownedAnimals; - } - - public static Map> getPlayerAnimals() { - return playerAnimals; - } - - public static ArrayList getChunkAreas() { - return chunkAreas; - } -} diff --git a/src/main/java/net/tfminecraft/breedingbuddies/SoundManager.java b/src/main/java/net/tfminecraft/breedingbuddies/SoundManager.java deleted file mode 100644 index 728005f..0000000 --- a/src/main/java/net/tfminecraft/breedingbuddies/SoundManager.java +++ /dev/null @@ -1,52 +0,0 @@ -package net.tfminecraft.breedingbuddies; - -import org.bukkit.Sound; -import org.bukkit.entity.Entity; -import org.bukkit.entity.LivingEntity; -import org.bukkit.entity.Player; - -public class SoundManager { - private SoundManager() { - } - - public static void playAmethystStepSound(Entity entity) { - if (entity instanceof LivingEntity) { - LivingEntity livingEntity = (LivingEntity) entity; - livingEntity.getWorld().playSound(livingEntity.getLocation(), "block.amethyst_block.step", 1.0f, 1.0f); - } - } - - public static void playAmethystClusterStepSound(Entity entity) { - if (entity instanceof LivingEntity) { - LivingEntity livingEntity = (LivingEntity) entity; - livingEntity.getWorld().playSound(livingEntity.getLocation(), Sound.BLOCK_AMETHYST_CLUSTER_STEP, 1.0f, 1.0f); - } - } - - public static void playAngryCowSound(Entity entity) { - if (entity instanceof LivingEntity) { - String name = ("entity." + entity.getType() + ".ambient").toLowerCase(); - LivingEntity livingEntity = (LivingEntity) entity; - livingEntity.getWorld().playSound(livingEntity.getLocation(), name, 1.0f, 1.0f); - } - } - - public static void playFoalScream(Entity entity) { - if (entity instanceof LivingEntity) { - LivingEntity livingEntity = (LivingEntity) entity; - livingEntity.getWorld().playSound(livingEntity.getLocation(), Sound.ENTITY_HORSE_DEATH, 1.2f, 1.5f); - } - } - - public static void playBookOpenSound(Player player) { - player.playSound(player.getLocation(), Sound.ITEM_BOOK_PAGE_TURN, 1.0f, 1.0f); - } - - public static void playExperienceSound(Player player) { - player.playSound(player.getLocation(), Sound.ENTITY_EXPERIENCE_ORB_PICKUP, 1.0f, 1.0f); - } - - public static void playSignSound(Player player) { - player.playSound(player.getLocation(), Sound.BLOCK_HANGING_SIGN_PLACE, 1.0f, 1.0f); - } -} diff --git a/src/main/java/net/tfminecraft/breedingbuddies/UnownedAnimalsManager.java b/src/main/java/net/tfminecraft/breedingbuddies/UnownedAnimalsManager.java deleted file mode 100644 index 571124f..0000000 --- a/src/main/java/net/tfminecraft/breedingbuddies/UnownedAnimalsManager.java +++ /dev/null @@ -1,24 +0,0 @@ -package net.tfminecraft.breedingbuddies; - -import java.util.UUID; - -public class UnownedAnimalsManager { - private UnownedAnimalsManager() { - } - - public static void addUnownedAnimal(FarmAnimal animal) { - PluginData.getUnownedAnimals().putIfAbsent(animal.getUuid(), animal); - } - - public static void removeUnownedAnimal(UUID animalUuid) { - PluginData.getUnownedAnimals().remove(animalUuid); - } - - public static FarmAnimal getAnimal(UUID animalUuid) { - return PluginData.getUnownedAnimals().get(animalUuid); - } - - public static boolean isUnowned(UUID animalUuid) { - return PluginData.getUnownedAnimals().containsKey(animalUuid); - } -} diff --git a/src/main/java/net/tfminecraft/breedingbuddies/configurables/CustomItems.java b/src/main/java/net/tfminecraft/breedingbuddies/configurables/CustomItems.java deleted file mode 100644 index c1816a9..0000000 --- a/src/main/java/net/tfminecraft/breedingbuddies/configurables/CustomItems.java +++ /dev/null @@ -1,30 +0,0 @@ -package net.tfminecraft.breedingbuddies.configurables; - -import org.bukkit.configuration.file.FileConfiguration; - -public class CustomItems { - public static String tamingItem; - public static String coownershipItem; - public static String universalFeed; - public static String caringItem; - public static String farmReportItem; - public static String stableChunkItem; - public static String collectorsItem; - public static String mountStatsItem; - public static String neuterItem; - - private CustomItems() { - } - - public static void loadConfig(FileConfiguration config) { - tamingItem = config.getString("items.tamingItem", "minecraft:carrot"); - coownershipItem = config.getString("items.coownershipItem", "minecraft:paper"); - universalFeed = config.getString("items.universalFeed", "minecraft:wheat"); - caringItem = config.getString("items.caringItem", "minecraft:golden_apple"); - farmReportItem = config.getString("items.farmReportItem", "minecraft:book"); - stableChunkItem = config.getString("items.stableChunkItem", "minecraft:fence"); - collectorsItem = config.getString("items.collectorsItem", "minecraft:hopper"); - mountStatsItem = config.getString("items.mountStatsItem", "minecraft:carrot_on_a_stick"); - neuterItem = config.getString("items.neuterItem", "minecraft:shears"); - } -} diff --git a/src/main/java/net/tfminecraft/breedingbuddies/configurables/Messages.java b/src/main/java/net/tfminecraft/breedingbuddies/configurables/Messages.java deleted file mode 100644 index 299827e..0000000 --- a/src/main/java/net/tfminecraft/breedingbuddies/configurables/Messages.java +++ /dev/null @@ -1,66 +0,0 @@ -package net.tfminecraft.breedingbuddies.configurables; - -import org.bukkit.configuration.file.FileConfiguration; - -public class Messages { - public static String inventoryName; - public static String nameFirst; - public static String ownerFirst; - public static String invalidAnimalOrNotFound; - public static String alreadyOwner; - public static String nowCoowner; - public static String ownershipShared; - public static String useOnOwnedAnimal; - public static String onlyOwnerCanShare; - public static String nowOwner; - public static String notOwner; - public static String alreadyFed; - public static String alreadyCared; - public static String animalsOut; - public static String spaceNeeded; - public static String waterNeeded; - public static String animalsFed; - public static String animalsCared; - public static String animalsOwned; - public static String lacksSpace; - public static String stableChunk; - public static String alreadyStableChunk; - public static String alreadyLinked; - public static String neuteredHorse; - public static String tameFirst; - public static String removeOwnership; - - private Messages() { - } - - public static void loadConfig(FileConfiguration config) { - inventoryName = config.getString("messages.inventoryName"); - nameFirst = config.getString("messages.nameFirst"); - ownerFirst = config.getString("messages.ownerFirst"); - invalidAnimalOrNotFound = config.getString("messages.invalidAnimalOrNotFound"); - alreadyOwner = config.getString("messages.alreadyOwner"); - nowCoowner = config.getString("messages.nowCoowner"); - ownershipShared = config.getString("messages.ownershipShared"); - useOnOwnedAnimal = config.getString("messages.useOnOwnedAnimal"); - onlyOwnerCanShare = config.getString("messages.onlyOwnerCanShare"); - nowOwner = config.getString("messages.nowOwner"); - notOwner = config.getString("messages.notOwner"); - alreadyFed = config.getString("messages.alreadyFed"); - alreadyCared = config.getString("messages.alreadyCared"); - animalsOut = config.getString("messages.animalsOut"); - spaceNeeded = config.getString("messages.spaceNeeded"); - waterNeeded = config.getString("messages.waterNeeded"); - animalsFed = config.getString("messages.animalsFed"); - animalsCared = config.getString("messages.animalsCared"); - animalsOwned = config.getString("messages.animalsOwned"); - lacksSpace = config.getString("messages.lacksSpace"); - stableChunk = config.getString("messages.stableChunk"); - alreadyStableChunk = config.getString("messages.alreadyStableChunk"); - alreadyLinked = config.getString("messages.alreadyLinked"); - neuteredHorse = config.getString("messages.neuteredHorse"); - tameFirst = config.getString("messages.tameFirst"); - removeOwnership = config.getString("messages.removeOwnership"); - -// Bukkit.getLogger().info("[BreedingBuddies] Loaded message: nameFirst = " + nameFirst); - } -} diff --git a/src/main/java/net/tfminecraft/breedingbuddies/configurables/Numbers.java b/src/main/java/net/tfminecraft/breedingbuddies/configurables/Numbers.java deleted file mode 100644 index bb5a412..0000000 --- a/src/main/java/net/tfminecraft/breedingbuddies/configurables/Numbers.java +++ /dev/null @@ -1,51 +0,0 @@ -package net.tfminecraft.breedingbuddies.configurables; - -import org.bukkit.configuration.file.FileConfiguration; - -public class Numbers { - public static int maxFriendshipAndGenetics; - public static int initialGeneticMax; - public static int friendshipByFeeding; - public static int friendshipByCaring; - public static int friendshipLostNotFeeding; - public static int friendshipLostNotCaring; - public static int maxAnimalsPerChunk; - public static int maxIrlDaysOut; - public static int maxIrlDaysChunkAbandoned; - public static double geneticVarianceMultiplier; - public static double friendshipInfluenceMultiplier; - public static double geneticDivisorMultiplierToRetardProgression; - public static double hoursBetweenRewards; - public static int startingDayHour; - public static int startingDayMin; - - public static int minMountHealth; - public static double minMountSpeed; - - public static double minMountJump; - public static double mountNerfDivisor; - public static boolean nerfMountStats; - - private Numbers() { - } - - public static void loadConfig(FileConfiguration numbersConfig) { - maxFriendshipAndGenetics = numbersConfig.getInt("maxFriendshipAndGenetics"); - initialGeneticMax = numbersConfig.getInt("initialGeneticMax"); - friendshipByFeeding = numbersConfig.getInt("friendshipByFeeding"); - friendshipByCaring = numbersConfig.getInt("friendshipByCaring"); - friendshipLostNotFeeding = numbersConfig.getInt("friendshipLostNotFeeding"); - friendshipLostNotCaring = numbersConfig.getInt("friendshipLostNotCaring"); - maxAnimalsPerChunk = numbersConfig.getInt("maxAnimalsPerChunk"); - maxIrlDaysOut = numbersConfig.getInt("maxIrlDaysOut"); - maxIrlDaysChunkAbandoned = numbersConfig.getInt("maxIrlDaysChunkAbandoned"); - geneticVarianceMultiplier = numbersConfig.getDouble("geneticVarianceMultiplier"); - friendshipInfluenceMultiplier = numbersConfig.getDouble("friendshipInfluenceMultiplier"); - geneticDivisorMultiplierToRetardProgression = numbersConfig.getDouble("geneticDivisorMultiplierToRetardProgression"); - hoursBetweenRewards = numbersConfig.getDouble("hoursBetweenRewards"); - startingDayHour = numbersConfig.getInt("startingDayHour"); - startingDayMin = numbersConfig.getInt("startingDayMin"); - mountNerfDivisor = numbersConfig.getDouble("mountNerfDivisor"); - nerfMountStats = numbersConfig.getBoolean("nerfMountStats"); - } -} diff --git a/src/main/java/net/tfminecraft/breedingbuddies/events/DayChangeEvent.java b/src/main/java/net/tfminecraft/breedingbuddies/events/DayChangeEvent.java deleted file mode 100644 index 70a237a..0000000 --- a/src/main/java/net/tfminecraft/breedingbuddies/events/DayChangeEvent.java +++ /dev/null @@ -1,17 +0,0 @@ -package net.tfminecraft.breedingbuddies.events; - -import org.bukkit.event.Event; -import org.bukkit.event.HandlerList; - -public class DayChangeEvent extends Event { - private static final HandlerList HANDLERS_LIST = new HandlerList(); - - @Override - public HandlerList getHandlers() { - return HANDLERS_LIST; - } - - public static HandlerList getHandlerList() { - return HANDLERS_LIST; - } -} diff --git a/src/main/java/net/tfminecraft/breedingbuddies/husbandry/HusbandryAmountBand.java b/src/main/java/net/tfminecraft/breedingbuddies/husbandry/HusbandryAmountBand.java new file mode 100644 index 0000000..16f7bd6 --- /dev/null +++ b/src/main/java/net/tfminecraft/breedingbuddies/husbandry/HusbandryAmountBand.java @@ -0,0 +1,36 @@ +package net.tfminecraft.breedingbuddies.husbandry; + +public final class HusbandryAmountBand { + + private final int minGenetics; + private final int roastCuts; + private final int wool; + private final int secondaryExtra; + + public HusbandryAmountBand(int minGenetics, int roastCuts, int wool) { + this(minGenetics, roastCuts, wool, 0); + } + + public HusbandryAmountBand(int minGenetics, int roastCuts, int wool, int secondaryExtra) { + this.minGenetics = minGenetics; + this.roastCuts = roastCuts; + this.wool = wool; + this.secondaryExtra = Math.max(0, Math.min(2, secondaryExtra)); + } + + public int minGenetics() { + return minGenetics; + } + + public int roastCuts() { + return roastCuts; + } + + public int wool() { + return wool; + } + + public int secondaryExtra() { + return secondaryExtra; + } +} diff --git a/src/main/java/net/tfminecraft/breedingbuddies/husbandry/HusbandryAnimal.java b/src/main/java/net/tfminecraft/breedingbuddies/husbandry/HusbandryAnimal.java new file mode 100644 index 0000000..307ab38 --- /dev/null +++ b/src/main/java/net/tfminecraft/breedingbuddies/husbandry/HusbandryAnimal.java @@ -0,0 +1,305 @@ +package net.tfminecraft.breedingbuddies.husbandry; + +import java.util.UUID; + +public final class HusbandryAnimal { + + private UUID uuid; + private String type; + private String name; + private HusbandryAnimalState state; + private int genetics; + private int care; + private Long hungrySince; + private Long dirtySince; + private long lastProcessedAt; + private Long unloadedAt; + private double afflictionElapsed; + private double afflictionAt; + private Long lastMilkAt; + private Long woolReadyAt; + private boolean neutered; + private Long loadedVisitStart; + private Long matureAt; + private Long shedReadyAt; + private Long eggReadyAt; + private Long lastPetAt; + private Integer geneHealth; + private Integer geneSpeed; + private Integer geneJump; + private long careUpRemainderSeconds; + private long careDownRemainderSeconds; + private String statsRevision; + private String world; + private Integer x; + private Integer y; + private Integer z; + + public HusbandryAnimal(UUID uuid, String type, String name) { + this.uuid = uuid; + this.type = type; + this.name = sanitizeName(name); + this.state = HusbandryAnimalState.UNTAMED; + this.lastProcessedAt = System.currentTimeMillis(); + } + + public UUID uuid() { + return uuid; + } + + public void setUuid(UUID uuid) { + this.uuid = uuid; + } + + public String type() { + return type; + } + + public void setType(String type) { + this.type = type; + } + + public String name() { + return name; + } + + public void setName(String name) { + this.name = sanitizeName(name); + } + + public HusbandryAnimalState state() { + return state; + } + + public void setState(HusbandryAnimalState state) { + this.state = state == null ? HusbandryAnimalState.UNTAMED : state; + } + + public int genetics() { + return genetics; + } + + public void setGenetics(int genetics) { + this.genetics = genetics; + } + + public int care() { + return care; + } + + public void setCare(int care) { + this.care = care; + } + + public Long hungrySince() { + return hungrySince; + } + + public void setHungrySince(Long hungrySince) { + this.hungrySince = hungrySince; + } + + public Long dirtySince() { + return dirtySince; + } + + public void setDirtySince(Long dirtySince) { + this.dirtySince = dirtySince; + } + + public long lastProcessedAt() { + return lastProcessedAt; + } + + public void setLastProcessedAt(long lastProcessedAt) { + this.lastProcessedAt = lastProcessedAt; + } + + public Long unloadedAt() { + return unloadedAt; + } + + public void setUnloadedAt(Long unloadedAt) { + this.unloadedAt = unloadedAt; + } + + public double afflictionElapsed() { + return afflictionElapsed; + } + + public void setAfflictionElapsed(double afflictionElapsed) { + this.afflictionElapsed = afflictionElapsed; + } + + public double afflictionAt() { + return afflictionAt; + } + + public void setAfflictionAt(double afflictionAt) { + this.afflictionAt = afflictionAt; + } + + public Long lastMilkAt() { + return lastMilkAt; + } + + public void setLastMilkAt(Long lastMilkAt) { + this.lastMilkAt = lastMilkAt; + } + + public Long woolReadyAt() { + return woolReadyAt; + } + + public void setWoolReadyAt(Long woolReadyAt) { + this.woolReadyAt = woolReadyAt; + } + + public boolean neutered() { + return neutered; + } + + public void setNeutered(boolean neutered) { + this.neutered = neutered; + } + + public Long loadedVisitStart() { + return loadedVisitStart; + } + + public void setLoadedVisitStart(Long loadedVisitStart) { + this.loadedVisitStart = loadedVisitStart; + } + + public Long matureAt() { + return matureAt; + } + + public void setMatureAt(Long matureAt) { + this.matureAt = matureAt; + } + + public Long shedReadyAt() { + return shedReadyAt; + } + + public void setShedReadyAt(Long shedReadyAt) { + this.shedReadyAt = shedReadyAt; + } + + public Long eggReadyAt() { + return eggReadyAt; + } + + public void setEggReadyAt(Long eggReadyAt) { + this.eggReadyAt = eggReadyAt; + } + + public Long lastPetAt() { + return lastPetAt; + } + + public void setLastPetAt(Long lastPetAt) { + this.lastPetAt = lastPetAt; + } + + public Integer geneHealth() { + return geneHealth; + } + + public Integer geneSpeed() { + return geneSpeed; + } + + public Integer geneJump() { + return geneJump; + } + + /** Mount stat genes; null clears them so the animal's genetics stands in for all three. */ + public void setMountGenes(Integer health, Integer speed, Integer jump) { + this.geneHealth = health; + this.geneSpeed = speed; + this.geneJump = jump; + } + + public long careUpRemainderSeconds() { + return careUpRemainderSeconds; + } + + public void setCareUpRemainderSeconds(long careUpRemainderSeconds) { + this.careUpRemainderSeconds = Math.max(0, careUpRemainderSeconds); + } + + public long careDownRemainderSeconds() { + return careDownRemainderSeconds; + } + + public void setCareDownRemainderSeconds(long careDownRemainderSeconds) { + this.careDownRemainderSeconds = Math.max(0, careDownRemainderSeconds); + } + + public String statsRevision() { + return statsRevision; + } + + public void setStatsRevision(String statsRevision) { + if (statsRevision == null || statsRevision.isBlank()) { + this.statsRevision = null; + return; + } + this.statsRevision = statsRevision.trim(); + } + + public String world() { + return world; + } + + public Integer x() { + return x; + } + + public Integer y() { + return y; + } + + public Integer z() { + return z; + } + + public boolean hasLocation() { + return world != null && !world.isBlank() && x != null && y != null && z != null; + } + + public void setLastLocation(String world, int x, int y, int z) { + if (world == null || world.isBlank()) { + clearLocation(); + return; + } + this.world = world.trim(); + this.x = x; + this.y = y; + this.z = z; + } + + public void setStoredLocation(String world, Integer x, Integer y, Integer z) { + if (world == null || world.isBlank() || x == null || y == null || z == null) { + clearLocation(); + return; + } + setLastLocation(world, x, y, z); + } + + public void clearLocation() { + this.world = null; + this.x = null; + this.y = null; + this.z = null; + } + + private static String sanitizeName(String name) { + if (name == null || name.isBlank() || "???".equals(name.trim())) { + return ""; + } + return name; + } +} diff --git a/src/main/java/net/tfminecraft/breedingbuddies/husbandry/HusbandryAnimalState.java b/src/main/java/net/tfminecraft/breedingbuddies/husbandry/HusbandryAnimalState.java new file mode 100644 index 0000000..44e71e7 --- /dev/null +++ b/src/main/java/net/tfminecraft/breedingbuddies/husbandry/HusbandryAnimalState.java @@ -0,0 +1,23 @@ +package net.tfminecraft.breedingbuddies.husbandry; + +import java.util.Locale; + +public enum HusbandryAnimalState { + UNTAMED, + OWNED; + + public static HusbandryAnimalState fromStorage(String raw) { + if (raw == null || raw.isBlank()) { + return UNTAMED; + } + try { + return valueOf(raw.trim().toUpperCase(Locale.ROOT)); + } catch (IllegalArgumentException ex) { + return UNTAMED; + } + } + + public String storage() { + return name().toLowerCase(Locale.ROOT); + } +} diff --git a/src/main/java/net/tfminecraft/breedingbuddies/husbandry/HusbandryAnimalsCommand.java b/src/main/java/net/tfminecraft/breedingbuddies/husbandry/HusbandryAnimalsCommand.java new file mode 100644 index 0000000..245368f --- /dev/null +++ b/src/main/java/net/tfminecraft/breedingbuddies/husbandry/HusbandryAnimalsCommand.java @@ -0,0 +1,97 @@ +package net.tfminecraft.breedingbuddies.husbandry; + +import java.util.ArrayList; +import java.util.Collections; +import java.util.List; +import java.util.Locale; +import java.util.UUID; + +import org.bukkit.Bukkit; +import org.bukkit.OfflinePlayer; +import org.bukkit.command.Command; +import org.bukkit.command.CommandExecutor; +import org.bukkit.command.CommandSender; +import org.bukkit.command.TabCompleter; +import org.bukkit.entity.Entity; +import org.bukkit.entity.Player; + +import net.kyori.adventure.text.Component; +import net.kyori.adventure.text.format.NamedTextColor; +import net.tfminecraft.breedingbuddies.BreedingBuddies; + +public final class HusbandryAnimalsCommand implements CommandExecutor, TabCompleter { + + @Override + public boolean onCommand(CommandSender sender, Command command, String label, String[] args) { + HusbandryRepository repository = BreedingBuddies.plugin == null ? null : BreedingBuddies.plugin.getHusbandryRepository(); + if (repository == null) { + sender.sendMessage(Component.text("Animal records are not available right now.", NamedTextColor.RED)); + return true; + } + if (args.length > 0 && !sender.hasPermission("breedingbuddies.admin")) { + sender.sendMessage(Component.text("You can only list your own animals.", NamedTextColor.RED)); + return true; + } + boolean self = args.length == 0; + UUID targetId; + net.tfminecraft.tlibs.character.CharacterRef targetCharacter = null; + String ownerName; + if (self) { + if (!(sender instanceof Player player)) { + sender.sendMessage(Component.text("Usage: /" + label + " ", NamedTextColor.RED)); + return true; + } + targetId = player.getUniqueId(); + targetCharacter = HusbandryIdentity.session(player).map(session -> session.ref()).orElse(null); + if (targetCharacter == null) { + sender.sendMessage(Component.text("Choose a character before listing animals.", NamedTextColor.RED)); + return true; + } + ownerName = HusbandryIdentity.name(targetCharacter); + } else { + OfflinePlayer offline = Bukkit.getOfflinePlayerIfCached(args[0]); + if (offline == null) { + sender.sendMessage(Component.text("No player by that name has joined.", NamedTextColor.RED)); + return true; + } + targetId = offline.getUniqueId(); + ownerName = offline.getName() == null ? args[0] : offline.getName(); + } + List rows = new ArrayList<>(); + for (HusbandryOwned owned : self ? repository.listForCharacter(targetCharacter) : repository.listForPlayer(targetId)) { + HusbandryAnimal animal = HusbandryEntities.getLoaded(owned.animal().uuid()).orElse(owned.animal()); + Entity live = Bukkit.getEntity(animal.uuid()); + if (live != null) { + HusbandryLocation.remember(animal, live); + } + rows.add(new HusbandryOwned(animal, owned.role())); + } + List lines = HusbandryRoster.render(ownerName, self, rows, HusbandryConfig.maxAnimals(), + System.currentTimeMillis(), HusbandryLocator::isMissing); + if (self) { + lines.set(0, Component.text("Your character's animals (" + rows.size() + ")", NamedTextColor.GOLD)); + lines.add(1, Component.text("Account animal places: " + repository.countForPlayer(targetId) + + "/" + HusbandryConfig.maxAnimals(), NamedTextColor.GRAY)); + } + for (Component line : lines) { + sender.sendMessage(line); + } + return true; + } + + @Override + public List onTabComplete(CommandSender sender, Command command, String alias, String[] args) { + if (!sender.hasPermission("breedingbuddies.admin") || args.length != 1) { + return Collections.emptyList(); + } + String prefix = args[0].toLowerCase(Locale.ROOT); + List names = new ArrayList<>(); + for (Player player : Bukkit.getOnlinePlayers()) { + if (player.getName().toLowerCase(Locale.ROOT).startsWith(prefix)) { + names.add(player.getName()); + } + } + names.sort(String.CASE_INSENSITIVE_ORDER); + return names; + } +} diff --git a/src/main/java/net/tfminecraft/breedingbuddies/husbandry/HusbandryBreedListener.java b/src/main/java/net/tfminecraft/breedingbuddies/husbandry/HusbandryBreedListener.java new file mode 100644 index 0000000..4eca3ca --- /dev/null +++ b/src/main/java/net/tfminecraft/breedingbuddies/husbandry/HusbandryBreedListener.java @@ -0,0 +1,224 @@ +package net.tfminecraft.breedingbuddies.husbandry; + +import java.util.Optional; +import java.util.UUID; +import java.util.concurrent.ThreadLocalRandom; + +import org.bukkit.entity.Animals; +import org.bukkit.entity.Entity; +import org.bukkit.entity.LivingEntity; +import org.bukkit.entity.Player; +import org.bukkit.event.EventHandler; +import org.bukkit.event.EventPriority; +import org.bukkit.event.Listener; +import org.bukkit.event.entity.EntityBreedEvent; +import org.bukkit.event.entity.EntityEnterLoveModeEvent; +import org.bukkit.event.player.PlayerInteractEntityEvent; +import org.bukkit.inventory.EquipmentSlot; +import org.bukkit.inventory.ItemStack; + +public final class HusbandryBreedListener implements Listener { + + @EventHandler(ignoreCancelled = true) + public void onFeed(PlayerInteractEntityEvent event) { + if (!(event.getRightClicked() instanceof LivingEntity living)) { + return; + } + if (!HusbandryConfig.isHusbandryType(living.getType())) { + return; + } + Player player = event.getPlayer(); + ItemStack hand = event.getHand() == EquipmentSlot.OFF_HAND + ? player.getInventory().getItemInOffHand() + : player.getInventory().getItemInMainHand(); + HusbandryFeedQuality.offer( + living.getUniqueId(), + HusbandryFeedQuality.scaleOf(hand), + System.currentTimeMillis()); + } + + @EventHandler(ignoreCancelled = true) + public void onEnterLove(EntityEnterLoveModeEvent event) { + Entity entity = event.getEntity(); + BlockReason reason = cannotBreed(entity); + if (reason == BlockReason.NONE) { + HusbandryFeedQuality.commitOffer(entity.getUniqueId(), System.currentTimeMillis()); + return; + } + HusbandryFeedQuality.discardOffer(entity.getUniqueId()); + event.setCancelled(true); + clearLove(entity); + Player feeder = event.getHumanEntity() instanceof Player player ? player : null; + if (feeder != null) { + feeder.sendMessage(reason.message); + } + } + + @EventHandler(priority = EventPriority.HIGH, ignoreCancelled = true) + public void onBreed(EntityBreedEvent event) { + LivingEntity mother = event.getMother(); + LivingEntity father = event.getFather(); + BlockReason motherReason = cannotBreed(mother); + BlockReason fatherReason = cannotBreed(father); + if (motherReason != BlockReason.NONE || fatherReason != BlockReason.NONE) { + event.setCancelled(true); + clearLove(mother); + clearLove(father); + messageBreeder(event, firstBlocked(motherReason, fatherReason)); + return; + } + + LivingEntity child = event.getEntity(); + if (!HusbandryConfig.isHusbandryType(child.getType()) + && !HusbandryConfig.isHusbandryType(mother.getType()) + && !HusbandryConfig.isHusbandryType(father.getType())) { + return; + } + + if (!ownershipAllows(event.getBreeder(), mother, father)) { + event.setCancelled(true); + clearLove(mother); + clearLove(father); + messageBreeder(event, BlockReason.NOT_OWNER); + return; + } + + persistBaby(child, mother, father, event.getBredWith()); + scheduleMountStats(child); + } + + private static void scheduleMountStats(LivingEntity child) { + if (!HusbandryMounts.isMount(child)) { + return; + } + org.bukkit.Bukkit.getScheduler().runTaskLater(net.tfminecraft.breedingbuddies.BreedingBuddies.plugin, () -> { + if (child == null || !child.isValid()) { + return; + } + HusbandryEntities.lookup(child.getUniqueId()).ifPresent(baby -> + HusbandryMounts.applyStats(child, baby)); + }, 1L); + } + + private static BlockReason firstBlocked(BlockReason mother, BlockReason father) { + return mother != BlockReason.NONE ? mother : father; + } + + private static void messageBreeder(EntityBreedEvent event, BlockReason reason) { + if (event.getBreeder() instanceof Player player) { + player.sendMessage(reason.message); + } + } + + private static boolean ownershipAllows(LivingEntity breeder, LivingEntity mother, LivingEntity father) { + boolean owned = HusbandryOwnershipService.hasAnyOwner(mother.getUniqueId()) + || HusbandryOwnershipService.hasAnyOwner(father.getUniqueId()); + if (!owned) { + return true; + } + if (!(breeder instanceof Player player)) { + return false; + } + if (HusbandryOwnershipService.isStaff(player)) { + return true; + } + return HusbandryOwnershipService.isOwner(player, mother.getUniqueId()) + && HusbandryOwnershipService.isOwner(player, father.getUniqueId()); + } + + private static BlockReason cannotBreed(Entity entity) { + if (entity == null || !HusbandryConfig.isHusbandryType(entity.getType())) { + return BlockReason.NONE; + } + if (HusbandryEntities.repository() == null) { + return BlockReason.UNAVAILABLE; + } + Optional stored = HusbandryEntities.lookup(entity.getUniqueId()); + if (stored.isEmpty()) { + return BlockReason.NO_RECORD; + } + HusbandryAnimal animal = stored.get(); + if (animal.neutered()) { + return BlockReason.NEUTERED; + } + if (animal.hungrySince() != null || animal.dirtySince() != null) { + return BlockReason.UNWELL; + } + if (!HusbandryGrowth.isMature(animal, System.currentTimeMillis())) { + return BlockReason.IMMATURE; + } + return BlockReason.NONE; + } + + // Keep the existing legacy text representation, formatting, and exact-string comparisons. + @SuppressWarnings("deprecation") + private static void persistBaby( + LivingEntity child, + LivingEntity mother, + LivingEntity father, + ItemStack bredWith) { + HusbandryRepository repository = HusbandryEntities.repository(); + if (repository == null) { + return; + } + UUID uuid = child.getUniqueId(); + if (repository.exists(uuid)) { + return; + } + HusbandryAnimal motherAnimal = HusbandryEntities.lookup(mother.getUniqueId()).orElse(null); + HusbandryAnimal fatherAnimal = HusbandryEntities.lookup(father.getUniqueId()).orElse(null); + int motherGenetics = motherAnimal == null ? 0 : motherAnimal.genetics(); + int fatherGenetics = fatherAnimal == null ? 0 : fatherAnimal.genetics(); + int motherCare = motherAnimal == null ? 0 : motherAnimal.care(); + int fatherCare = fatherAnimal == null ? 0 : fatherAnimal.care(); + long now = System.currentTimeMillis(); + double feedScale = HusbandryFeedQuality.consumeBreedingScale( + mother.getUniqueId(), father.getUniqueId(), bredWith, now); + int genetics = HusbandryGenetics.roll( + motherGenetics, fatherGenetics, motherCare, fatherCare, ThreadLocalRandom.current(), feedScale); + String name = HusbandryEntities.displayName(child.getType()); + child.setCustomName(name); + child.setCustomNameVisible(false); + HusbandryEntities.applyPersistFlags(child); + HusbandryEntities.stampManaged(child); + + HusbandryAnimal baby = new HusbandryAnimal(uuid, child.getType().name(), name); + baby.setState(HusbandryAnimalState.UNTAMED); + baby.setGenetics(genetics); + if (HusbandryMounts.isMount(child) && motherAnimal != null && fatherAnimal != null) { + HusbandryMountGenes.inherit(baby, motherAnimal, fatherAnimal, feedScale, ThreadLocalRandom.current()); + } + baby.setCare(0); + baby.setStatsRevision(HusbandryConfig.statsRevision()); + baby.setLastProcessedAt(now); + baby.setLoadedVisitStart(now); + baby.setUnloadedAt(null); + baby.setMatureAt(HusbandryGrowth.computeMatureAt(child.getType(), now)); + HusbandryHarvest.prepareNewAnimal(baby, child.getType()); + repository.upsertAnimal(baby); + HusbandryEntities.putLoaded(baby); + HusbandryGrowth.applyMaturity(child, baby, now); + } + + private static void clearLove(Entity entity) { + if (entity instanceof Animals animals) { + animals.setLoveModeTicks(0); + } + } + + private enum BlockReason { + NONE(""), + UNAVAILABLE("§cCould not breed this animal."), + NO_RECORD("§cThis animal cannot breed."), + UNWELL("§cHungry or dirty animals cannot breed."), + NEUTERED("§cNeutered animals cannot breed."), + IMMATURE("§cThis animal is still growing up."), + NOT_OWNER("§cYou must own both parents."); + + final String message; + + BlockReason(String message) { + this.message = message; + } + } +} diff --git a/src/main/java/net/tfminecraft/breedingbuddies/husbandry/HusbandryCareListener.java b/src/main/java/net/tfminecraft/breedingbuddies/husbandry/HusbandryCareListener.java new file mode 100644 index 0000000..0202d2d --- /dev/null +++ b/src/main/java/net/tfminecraft/breedingbuddies/husbandry/HusbandryCareListener.java @@ -0,0 +1,93 @@ +package net.tfminecraft.breedingbuddies.husbandry; + +import java.util.Optional; +import java.util.concurrent.ThreadLocalRandom; + +import org.bukkit.entity.Entity; +import org.bukkit.entity.LivingEntity; +import org.bukkit.entity.Player; +import org.bukkit.event.EventHandler; +import org.bukkit.event.Listener; +import org.bukkit.event.player.PlayerInteractEntityEvent; +import org.bukkit.inventory.EquipmentSlot; +import org.bukkit.inventory.ItemStack; + +public final class HusbandryCareListener implements Listener { + + @EventHandler + public void onInteract(PlayerInteractEntityEvent event) { + if (event.getHand() != EquipmentSlot.HAND) { + return; + } + Entity clicked = event.getRightClicked(); + if (!(clicked instanceof LivingEntity living)) { + return; + } + HusbandryRepository repository = HusbandryEntities.repository(); + if (repository == null) { + return; + } + Optional stored = HusbandryEntities.lookup(living.getUniqueId()); + if (stored.isEmpty()) { + return; + } + + Player player = event.getPlayer(); + ItemStack hand = player.getInventory().getItemInMainHand(); + if (hand.getType().isAir()) { + pet(player, living, stored.get(), repository); + return; + } + boolean feed = HusbandryItems.matches(hand, HusbandryConfig.feedItem()); + boolean glove = HusbandryItems.matches(hand, HusbandryConfig.gloveItem()); + if (!feed && !glove) { + return; + } + + event.setCancelled(true); + if (!HusbandryOwnershipService.isOwner(player, living.getUniqueId()) + && !HusbandryOwnershipService.isStaff(player)) { + player.sendMessage("§cThis is not your animal."); + return; + } + HusbandryAnimal animal = stored.get(); + HusbandryTrust.Gesture gesture; + if (feed) { + if (animal.hungrySince() == null) { + return; + } + HusbandrySimulator.clearHungry(animal, ThreadLocalRandom.current()); + HusbandryItems.useFromMainHand(player); + gesture = HusbandryTrust.Gesture.FEED; + } else { + if (animal.dirtySince() == null) { + return; + } + HusbandrySimulator.clearDirty(animal, ThreadLocalRandom.current()); + gesture = HusbandryTrust.Gesture.GLOVE; + } + HusbandryFx.playCare(living); + // Staff can tend any animal, but only an owner's care builds its trust. + if (HusbandryOwnershipService.isOwner(player, living.getUniqueId())) { + HusbandryTrust.show(player, living, animal, HusbandryTrust.reward(animal, gesture, System.currentTimeMillis())); + } + repository.upsertAnimal(animal); + HusbandryStateDisplay.sync(living, animal); + HusbandryProfessionXp.tryGive(player, living.getType()); + } + + /** An empty-handed, standing right-click; mounts are petted by riding them (HusbandryMountListener). */ + private static void pet(Player player, LivingEntity living, HusbandryAnimal animal, HusbandryRepository repository) { + if (player.isSneaking() || HusbandryMounts.isMount(living) + || !HusbandryOwnershipService.isOwner(player, living.getUniqueId())) { + return; + } + long now = System.currentTimeMillis(); + boolean counted = HusbandryTrust.canPet(animal, now); + int gained = HusbandryTrust.reward(animal, HusbandryTrust.Gesture.PET, now); + if (counted) { + repository.upsertAnimal(animal); + } + HusbandryTrust.show(player, living, animal, gained); + } +} diff --git a/src/main/java/net/tfminecraft/breedingbuddies/husbandry/HusbandryClaimHint.java b/src/main/java/net/tfminecraft/breedingbuddies/husbandry/HusbandryClaimHint.java new file mode 100644 index 0000000..42c10f7 --- /dev/null +++ b/src/main/java/net/tfminecraft/breedingbuddies/husbandry/HusbandryClaimHint.java @@ -0,0 +1,112 @@ +package net.tfminecraft.breedingbuddies.husbandry; + +import java.util.List; +import java.util.Locale; +import java.util.Map; +import java.util.UUID; +import java.util.concurrent.ConcurrentHashMap; + +import org.bukkit.entity.Entity; +import org.bukkit.entity.Player; +import org.bukkit.entity.Tameable; + +/** Tells players who tame or ride an unowned animal how to claim it, and their animal limit. */ +public final class HusbandryClaimHint { + + static final long COOLDOWN_MILLIS = 60_000L; + + private static final Map LAST_SENT = new ConcurrentHashMap<>(); + + private HusbandryClaimHint() {} + + /** True when the chunk-load cleanup would remove this animal, using the same rule. */ + public static boolean needsClaim(Entity entity) { + if (entity == null || !HusbandryConfig.isRemoveUnowned(entity.getType())) { + return false; + } + UUID uuid = entity.getUniqueId(); + HusbandryRepository repository = HusbandryEntities.repository(); + boolean hasRow = HusbandryEntities.getLoaded(uuid).isPresent() + || (repository != null && repository.exists(uuid)); + return HusbandryMounts.shouldWipeUnowned( + true, + HusbandryOwnershipService.hasAnyOwner(uuid), + hasRow, + HusbandryMounts.hasConfiguredStats(entity)); + } + + /** Any listed animal without an owner, including ridden mounts the cleanup keeps as wild. */ + public static boolean isUnclaimed(Entity entity) { + return entity != null + && HusbandryConfig.isRemoveUnowned(entity.getType()) + && !HusbandryOwnershipService.hasAnyOwner(entity.getUniqueId()); + } + + /** Sends the hint unless this player saw one within the cooldown. */ + public static void remind(Player player, Entity entity) { + long now = System.currentTimeMillis(); + Long last = LAST_SENT.get(player.getUniqueId()); + if (last != null && now - last < COOLDOWN_MILLIS) { + return; + } + send(player, entity, entity instanceof Tameable tameable && !tameable.isTamed()); + } + + /** + * {@code untamed} comes from the caller: during {@code EntityTameEvent} the animal is not tamed yet, + * so reading it from the entity would tell a player who just tamed it to tame it first. + */ + public static void send(Player player, Entity entity, boolean untamed) { + if (player == null || !isUnclaimed(entity)) { + return; + } + // Riding enrolls a mount, and the cleanup keeps enrolled mounts as unowned wild animals. + boolean staysWild = !needsClaim(entity); + LAST_SENT.put(player.getUniqueId(), System.currentTimeMillis()); + boolean claimable = HusbandryConfig.species(entity.getType()) != null; + boolean staff = HusbandryOwnershipService.isStaff(player); + HusbandryRepository repository = HusbandryEntities.repository(); + int owned = repository == null ? 0 : repository.countForPlayer(player.getUniqueId()); + for (String line : lines(HusbandryEntities.displayName(entity.getType()), + claimable, untamed, staysWild, owned, HusbandryConfig.maxAnimals(), staff)) { + player.sendMessage(line); + } + } + + public static void forget(UUID playerUuid) { + LAST_SENT.remove(playerUuid); + } + + static List lines( + String species, + boolean claimable, + boolean untamed, + boolean staysWild, + int owned, + int cap, + boolean staff) { + String label = species == null || species.isBlank() ? "animal" : species.toLowerCase(Locale.ROOT); + String warning = staysWild + ? "§eThis " + label + " is not claimed. Anyone can ride it or claim it until someone does." + : "§eThis " + label + " is not claimed. Unclaimed animals wander off before" + + " anyone next comes by."; + if (!claimable) { + return List.of(warning, "§7This kind of animal cannot be claimed, so it will not stay."); + } + String claim = (untamed ? "Tame it, then right-click" : "Right-click") + + " it with an Ownership Token renamed on an anvil"; + if (staff) { + return List.of(warning, + "§7" + claim + ". §8(staff: no animal limit)"); + } + if (owned >= cap) { + return List.of(warning, + "§cYou already own " + owned + "/" + cap + " animals, so you cannot claim it.", + "§7Release one first: shift-right-click it with an empty hand, then click Remove ownership." + + " See §f/animals§7."); + } + return List.of(warning, + "§7" + claim + " to keep it.", + "§7You own §f" + owned + "/" + cap + "§7 animals. See §f/animals§7."); + } +} diff --git a/src/main/java/net/tfminecraft/breedingbuddies/husbandry/HusbandryCoOwnToken.java b/src/main/java/net/tfminecraft/breedingbuddies/husbandry/HusbandryCoOwnToken.java new file mode 100644 index 0000000..8a5fe3f --- /dev/null +++ b/src/main/java/net/tfminecraft/breedingbuddies/husbandry/HusbandryCoOwnToken.java @@ -0,0 +1,29 @@ +package net.tfminecraft.breedingbuddies.husbandry; + +import java.util.UUID; + +/** Decides when a right-click with a linked Co-Ownership Token accepts it. */ +final class HusbandryCoOwnToken { + + private HusbandryCoOwnToken() {} + + /** + * A token linked to someone else's animal is accepted by any right-click: the air, a block, an + * animal or a player. Clients follow an entity click with a use-item packet, so every path must + * agree or the player sees an error and then a success. Owners keep linking and giving. + */ + static boolean accepts(UUID linkedAnimal, boolean linkedHasOwners, boolean clickerOwnsLinked) { + return linkedAnimal != null && linkedHasOwners && !clickerOwnsLinked; + } + + static UUID parse(String linked) { + if (linked == null || linked.isBlank()) { + return null; + } + try { + return UUID.fromString(linked); + } catch (IllegalArgumentException ignored) { + return null; + } + } +} diff --git a/src/main/java/net/tfminecraft/breedingbuddies/husbandry/HusbandryConfig.java b/src/main/java/net/tfminecraft/breedingbuddies/husbandry/HusbandryConfig.java new file mode 100644 index 0000000..6bec7e9 --- /dev/null +++ b/src/main/java/net/tfminecraft/breedingbuddies/husbandry/HusbandryConfig.java @@ -0,0 +1,482 @@ +package net.tfminecraft.breedingbuddies.husbandry; + +import java.util.List; +import java.util.Map; +import java.util.Set; + +import org.bukkit.entity.EntityType; + +public final class HusbandryConfig { + + private static int maxAnimals = 15; + private static int careMax = 200; + private static int careDownIntervalSeconds = 3600; + private static double careDownAmount = 1; + private static int decayGraceSeconds = 86400; + private static int longUnloadForceSeconds = 28800; + private static int minLoadedSeconds = 60; + private static double afflictionMeanHours = 6; + private static double afflictionMinHours = 4; + private static double afflictionMaxHours = 8; + private static int milkCooldownSeconds = 1200; + private static int initialGeneticMax = 20; + private static int maxGenetics = 1000; + private static int minRoastCuts = 1; + private static int woolTimerSeconds = 1200; + private static int growUpSeconds = 3600; + private static int shedTimerSeconds = 28800; + private static double shedChance = 0.15; + private static int eggTimerSeconds = 3600; + private static String tameItem = ""; + private static String coOwnItem = ""; + private static String feedItem = ""; + private static String gloveItem = ""; + private static String neuterItem = ""; + private static String inspectItem = ""; + private static String mountStatsItem = ""; + private static Set removeUnowned = Set.of(); + private static Map species = Map.of(); + private static boolean damageOtherPlayers = true; + private static boolean damageOwner = true; + private static boolean damageMobs = true; + private static boolean damageEnvironment = true; + private static List qualityBands = List.of(); + private static Map mounts = Map.of(); + private static double geneticVarianceMultiplier = 1; + private static double geneticSlowdownDivisor = 1; + private static double careInfluence = 0.02; + private static String statsRevision = "1"; + private static boolean restoreLostAnimals = true; + private static List amountBands = List.of(); + private static double mountMinPct = 0.40; + private static double mountGeneticsPct = 0.30; + private static double mountCarePct = 0.30; + private static int trustFeed = 10; + private static int trustGlove = 10; + private static int trustPet = 5; + private static int petCooldownSeconds = 72000; + private static String professionId = "farming"; + private static HusbandryExpBracket defaultExp = HusbandryExpBracket.of(6, 8); + + private HusbandryConfig() {} + + public static void apply( + int maxAnimalsValue, + int careMaxValue, + int careDownIntervalSecondsValue, + double careDownAmountValue, + int decayGraceSecondsValue, + int longUnloadForceSecondsValue, + int minLoadedSecondsValue, + double afflictionMeanHoursValue, + double afflictionMinHoursValue, + double afflictionMaxHoursValue, + int milkCooldownSecondsValue, + int initialGeneticMaxValue, + int maxGeneticsValue, + int minRoastCutsValue, + int woolTimerSecondsValue, + int growUpSecondsValue, + int shedTimerSecondsValue, + double shedChanceValue, + int eggTimerSecondsValue, + String tameItemValue, + String coOwnItemValue, + String feedItemValue, + String gloveItemValue, + String neuterItemValue, + String inspectItemValue, + String mountStatsItemValue, + Set removeUnownedValue, + Map speciesValue, + boolean damageOtherPlayersValue, + boolean damageOwnerValue, + boolean damageMobsValue, + boolean damageEnvironmentValue, + List qualityBandsValue, + Map mountsValue, + double geneticVarianceMultiplierValue, + double geneticSlowdownDivisorValue, + List amountBandsValue) { + maxAnimals = Math.max(1, maxAnimalsValue); + careMax = Math.max(1, careMaxValue); + careDownIntervalSeconds = Math.max(1, careDownIntervalSecondsValue); + careDownAmount = careDownAmountValue; + decayGraceSeconds = Math.max(0, decayGraceSecondsValue); + longUnloadForceSeconds = Math.max(0, longUnloadForceSecondsValue); + minLoadedSeconds = Math.max(0, minLoadedSecondsValue); + afflictionMeanHours = afflictionMeanHoursValue; + afflictionMinHours = afflictionMinHoursValue; + afflictionMaxHours = Math.max(afflictionMinHoursValue, afflictionMaxHoursValue); + milkCooldownSeconds = Math.max(0, milkCooldownSecondsValue); + initialGeneticMax = Math.max(0, initialGeneticMaxValue); + maxGenetics = Math.max(1, maxGeneticsValue); + minRoastCuts = Math.max(0, minRoastCutsValue); + woolTimerSeconds = Math.max(0, woolTimerSecondsValue); + growUpSeconds = Math.max(0, growUpSecondsValue); + shedTimerSeconds = Math.max(0, shedTimerSecondsValue); + shedChance = Math.max(0, Math.min(1, shedChanceValue)); + eggTimerSeconds = Math.max(0, eggTimerSecondsValue); + tameItem = tameItemValue == null ? "" : tameItemValue; + coOwnItem = coOwnItemValue == null ? "" : coOwnItemValue; + feedItem = feedItemValue == null ? "" : feedItemValue; + gloveItem = gloveItemValue == null ? "" : gloveItemValue; + neuterItem = neuterItemValue == null ? "" : neuterItemValue; + inspectItem = inspectItemValue == null ? "" : inspectItemValue; + mountStatsItem = mountStatsItemValue == null ? "" : mountStatsItemValue; + removeUnowned = Set.copyOf(removeUnownedValue); + species = Map.copyOf(speciesValue); + damageOtherPlayers = damageOtherPlayersValue; + damageOwner = damageOwnerValue; + damageMobs = damageMobsValue; + damageEnvironment = damageEnvironmentValue; + qualityBands = List.copyOf(qualityBandsValue); + mounts = Map.copyOf(mountsValue); + geneticVarianceMultiplier = geneticVarianceMultiplierValue; + geneticSlowdownDivisor = geneticSlowdownDivisorValue; + amountBands = List.copyOf(amountBandsValue); + } + + public static void setMountShares(double minPct, double geneticsPct, double carePct) { + mountMinPct = Math.max(0, minPct); + mountGeneticsPct = Math.max(0, geneticsPct); + mountCarePct = Math.max(0, carePct); + } + + public static void setBreeding(double multiplier, double slowdown, double careInfluenceValue) { + geneticVarianceMultiplier = Math.max(0, multiplier); + geneticSlowdownDivisor = slowdown; + careInfluence = Math.max(0, careInfluenceValue); + } + + public static void setStatsRevision(String revision) { + statsRevision = revision == null ? "" : revision.trim(); + } + + public static void setTrust(int feed, int glove, int pet, int petCooldown) { + trustFeed = Math.max(0, feed); + trustGlove = Math.max(0, glove); + trustPet = Math.max(0, pet); + petCooldownSeconds = Math.max(0, petCooldown); + } + + public static int trustFor(HusbandryTrust.Gesture gesture) { + return switch (gesture) { + case FEED -> trustFeed; + case GLOVE -> trustGlove; + case PET -> trustPet; + }; + } + + public static int petCooldownSeconds() { + return petCooldownSeconds; + } + + public static void setRestoreLostAnimals(boolean enabled) { + restoreLostAnimals = enabled; + } + + public static boolean restoreLostAnimals() { + return restoreLostAnimals; + } + + public static void setProfessionExp(String profession, HusbandryExpBracket bracket) { + professionId = profession == null ? "" : profession.trim(); + defaultExp = bracket == null ? HusbandryExpBracket.of(6, 8) : bracket; + } + + public static String professionId() { + return professionId; + } + + public static HusbandryExpBracket expFor(EntityType type) { + if (type == null) { + return defaultExp; + } + HusbandrySpecies configured = species(type); + if (configured != null && configured.exp() != null) { + return configured.exp(); + } + return defaultExp; + } + + public static String statsRevision() { + return statsRevision; + } + + public static double mountMinPct() { + return mountMinPct; + } + + public static double mountGeneticsPct() { + return mountGeneticsPct; + } + + public static double mountCarePct() { + return mountCarePct; + } + + public static int maxAnimals() { + return maxAnimals; + } + + public static int careMax() { + return careMax; + } + + public static int careDownIntervalSeconds() { + return careDownIntervalSeconds; + } + + public static double careDownAmount() { + return careDownAmount; + } + + public static int decayGraceSeconds() { + return decayGraceSeconds; + } + + public static int longUnloadForceSeconds() { + return longUnloadForceSeconds; + } + + public static int minLoadedSeconds() { + return minLoadedSeconds; + } + + public static double afflictionMeanHours() { + return afflictionMeanHours; + } + + public static double afflictionMinHours() { + return afflictionMinHours; + } + + public static double afflictionMaxHours() { + return afflictionMaxHours; + } + + public static int milkCooldownSeconds() { + return milkCooldownSeconds; + } + + public static int milkTimerSeconds() { + return milkCooldownSeconds; + } + + public static int milkTimerSeconds(EntityType type) { + if (type != null) { + HusbandrySpecies configured = species.get(type); + if (configured != null && configured.milkTimerSeconds() > 0) { + return configured.milkTimerSeconds(); + } + } + return milkCooldownSeconds; + } + + public static int resolveMilkTimerSeconds(int speciesOverrideSeconds, int globalSeconds) { + return speciesOverrideSeconds > 0 ? speciesOverrideSeconds : Math.max(0, globalSeconds); + } + + public static int initialGeneticMax() { + return initialGeneticMax; + } + + public static int maxGenetics() { + return maxGenetics; + } + + public static int minRoastCuts() { + return minRoastCuts; + } + + public static int woolTimerSeconds() { + return woolTimerSeconds; + } + + public static int woolTimerSeconds(EntityType type) { + if (type != null) { + HusbandrySpecies configured = species.get(type); + if (configured != null && configured.woolTimerSeconds() > 0) { + return configured.woolTimerSeconds(); + } + } + return woolTimerSeconds; + } + + public static int resolveWoolTimerSeconds(int speciesOverrideSeconds, int globalSeconds) { + return speciesOverrideSeconds > 0 ? speciesOverrideSeconds : Math.max(0, globalSeconds); + } + + public static int growUpSeconds() { + return growUpSeconds; + } + + public static int growUpSeconds(EntityType type) { + if (type != null) { + HusbandrySpecies configured = species.get(type); + if (configured != null && configured.growUpSeconds() > 0) { + return configured.growUpSeconds(); + } + } + return growUpSeconds; + } + + public static int shedTimerSeconds() { + return shedTimerSeconds; + } + + public static double shedChance() { + return shedChance; + } + + public static int eggTimerSeconds() { + return eggTimerSeconds; + } + + public static String tameItem() { + return tameItem; + } + + public static String coOwnItem() { + return coOwnItem; + } + + public static String feedItem() { + return feedItem; + } + + public static String gloveItem() { + return gloveItem; + } + + public static String neuterItem() { + return neuterItem; + } + + public static String inspectItem() { + return inspectItem; + } + + public static String mountStatsItem() { + return mountStatsItem; + } + + public static Set removeUnowned() { + return removeUnowned; + } + + public static boolean isRemoveUnowned(EntityType type) { + return type != null && removeUnowned.contains(type); + } + + public static Map species() { + return species; + } + + public static HusbandrySpecies species(EntityType type) { + return species.get(type); + } + + public static boolean damageOtherPlayers() { + return damageOtherPlayers; + } + + public static boolean damageOwner() { + return damageOwner; + } + + public static boolean damageMobs() { + return damageMobs; + } + + public static boolean damageEnvironment() { + return damageEnvironment; + } + + public static List qualityBands() { + return qualityBands; + } + + public static int starsForGenetics(int genetics) { + return HusbandryQualityRange.starsForGenetics(genetics, qualityBands); + } + + public static HusbandryQualityRange.Bounds qualityRange(HusbandryAnimal animal) { + if (animal == null) { + return new HusbandryQualityRange.Bounds(1, 1); + } + return HusbandryQualityRange.of(animal.genetics(), animal.care(), careMax, qualityBands); + } + + public static Map mounts() { + return mounts; + } + + public static HusbandryMountStats mountStats(EntityType type) { + return mounts.get(type); + } + + public static double geneticVarianceMultiplier() { + return geneticVarianceMultiplier; + } + + public static double geneticSlowdownDivisor() { + return geneticSlowdownDivisor; + } + + public static double careInfluence() { + return careInfluence; + } + + public static boolean isHusbandryType(EntityType type) { + return type != null && (species.containsKey(type) || removeUnowned.contains(type)); + } + + public static int effectiveGenetics(HusbandryAnimal animal) { + if (animal == null) { + return 0; + } + return (int) Math.floor(animal.genetics() * (animal.care() / (double) careMax)); + } + + public static int roastCutsFor(int effectiveGenetics) { + int cuts = minRoastCuts; + for (HusbandryAmountBand band : amountBands) { + if (effectiveGenetics >= band.minGenetics()) { + cuts = band.roastCuts(); + } + } + return Math.max(minRoastCuts, cuts); + } + + public static int secondaryExtraFor(int effectiveGenetics) { + return secondaryExtraFor(effectiveGenetics, amountBands); + } + + public static int secondaryExtraFor(int effectiveGenetics, List bands) { + int extra = 0; + if (bands != null) { + for (HusbandryAmountBand band : bands) { + if (effectiveGenetics >= band.minGenetics()) { + extra = band.secondaryExtra(); + } + } + } + return Math.max(0, Math.min(2, extra)); + } + + public static int hideCount(int effectiveGenetics) { + return 1 + secondaryExtraFor(effectiveGenetics); + } + + public static int woolFor(int effectiveGenetics) { + int wool = 1; + for (HusbandryAmountBand band : amountBands) { + if (effectiveGenetics >= band.minGenetics()) { + wool = band.wool(); + } + } + return Math.max(1, wool); + } +} diff --git a/src/main/java/net/tfminecraft/breedingbuddies/husbandry/HusbandryDamageListener.java b/src/main/java/net/tfminecraft/breedingbuddies/husbandry/HusbandryDamageListener.java new file mode 100644 index 0000000..f8861b2 --- /dev/null +++ b/src/main/java/net/tfminecraft/breedingbuddies/husbandry/HusbandryDamageListener.java @@ -0,0 +1,68 @@ +package net.tfminecraft.breedingbuddies.husbandry; + +import org.bukkit.entity.Entity; +import org.bukkit.entity.Mob; +import org.bukkit.entity.Player; +import org.bukkit.entity.Projectile; +import org.bukkit.event.EventHandler; +import org.bukkit.event.Listener; +import org.bukkit.event.entity.EntityDamageByEntityEvent; +import org.bukkit.event.entity.EntityDamageEvent; +import org.bukkit.projectiles.ProjectileSource; + +public final class HusbandryDamageListener implements Listener { + + @EventHandler(ignoreCancelled = true) + public void onDamage(EntityDamageEvent event) { + Entity entity = event.getEntity(); + HusbandryRepository repository = HusbandryEntities.repository(); + if (repository == null || HusbandryEntities.lookup(entity.getUniqueId()).isEmpty()) { + return; + } + if (event instanceof EntityDamageByEntityEvent byEntity) { + if (cancelAttack(entity, attacker(byEntity))) { + event.setCancelled(true); + } + return; + } + if (!HusbandryConfig.damageEnvironment() && isEnvironment(event.getCause())) { + event.setCancelled(true); + } + } + + private static boolean cancelAttack(Entity victim, Entity attacker) { + if (attacker instanceof Player player) { + boolean owner = HusbandryOwnershipService.isOwner(player, victim.getUniqueId()); + if (owner) { + return !HusbandryConfig.damageOwner(); + } + if (HusbandryOwnershipService.isStaff(player)) { + return false; + } + return !HusbandryConfig.damageOtherPlayers(); + } + if (attacker instanceof Mob) { + return !HusbandryConfig.damageMobs(); + } + return false; + } + + private static Entity attacker(EntityDamageByEntityEvent event) { + Entity damager = event.getDamager(); + if (damager instanceof Projectile projectile) { + ProjectileSource source = projectile.getShooter(); + if (source instanceof Entity shooter) { + return shooter; + } + } + return damager; + } + + private static boolean isEnvironment(EntityDamageEvent.DamageCause cause) { + return switch (cause) { + case FALL, FIRE, FIRE_TICK, LAVA, DROWNING, CONTACT, CRAMMING, + SUFFOCATION, FREEZE, LIGHTNING, HOT_FLOOR -> true; + default -> false; + }; + } +} diff --git a/src/main/java/net/tfminecraft/breedingbuddies/husbandry/HusbandryDeathListener.java b/src/main/java/net/tfminecraft/breedingbuddies/husbandry/HusbandryDeathListener.java new file mode 100644 index 0000000..c8ed4f9 --- /dev/null +++ b/src/main/java/net/tfminecraft/breedingbuddies/husbandry/HusbandryDeathListener.java @@ -0,0 +1,62 @@ +package net.tfminecraft.breedingbuddies.husbandry; + +import java.util.Optional; +import java.util.concurrent.ThreadLocalRandom; + +import org.bukkit.Bukkit; +import org.bukkit.entity.LivingEntity; +import org.bukkit.event.EventHandler; +import org.bukkit.event.Listener; +import org.bukkit.event.entity.EntityDeathEvent; +import org.bukkit.inventory.ItemStack; + +public final class HusbandryDeathListener implements Listener { + + @EventHandler + public void onDeath(EntityDeathEvent event) { + LivingEntity entity = event.getEntity(); + // CompanionPets decides what its pets drop; they are not unowned livestock. + if (HusbandryEntities.isCompanionPet(entity)) { + return; + } + HusbandryRepository repository = HusbandryEntities.repository(); + if (repository == null) { + return; + } + + if (HusbandryConfig.isRemoveUnowned(entity.getType()) + && !HusbandryOwnershipService.hasAnyOwner(entity.getUniqueId())) { + event.getDrops().clear(); + event.setDroppedExp(0); + repository.deleteAnimal(entity.getUniqueId()); + HusbandryEntities.evict(entity.getUniqueId()); + HusbandryStateDisplay.removeAll(entity); + return; + } + + Optional stored = HusbandryEntities.lookup(entity.getUniqueId()); + if (stored.isEmpty()) { + return; + } + HusbandryAnimal animal = stored.get(); + HusbandrySpecies species = HusbandryConfig.species(entity.getType()); + long now = System.currentTimeMillis(); + boolean hasSlaughter = species != null && species.canSlaughter(); + boolean mature = HusbandryGrowth.isMature(animal, now); + if (HusbandrySlaughterDrops.shouldReplaceVanilla(hasSlaughter, mature)) { + ItemStack roast = HusbandryHarvest.buildFood(animal, species.slaughterMeat()); + if (roast == null || roast.getType().isAir()) { + Bukkit.getLogger().warning("[BreedingBuddies] 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)); + } + } + repository.deleteAnimal(entity.getUniqueId()); + HusbandryEntities.evict(entity.getUniqueId()); + HusbandryStateDisplay.removeAll(entity); + } +} diff --git a/src/main/java/net/tfminecraft/breedingbuddies/husbandry/HusbandryDropEntry.java b/src/main/java/net/tfminecraft/breedingbuddies/husbandry/HusbandryDropEntry.java new file mode 100644 index 0000000..e4f8dcc --- /dev/null +++ b/src/main/java/net/tfminecraft/breedingbuddies/husbandry/HusbandryDropEntry.java @@ -0,0 +1,26 @@ +package net.tfminecraft.breedingbuddies.husbandry; + +public final class HusbandryDropEntry { + + private final String path; + private final int amount; + private final int weight; + + public HusbandryDropEntry(String path, int amount, int weight) { + this.path = path == null ? "" : path; + this.amount = Math.max(1, amount); + this.weight = Math.max(1, weight); + } + + public String path() { + return path; + } + + public int amount() { + return amount; + } + + public int weight() { + return weight; + } +} diff --git a/src/main/java/net/tfminecraft/breedingbuddies/husbandry/HusbandryDropRoller.java b/src/main/java/net/tfminecraft/breedingbuddies/husbandry/HusbandryDropRoller.java new file mode 100644 index 0000000..75f61bd --- /dev/null +++ b/src/main/java/net/tfminecraft/breedingbuddies/husbandry/HusbandryDropRoller.java @@ -0,0 +1,171 @@ +package net.tfminecraft.breedingbuddies.husbandry; + +import java.util.ArrayList; +import java.util.List; +import java.util.Optional; +import java.util.Random; + +import org.bukkit.inventory.ItemStack; + +public final class HusbandryDropRoller { + + public enum CountMode { + HIDE_COUNT, + WOOL_COUNT, + SINGLE + } + + private HusbandryDropRoller() {} + + public static List rollSlaughterExtras(HusbandryAnimal animal, Random random, long nowMillis) { + if (animal == null) { + return List.of(); + } + HusbandrySpecies species = speciesOf(animal); + if (species == null) { + return List.of(); + } + return roll(species.slaughterDrops(), animal, random, nowMillis, CountMode.HIDE_COUNT); + } + + public static List rollShearDrops(HusbandryAnimal animal, Random random, long nowMillis) { + if (animal == null) { + return List.of(); + } + HusbandrySpecies species = speciesOf(animal); + if (species == null) { + return List.of(); + } + return roll(species.shearDrops(), animal, random, nowMillis, CountMode.WOOL_COUNT); + } + + public static List rollShedDrops(HusbandryAnimal animal, Random random) { + if (animal == null) { + return List.of(); + } + HusbandrySpecies species = speciesOf(animal); + if (species == null) { + return List.of(); + } + CountMode countMode = species.shedDrops().counted() ? CountMode.WOOL_COUNT : CountMode.SINGLE; + return roll(species.shedDrops(), animal, random, System.currentTimeMillis(), countMode); + } + + public static List roll( + HusbandryDropTable table, + HusbandryAnimal animal, + Random random, + long nowMillis, + CountMode countMode) { + if (animal == null || random == null || table == null || table.isEmpty()) { + return List.of(); + } + if (table.counted()) { + int count = resolveCount(animal, nowMillis, countMode); + if (count <= 0) { + return List.of(); + } + List pool = unlockedPool(table, HusbandryConfig.starsForGenetics(animal.genetics())); + List drops = new ArrayList<>(); + for (int i = 0; i < count; i++) { + HusbandryDropEntry picked = pickEntry(pool, random); + if (picked == null || picked.path().isBlank()) { + continue; + } + ItemStack stack = HusbandryHarvest.buildTlibs(picked.path(), Math.max(1, picked.amount())); + if (stack != null) { + drops.add(stack); + } + } + return drops; + } + return rollExtras(table, animal, random).map(List::of).orElse(List.of()); + } + + public static Optional rollExtras( + HusbandryDropTable table, + HusbandryAnimal animal, + Random random) { + if (animal == null || random == null || table == null || table.isEmpty()) { + return Optional.empty(); + } + List pool = unlockedPool(table, HusbandryConfig.starsForGenetics(animal.genetics())); + HusbandryDropEntry picked = pickEntry(pool, random); + if (picked == null || picked.path().isBlank()) { + return Optional.empty(); + } + return Optional.ofNullable(HusbandryHarvest.buildTlibs(picked.path(), picked.amount())); + } + + private static int resolveCount(HusbandryAnimal animal, long nowMillis, CountMode countMode) { + return switch (countMode) { + case HIDE_COUNT -> hideCount(animal, nowMillis); + case WOOL_COUNT -> HusbandryConfig.woolFor(HusbandryConfig.effectiveGenetics(animal)); + case SINGLE -> 1; + }; + } + + public static int hideCount(HusbandryAnimal animal, long nowMillis) { + if (animal == null) { + return 0; + } + if (woolBlocked(animal.type(), animal, nowMillis)) { + return 0; + } + return HusbandryConfig.hideCount(HusbandryConfig.effectiveGenetics(animal)); + } + + public static boolean woolBlocked(String typeName, HusbandryAnimal animal, long nowMillis) { + if (typeName == null) { + return false; + } + if (!typeName.equalsIgnoreCase("SHEEP") && !typeName.equalsIgnoreCase("GOAT")) { + return false; + } + Long readyAt = animal == null ? null : animal.woolReadyAt(); + return readyAt != null && readyAt > nowMillis; + } + + public static List unlockedPool(HusbandryDropTable table, int stars) { + List pool = new ArrayList<>(); + if (table == null) { + return pool; + } + pool.addAll(table.common()); + if (stars >= 3) { + pool.addAll(table.rare()); + } + if (stars >= 4) { + pool.addAll(table.epic()); + } + if (stars >= 5) { + pool.addAll(table.legendary()); + } + return pool; + } + + public static HusbandryDropEntry pickEntry(List pool, Random random) { + if (pool == null || pool.isEmpty() || random == null) { + return null; + } + 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 pool.getLast(); + } + + private static HusbandrySpecies speciesOf(HusbandryAnimal animal) { + try { + return HusbandryConfig.species(org.bukkit.entity.EntityType.valueOf(animal.type())); + } catch (IllegalArgumentException ex) { + return null; + } + } +} diff --git a/src/main/java/net/tfminecraft/breedingbuddies/husbandry/HusbandryDropTable.java b/src/main/java/net/tfminecraft/breedingbuddies/husbandry/HusbandryDropTable.java new file mode 100644 index 0000000..3330b33 --- /dev/null +++ b/src/main/java/net/tfminecraft/breedingbuddies/husbandry/HusbandryDropTable.java @@ -0,0 +1,61 @@ +package net.tfminecraft.breedingbuddies.husbandry; + +import java.util.List; + +public final class HusbandryDropTable { + + private final List common; + private final List rare; + private final List epic; + private final List legendary; + private final boolean counted; + + public HusbandryDropTable( + List common, + List rare, + List epic, + List legendary) { + this(common, rare, epic, legendary, false); + } + + public HusbandryDropTable( + List common, + List rare, + List epic, + List legendary, + boolean counted) { + this.common = List.copyOf(common == null ? List.of() : common); + this.rare = List.copyOf(rare == null ? List.of() : rare); + this.epic = List.copyOf(epic == null ? List.of() : epic); + this.legendary = List.copyOf(legendary == null ? List.of() : legendary); + this.counted = counted; + } + + public static HusbandryDropTable empty() { + return new HusbandryDropTable(List.of(), List.of(), List.of(), List.of(), false); + } + + public boolean counted() { + return counted; + } + + public List common() { + return common; + } + + public List rare() { + return rare; + } + + public List epic() { + return epic; + } + + public List legendary() { + return legendary; + } + + public boolean isEmpty() { + return common.isEmpty() && rare.isEmpty() && epic.isEmpty() && legendary.isEmpty(); + } +} diff --git a/src/main/java/net/tfminecraft/breedingbuddies/husbandry/HusbandryDuration.java b/src/main/java/net/tfminecraft/breedingbuddies/husbandry/HusbandryDuration.java new file mode 100644 index 0000000..ce495af --- /dev/null +++ b/src/main/java/net/tfminecraft/breedingbuddies/husbandry/HusbandryDuration.java @@ -0,0 +1,88 @@ +package net.tfminecraft.breedingbuddies.husbandry; + +import org.bukkit.Bukkit; +import org.bukkit.configuration.ConfigurationSection; +import org.bukkit.configuration.file.FileConfiguration; + +import net.tfminecraft.tlibs.utils.TimeFormatter; + +final class HusbandryDuration { + + private static final java.util.Set LEGACY_WARNED = java.util.concurrent.ConcurrentHashMap.newKeySet(); + + private HusbandryDuration() {} + + static int parseSeconds(FileConfiguration config, String key, String legacyKey, int legacyMultiplier, int fallback) { + if (config.contains(key)) { + return TimeFormatter.parseSeconds(config.getString(key)); + } + if (legacyKey != null && config.contains(legacyKey)) { + warnLegacy(legacyKey, key); + return (int) Math.round(config.getDouble(legacyKey) * legacyMultiplier); + } + return fallback; + } + + static double parseHours(FileConfiguration config, String key, String legacyKey, double fallback) { + if (config.contains(key)) { + return TimeFormatter.parseSeconds(config.getString(key)) / 3600.0; + } + if (legacyKey != null && config.contains(legacyKey)) { + warnLegacy(legacyKey, key); + return config.getDouble(legacyKey); + } + return fallback; + } + + static double parseHours(ConfigurationSection section, String key, String legacyKey, double fallback) { + if (section != null && section.contains(key)) { + return TimeFormatter.parseSeconds(section.getString(key)) / 3600.0; + } + if (section != null && legacyKey != null && section.contains(legacyKey)) { + warnLegacy(legacyKey, key); + return section.getDouble(legacyKey); + } + return fallback; + } + + static double parseAfflictionHours(FileConfiguration config, String subKey, String legacyKey, double fallback) { + ConfigurationSection section = config.getConfigurationSection("affliction"); + if (section != null && section.contains(subKey)) { + return TimeFormatter.parseSeconds(section.getString(subKey)) / 3600.0; + } + if (legacyKey != null && config.contains(legacyKey)) { + warnLegacy(legacyKey, "affliction." + subKey); + return config.getDouble(legacyKey); + } + return fallback; + } + + static int parseCareIntervalSeconds(FileConfiguration config, String path, String legacyKey, int fallback) { + if (config.contains(path + ".interval")) { + return Math.max(1, TimeFormatter.parseSeconds(config.getString(path + ".interval"))); + } + if (legacyKey != null && config.contains(legacyKey)) { + warnLegacy(legacyKey, path + ".interval"); + return 3600; + } + return fallback; + } + + static double parseCareAmount(FileConfiguration config, String path, String legacyKey, double fallback) { + if (config.contains(path + ".amount")) { + return config.getDouble(path + ".amount"); + } + if (legacyKey != null && config.contains(legacyKey)) { + warnLegacy(legacyKey, path + ".amount"); + return config.getDouble(legacyKey); + } + return fallback; + } + + private static void warnLegacy(String legacyKey, String newKey) { + if (LEGACY_WARNED.add(legacyKey)) { + Bukkit.getLogger().warning("[BreedingBuddies] config.yml uses deprecated key '" + legacyKey + + "'; prefer '" + newKey + "' with TLibs duration strings (e.g. 8h, 20m)."); + } + } +} diff --git a/src/main/java/net/tfminecraft/breedingbuddies/husbandry/HusbandryEggs.java b/src/main/java/net/tfminecraft/breedingbuddies/husbandry/HusbandryEggs.java new file mode 100644 index 0000000..9016ef3 --- /dev/null +++ b/src/main/java/net/tfminecraft/breedingbuddies/husbandry/HusbandryEggs.java @@ -0,0 +1,54 @@ +package net.tfminecraft.breedingbuddies.husbandry; + +import org.bukkit.Material; +import org.bukkit.entity.LivingEntity; +import org.bukkit.inventory.ItemStack; + +final class HusbandryEggs { + + private HusbandryEggs() {} + + // Cold and warm chicken variants lay blue and brown eggs. + static boolean isVanillaEgg(Material material) { + return material == Material.EGG || material == Material.BLUE_EGG || material == Material.BROWN_EGG; + } + + static void tryLay(LivingEntity entity, HusbandryAnimal animal, long nowMillis) { + if (entity == null || animal == null) { + return; + } + if (!HusbandryGrowth.isMature(animal, nowMillis) || !HusbandrySimulator.isHappy(animal)) { + return; + } + org.bukkit.entity.EntityType type; + try { + type = org.bukkit.entity.EntityType.valueOf(animal.type()); + } catch (IllegalArgumentException ex) { + return; + } + HusbandrySpecies species = HusbandryConfig.species(type); + if (species == null || !species.hasEgg()) { + return; + } + Long readyAt = animal.eggReadyAt(); + if (readyAt != null && readyAt > nowMillis) { + return; + } + ItemStack stack = buildEgg(animal, species); + if (stack != null) { + entity.getWorld().dropItemNaturally(entity.getLocation(), stack); + } + animal.setEggReadyAt(nowMillis + HusbandryConfig.eggTimerSeconds() * 1000L); + } + + private static ItemStack buildEgg(HusbandryAnimal animal, HusbandrySpecies species) { + String egg = species.egg(); + if (species.vanillaEggs()) { + return new ItemStack(Material.EGG); + } + if (egg.startsWith("food(")) { + return HusbandryHarvest.buildFood(animal, egg); + } + return HusbandryHarvest.buildTlibs(egg, 1); + } +} diff --git a/src/main/java/net/tfminecraft/breedingbuddies/husbandry/HusbandryEntities.java b/src/main/java/net/tfminecraft/breedingbuddies/husbandry/HusbandryEntities.java new file mode 100644 index 0000000..d1bfb81 --- /dev/null +++ b/src/main/java/net/tfminecraft/breedingbuddies/husbandry/HusbandryEntities.java @@ -0,0 +1,129 @@ +package net.tfminecraft.breedingbuddies.husbandry; + +import java.util.ArrayList; +import java.util.Collections; +import java.util.List; +import java.util.Locale; +import java.util.Map; +import java.util.Optional; +import java.util.Set; +import java.util.UUID; +import java.util.concurrent.ConcurrentHashMap; + +import org.bukkit.entity.Entity; +import org.bukkit.entity.LivingEntity; +import org.bukkit.persistence.PersistentDataType; + +import net.tfminecraft.breedingbuddies.BreedingBuddies; + +public final class HusbandryEntities { + + private static final Map LOADED = new ConcurrentHashMap<>(); + + private HusbandryEntities() {} + + public static Set loadedIds() { + return Collections.unmodifiableSet(LOADED.keySet()); + } + + public static List snapshotLoaded() { + return new ArrayList<>(LOADED.values()); + } + + public static Optional getLoaded(UUID uuid) { + if (uuid == null) { + return Optional.empty(); + } + return Optional.ofNullable(LOADED.get(uuid)); + } + + public static void putLoaded(HusbandryAnimal animal) { + if (animal == null || animal.uuid() == null) { + return; + } + LOADED.put(animal.uuid(), animal); + } + + public static void evict(UUID uuid) { + if (uuid != null) { + LOADED.remove(uuid); + } + } + + public static void untrack(UUID uuid) { + evict(uuid); + } + + public static void clearLoaded() { + LOADED.clear(); + } + + /** + * Cache-first lookup, then SQLite for unloaded or offline animals. + * Loaded animals always return the canonical in-memory instance. + */ + public static Optional lookup(UUID uuid) { + Optional loaded = getLoaded(uuid); + if (loaded.isPresent()) { + return loaded; + } + HusbandryRepository repository = repository(); + if (repository == null || uuid == null) { + return Optional.empty(); + } + return repository.getAnimal(uuid); + } + + public static boolean isManaged(Entity entity) { + if (entity == null) { + return false; + } + Byte value = HusbandryKeys.read(entity.getPersistentDataContainer(), HusbandryKeys.MANAGED, PersistentDataType.BYTE); + return value != null && value == 1; + } + + /** True for CompanionPets bodies, including before CompanionPets has enabled at startup. */ + public static boolean isCompanionPet(Entity entity) { + return entity != null + && entity.getPersistentDataContainer().has(HusbandryKeys.COMPANION_PET, PersistentDataType.STRING); + } + + public static void stampManaged(Entity entity) { + if (entity == null) { + return; + } + entity.getPersistentDataContainer().set(HusbandryKeys.MANAGED, PersistentDataType.BYTE, (byte) 1); + } + + public static void applyPersistFlags(LivingEntity entity) { + if (entity == null) { + return; + } + entity.setPersistent(true); + entity.setRemoveWhenFarAway(false); + } + + public static String displayName(org.bukkit.entity.EntityType type) { + if (type == null) { + return "Animal"; + } + String raw = type.name().toLowerCase(Locale.ROOT).replace('_', ' '); + StringBuilder out = new StringBuilder(); + for (String word : raw.split(" ")) { + if (!out.isEmpty()) { + out.append(' '); + } + out.append(Character.toUpperCase(word.charAt(0))); + out.append(word.substring(1)); + } + return out.toString(); + } + + public static HusbandryRepository repository() { + BreedingBuddies plugin = BreedingBuddies.plugin; + if (plugin == null) { + return null; + } + return plugin.getHusbandryRepository(); + } +} diff --git a/src/main/java/net/tfminecraft/breedingbuddies/husbandry/HusbandryEntityScan.java b/src/main/java/net/tfminecraft/breedingbuddies/husbandry/HusbandryEntityScan.java new file mode 100644 index 0000000..6b3db89 --- /dev/null +++ b/src/main/java/net/tfminecraft/breedingbuddies/husbandry/HusbandryEntityScan.java @@ -0,0 +1,328 @@ +package net.tfminecraft.breedingbuddies.husbandry; + +import java.io.ByteArrayInputStream; +import java.io.DataInputStream; +import java.io.File; +import java.io.IOException; +import java.io.InputStream; +import java.nio.file.Files; +import java.util.HashMap; +import java.util.HashSet; +import java.util.List; +import java.util.Map; +import java.util.Set; +import java.util.UUID; +import java.util.regex.Matcher; +import java.util.regex.Pattern; +import java.util.zip.GZIPInputStream; +import java.util.zip.InflaterInputStream; + +/** + * Reads saved entity chunks ({@code entities/r.X.Z.mca}) to find where unloaded animals are, + * and player files for animals saved with a rider who logged out ({@code RootVehicle}). + * Runs off the main thread; it only reads files. + */ +final class HusbandryEntityScan { + + record WorldDir(String world, File entities) {} + + record Found(String world, int x, int y, int z) {} + + /** + * {@code ridden} holds animals saved under a logged-out rider. + * {@code complete} is false when some chunk or player file could not be read, so a missing animal may still exist. + */ + record Result(Map found, Set ridden, boolean complete) {} + + private static final Pattern REGION = Pattern.compile("r\\.(-?\\d{1,7})\\.(-?\\d{1,7})\\.mca"); + private static final int SECTOR = 4096; + private static final int MAX_CHUNK_BYTES = 32 * 1024 * 1024; + private static final int MAX_REGION_BYTES = 256 * 1024 * 1024; + + private HusbandryEntityScan() {} + + static Result scan(List worlds, Set targets) { + return scan(worlds, null, targets); + } + + static Result scan(List worlds, File playerdata, Set targets) { + Map found = new HashMap<>(); + Set ridden = new HashSet<>(); + boolean complete = true; + if (targets.isEmpty()) { + return new Result(found, ridden, true); + } + for (WorldDir world : worlds) { + if (!world.entities().exists()) { + continue; + } + File[] files = world.entities().listFiles(); + if (files == null) { + complete = false; + continue; + } + for (File file : files) { + Matcher name = REGION.matcher(file.getName()); + if (!name.matches()) { + continue; + } + int regionX = Integer.parseInt(name.group(1)); + int regionZ = Integer.parseInt(name.group(2)); + complete &= scanRegion(world, file, regionX, regionZ, targets, found); + } + } + complete &= scanPlayers(playerdata, targets, ridden); + return new Result(found, ridden, complete); + } + + private static boolean scanPlayers(File playerdata, Set targets, Set ridden) { + if (playerdata == null || !playerdata.exists()) { + return true; + } + File[] files = playerdata.listFiles((dir, name) -> name.endsWith(".dat")); + if (files == null) { + return false; + } + boolean complete = true; + for (File file : files) { + try (DataInputStream in = new DataInputStream(new ByteArrayInputStream( + new GZIPInputStream(new ByteArrayInputStream(readBounded(file, MAX_CHUNK_BYTES))) + .readNBytes(MAX_CHUNK_BYTES)))) { + Map vehicle = new HashMap<>(); + readPlayer(in, targets, vehicle); + ridden.addAll(vehicle.keySet()); + } catch (IOException | RuntimeException ex) { + complete = false; + } + } + return complete; + } + + private static boolean scanRegion( + WorldDir world, File file, int regionX, int regionZ, Set targets, Map found) { + byte[] data; + try { + data = readBounded(file, MAX_REGION_BYTES); + } catch (IOException ex) { + return false; + } + if (data.length == 0) { + return true; + } + if (data.length < SECTOR * 2) { + return false; + } + boolean complete = true; + for (int index = 0; index < 1024; index++) { + int entry = readInt(data, index * 4); + long sectorOffset = (long) (entry >>> 8) * SECTOR; + if (sectorOffset == 0) { + continue; + } + if (sectorOffset + 5 > data.length) { + complete = false; + continue; + } + int offset = (int) sectorOffset; + try { + byte[] chunk = chunkBytes(world, data, offset, regionX * 32 + index % 32, regionZ * 32 + index / 32); + if (chunk == null) { + complete = false; + continue; + } + readEntityChunk(new DataInputStream(new ByteArrayInputStream(chunk)), world.world(), targets, found); + } catch (IOException | RuntimeException ex) { + complete = false; + } + } + return complete; + } + + private static byte[] chunkBytes(WorldDir world, byte[] data, int offset, int chunkX, int chunkZ) + throws IOException { + int length = readInt(data, offset); + int compression = data[offset + 4] & 0xFF; + byte[] raw; + if ((compression & 0x80) != 0) { + compression &= 0x7F; + raw = readBounded(new File(world.entities(), "c." + chunkX + "." + chunkZ + ".mcc"), MAX_CHUNK_BYTES); + } else { + if (length < 1 || (long) offset + 4 + length > data.length) { + return null; + } + raw = new byte[length - 1]; + System.arraycopy(data, offset + 5, raw, 0, raw.length); + } + InputStream in = switch (compression) { + case 1 -> new GZIPInputStream(new ByteArrayInputStream(raw)); + case 2 -> new InflaterInputStream(new ByteArrayInputStream(raw)); + case 3 -> new ByteArrayInputStream(raw); + default -> null; + }; + if (in == null) { + return null; + } + try (in) { + byte[] chunk = in.readNBytes(MAX_CHUNK_BYTES + 1); + if (chunk.length > MAX_CHUNK_BYTES) { + throw new IOException("Entity chunk larger than " + MAX_CHUNK_BYTES + " bytes"); + } + return chunk; + } + } + + private static byte[] readBounded(File file, int max) throws IOException { + try (InputStream in = Files.newInputStream(file.toPath())) { + byte[] data = in.readNBytes(max + 1); + if (data.length > max) { + throw new IOException(file.getName() + " is larger than " + max + " bytes"); + } + return data; + } + } + + private static int readInt(byte[] data, int at) { + return ((data[at] & 0xFF) << 24) | ((data[at + 1] & 0xFF) << 16) + | ((data[at + 2] & 0xFF) << 8) | (data[at + 3] & 0xFF); + } + + // ---------------------------------------------------------------------- + // NBT: only Entities[].UUID, Pos and Passengers are read, the rest is skipped + // ---------------------------------------------------------------------- + + private static final byte END = 0; + private static final byte DOUBLE = 6; + private static final byte LIST = 9; + private static final byte COMPOUND = 10; + private static final byte INT_ARRAY = 11; + + private static void readEntityChunk(DataInputStream in, String world, Set targets, Map found) + throws IOException { + if (in.readByte() != COMPOUND) { + 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 (key.equals("Entities")) { + if (type != LIST) throw new IOException("Invalid Entities tag"); + readEntityList(in, world, targets, found); + } else { + skip(in, type); + } + } + } + + private static void readPlayer(DataInputStream in, Set targets, Map found) + throws IOException { + if (in.readByte() != COMPOUND) { + throw new IOException("Player NBT root is not a compound"); + } + in.readUTF(); + byte type; + while ((type = in.readByte()) != END) { + String key = in.readUTF(); + 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 (innerKey.equals("Entity")) { + if (inner != COMPOUND) throw new IOException("Invalid root vehicle Entity tag"); + readEntity(in, "", targets, found); + } else { + skip(in, inner); + } + } + } else { + skip(in, type); + } + } + } + + private static void readEntityList(DataInputStream in, String world, Set targets, Map found) + throws IOException { + byte element = in.readByte(); + int size = in.readInt(); + 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); + } + } + + private static void readEntity(DataInputStream in, String world, Set targets, Map found) + throws IOException { + UUID uuid = null; + double[] pos = null; + byte type; + while ((type = in.readByte()) != END) { + String key = in.readUTF(); + 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 { + throw new IOException("Invalid entity UUID length"); + } + } 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()}; + 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 (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) { + 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]))); + } + } + + private static void skip(DataInputStream in, byte type) throws IOException { + switch (type) { + case 1 -> in.skipNBytes(1); + case 2 -> in.skipNBytes(2); + case 3, 5 -> in.skipNBytes(4); + case 4, 6 -> in.skipNBytes(8); + case 7 -> in.skipNBytes(in.readInt()); + case 8 -> in.skipNBytes(in.readUnsignedShort()); + case 9 -> { + byte element = in.readByte(); + int size = in.readInt(); + for (int i = 0; i < size; i++) { + skip(in, element); + } + } + case 10 -> { + byte inner; + while ((inner = in.readByte()) != END) { + in.skipNBytes(in.readUnsignedShort()); + skip(in, inner); + } + } + case 11 -> in.skipNBytes(4L * in.readInt()); + case 12 -> in.skipNBytes(8L * in.readInt()); + default -> throw new IOException("Unknown NBT tag " + type); + } + } +} diff --git a/src/main/java/net/tfminecraft/breedingbuddies/husbandry/HusbandryEstate.java b/src/main/java/net/tfminecraft/breedingbuddies/husbandry/HusbandryEstate.java new file mode 100644 index 0000000..b8a7cb3 --- /dev/null +++ b/src/main/java/net/tfminecraft/breedingbuddies/husbandry/HusbandryEstate.java @@ -0,0 +1,268 @@ +package net.tfminecraft.breedingbuddies.husbandry; + +import java.sql.Connection; +import java.sql.SQLException; +import java.time.Instant; +import java.util.HashMap; +import java.util.List; +import java.util.Map; +import java.util.Optional; +import java.util.UUID; +import java.util.function.Predicate; +import java.util.function.Supplier; +import java.util.logging.Logger; + +import org.bukkit.plugin.Plugin; +import org.bukkit.plugin.ServicePriority; +import net.tfminecraft.tlibs.character.CharacterRef; +import net.tfminecraft.tlibs.character.estate.EstateDecision; +import net.tfminecraft.tlibs.character.estate.EstateParticipant; +import net.tfminecraft.tlibs.character.estate.Estates; +import net.tfminecraft.tlibs.character.estate.Holdings; +import net.tfminecraft.tlibs.character.estate.Outcome; +import net.tfminecraft.tlibs.character.estate.Recipient; +import net.tfminecraft.tlibs.database.SqliteDatabaseException; + +import static net.tfminecraft.breedingbuddies.husbandry.HusbandryOwnershipStore.*; + +/** A whole estate's slots, collision merges and receipts commit together; no capacity rejection. */ +final class HusbandryEstate implements EstateParticipant { + static final String ID = "cooking-animals"; + private final HusbandryRepository repository; + private final Logger logger; + Predicate guard = Estates::guardHolds; + Supplier> decided = Estates::decided; + interface Reporter { boolean report(UUID death, String id, long revision, Outcome outcome); } + Reporter reporter = Estates::report; + private final Map> reported = new HashMap<>(); + + HusbandryEstate(HusbandryRepository repository, Logger logger) { + this.repository = repository; + this.logger = logger; + + } + + static void start(Plugin plugin, HusbandryRepository repository) { + HusbandryEstate estate = new HusbandryEstate(repository, plugin.getLogger()); + plugin.getServer().getServicesManager().register(EstateParticipant.class, estate, plugin, ServicePriority.Normal); + plugin.getServer().getScheduler().runTaskTimer(plugin, estate::reconcile, 2, 20L * 60 * 60); + } + + @Override public String id() { return ID; } + + @Override public Outcome settle(EstateDecision decision) { + if (!guard.test(decision)) return Outcome.retry("Animal estate guard does not hold"); + var db = repository.ownershipDatabase(); + try { + String capturedDecision = captured(decision); + if (decision.property().character().isEmpty()) return receipts(decision); + CharacterRef to = decision.property().character().orElseThrow(); + if (to.equals(decision.testator())) return receipts(decision); + db.runTransaction(connection -> { + try { + if (scalar(connection, "SELECT COUNT(*) FROM husbandry_estate_intents WHERE status != 'done' AND (death_id = ? OR from_character = ?)", + decision.deathId().toString(), decision.testator().characterId().toString()) != 0) { + throw new Held("Incomplete animal estate intent requires staff review"); + } + validateReceipts(connection, decision); + if (scalar(connection, "SELECT COUNT(*) FROM husbandry_estate_intents WHERE death_id = ? AND revision = ?", + decision.deathId().toString(), decision.revision()) != 0) return; + List source = rows(connection, + "SELECT animal_uuid, account_uuid, role FROM character_owners WHERE character_uuid = ?", + decision.testator().characterId().toString()); + update(connection, "INSERT INTO husbandry_estate_intents VALUES (?, ?, ?, ?, ?, ?, ?, 'prepared')", + decision.deathId().toString(), decision.revision(), capturedDecision, + decision.testator().ownerId().toString(), decision.testator().characterId().toString(), + to.ownerId().toString(), to.characterId().toString()); + for (String[] row : source) { + if (!row[1].equals(decision.testator().ownerId().toString()) || !recognized(row[2])) { + throw new Held("Conflicting testator slot for animal " + row[0]); + } + String[] recipient = slot(connection, row[0], to.characterId().toString()); + if (recipient != null && (!recipient[0].equals(to.ownerId().toString()) || !recognized(recipient[1]))) { + throw new Held("Conflicting recipient slot for animal " + row[0]); + } + String role = recipient == null ? row[2] : stronger(row[2], recipient[1]); + if (!guard.test(decision)) throw new GuardChanged(); + archiveSlot(connection, decision, row[0], decision.testator().characterId(), "source"); + if (recipient != null) archiveSlot(connection, decision, row[0], to.characterId(), "recipient"); + put(connection, row[0], to, role); + if (!to.equals(decision.testator())) update(connection, + "DELETE FROM character_owners WHERE animal_uuid = ? AND character_uuid = ? AND account_uuid = ?", + row[0], decision.testator().characterId().toString(), decision.testator().ownerId().toString()); + update(connection, "INSERT INTO husbandry_estate_slots VALUES (?, ?, ?, ?, ?, ?)", + decision.deathId().toString(), decision.revision(), row[0], row[2], + recipient == null ? null : recipient[1], role); + } + if (!guard.test(decision)) throw new GuardChanged(); + update(connection, "UPDATE husbandry_estate_intents SET status = 'done' WHERE death_id = ? AND revision = ?", + decision.deathId().toString(), decision.revision()); + } catch (SQLException ex) { + throw new SqliteDatabaseException("Could not commit animal estate", ex); + } + }); + return receipts(decision); + } catch (Held ex) { + return Outcome.blocked(ex.getMessage()); + } catch (GuardChanged ex) { + return Outcome.retry("Animal estate changed; transaction rolled back"); + } catch (RuntimeException | SQLException ex) { + logger.warning("Animal estate rolled back: " + ex.getMessage()); + return Outcome.retry("Could not save animal estate: " + ex.getMessage()); + } + } + + /** Completed transfers remain visible even when a later property revision is frozen or waiting. */ + private Outcome receipts(EstateDecision decision) throws SQLException { + var db = repository.ownershipDatabase(); + synchronized (db) { + Connection connection = db.getConnection(); + if (scalar(connection, "SELECT COUNT(*) FROM husbandry_estate_intents WHERE status != 'done' AND (death_id = ? OR from_character = ?)", + decision.deathId().toString(), decision.testator().characterId().toString()) != 0) { + throw new Held("Incomplete animal estate intent requires staff review"); + } + validateReceipts(connection, decision); + List moved = rows(connection, + "SELECT DISTINCT animal_uuid FROM husbandry_estate_slots WHERE death_id = ? ORDER BY animal_uuid", + decision.deathId().toString()).stream().map(row -> row[0]).toList(); + return moved.isEmpty() ? Outcome.done() : Outcome.done(Map.of("animals", moved.size()), moved); + } + } + + /** A restored source cannot become an untouched holding merely by changing the revision. */ + private static void validateReceipts(Connection connection, EstateDecision decision) throws SQLException { + List saved = rows(connection, + "SELECT revision, decision, from_account, from_character, to_account, to_character FROM husbandry_estate_intents WHERE death_id = ?", + decision.deathId().toString()); + for (String[] row : saved) { + long revision; + try { revision = Long.parseLong(row[0]); } + catch (RuntimeException invalid) { throw new Held("Animal receipt revision is invalid"); } + if (revision < 0 || row[1].isBlank()) throw new Held("Animal receipt decision envelope is invalid"); + if (!decision.testator().ownerId().toString().equals(row[2]) + || !decision.testator().characterId().toString().equals(row[3])) + throw new Held("Animal receipt testator conflicts with this death"); + CharacterRef recipient; + try { recipient = new CharacterRef(UUID.fromString(row[4]), UUID.fromString(row[5])); } + catch (RuntimeException invalid) { throw new Held("Animal receipt recipient is invalid"); } + if (!row[1].startsWith("EstateDecision[deathId=" + decision.deathId() + ", testator=" + refText(decision.testator()) + ", diedAt=") + || !row[1].contains(", property=Character[ref=" + refText(recipient) + "], guildLocks=") + || !row[1].endsWith(", revision=" + revision + "]")) + throw new Held("Historical animal receipt conflicts with its captured decision"); + List original = scalar(connection, + "SELECT COUNT(*) FROM sqlite_master WHERE type = 'table' AND name = 'husbandry_estate_owner_archive'") == 0 + ? List.of() : rows(connection, """ + SELECT animal_uuid, account_uuid, character_uuid FROM husbandry_estate_owner_archive + WHERE estate_death_id = ? AND estate_revision = ? AND estate_position = 'source' + """, decision.deathId().toString(), revision); + for (String[] slot : original) { + if (!row[2].equals(slot[1]) || !row[3].equals(slot[2])) + throw new Held("Animal receipt source archive conflicts with its full reference"); + } + List plannedIds = original.stream().map(slot -> slot[0]).toList(); + List movedIds = rows(connection, + "SELECT animal_uuid FROM husbandry_estate_slots WHERE death_id = ? AND revision = ?", + decision.deathId().toString(), revision).stream().map(slot -> slot[0]).toList(); + if (plannedIds.size() != new java.util.HashSet<>(plannedIds).size() + || !new java.util.HashSet<>(plannedIds).equals(new java.util.HashSet<>(movedIds))) + throw new Held("Animal receipt IDs conflict with the immutable source archive"); + if (revision == decision.revision()) { + if (!captured(decision).equals(row[1]) + || !decision.property().character().equals(java.util.Optional.of(recipient))) + throw new Held("Decision conflicts with stored animal receipt"); + if (scalar(connection, "SELECT COUNT(*) FROM character_owners WHERE character_uuid = ?", + decision.testator().characterId().toString()) != 0) + throw new Held("Completed animal receipt conflicts with remaining property; review a new revision"); + } + } + if (scalar(connection, """ + SELECT COUNT(*) FROM character_owners o JOIN husbandry_estate_slots s ON s.animal_uuid = o.animal_uuid + WHERE s.death_id = ? AND o.character_uuid = ? + """, decision.deathId().toString(), decision.testator().characterId().toString()) != 0) + throw new Held("Previously transferred animal reappeared at the testator; preserve receipts for review"); + } + + /** Fixed text of all twelve TLibs 2.6.0 fields; byte-identical to its Java 21 record text. */ + private static String captured(EstateDecision decision) { + return "EstateDecision[deathId=" + decision.deathId() + ", testator=" + refText(decision.testator()) + + ", diedAt=" + optionalText(decision.diedAt().map(Instant::toString)) + + ", decidedAt=" + decision.decidedAt() + + ", heir=" + optionalText(decision.heir().map(HusbandryEstate::refText)) + + ", deathGuild=" + optionalText(decision.deathGuild().map(UUID::toString)) + + ", deathRealm=" + optionalText(decision.deathRealm().map(UUID::toString)) + + ", money=" + partText(decision.money()) + ", property=" + partText(decision.property()) + + ", guildLocks=" + partText(decision.guildLocks()) + ", letters=" + partText(decision.letters()) + + ", revision=" + decision.revision() + "]"; + } + + private static String refText(CharacterRef ref) { + return "CharacterRef[ownerId=" + ref.ownerId() + ", characterId=" + ref.characterId() + "]"; + } + + private static String optionalText(Optional value) { + return value.map(text -> "Optional[" + text + "]").orElse("Optional.empty"); + } + + private static String partText(Recipient recipient) { + Recipient.Kind kind = recipient.kind(); + if (kind == Recipient.Kind.ACCOUNT) return "Account[accountId=" + recipient.account().orElseThrow() + "]"; + if (kind == Recipient.Kind.CHARACTER) return "Character[ref=" + refText(recipient.character().orElseThrow()) + "]"; + if (kind == Recipient.Kind.WAITING) return "Waiting[]"; + if (kind == Recipient.Kind.FROZEN) return "Frozen[]"; + if (kind == Recipient.Kind.SENDER) return "Sender[]"; + throw new Held("Unsupported estate decision part " + kind + "; BreedingBuddies must be updated"); + } + + /** Independent raw rows retain unknown nullable/BLOB metadata even after the source slot is deleted. */ + private static void archiveSlot(Connection connection, EstateDecision decision, String animal, UUID character, + String position) throws SQLException { + update(connection, "CREATE TABLE IF NOT EXISTS husbandry_estate_owner_archive AS SELECT * FROM character_owners WHERE 0"); + List columns = rows(connection, "PRAGMA table_info(character_owners)").stream().map(row -> row[1]).toList(); + List saved = rows(connection, "PRAGMA table_info(husbandry_estate_owner_archive)").stream().map(row -> row[1]).toList(); + for (String metadata : List.of("estate_death_id", "estate_revision", "estate_position")) { + if (columns.contains(metadata)) throw new Held("Owner schema uses reserved estate archive column " + metadata); + if (!saved.contains(metadata)) update(connection, "ALTER TABLE husbandry_estate_owner_archive ADD COLUMN " + quoted(metadata)); + } + for (String column : columns) { + if (!saved.contains(column)) update(connection, "ALTER TABLE husbandry_estate_owner_archive ADD COLUMN " + quoted(column)); + } + String fields = columns.stream().map(HusbandryEstate::quoted).collect(java.util.stream.Collectors.joining(",")); + update(connection, "INSERT INTO husbandry_estate_owner_archive (" + fields + + ",estate_death_id,estate_revision,estate_position) SELECT " + fields + + ",?,?,? FROM character_owners WHERE animal_uuid = ? AND character_uuid = ?", + decision.deathId().toString(), decision.revision(), position, animal, character.toString()); + } + + private static String quoted(String column) { return "\"" + column.replace("\"", "\"\"") + "\""; } + + @Override public Holdings describe(UUID characterId) { + var db = repository.ownershipDatabase(); + try { + synchronized (db) { + List labels = rows(db.getConnection(), + "SELECT animal_uuid FROM character_owners WHERE character_uuid = ? ORDER BY animal_uuid", characterId.toString()) + .stream().map(row -> "Animal " + row[0]).toList(); + return labels.isEmpty() ? Holdings.NONE : new Holdings(Map.of("animals", labels.size()), labels); + } + } catch (SQLException ex) { + throw new SqliteDatabaseException("Could not describe animal holdings", ex); + } + } + + void reconcile() { + for (EstateDecision decision : decided.get()) { + try { + if (!guard.test(decision)) continue; + Outcome outcome = settle(decision); + var report = Map.entry(decision.revision(), outcome); + if (!report.equals(reported.get(decision.deathId())) + && reporter.report(decision.deathId(), ID, decision.revision(), outcome)) reported.put(decision.deathId(), report); + } catch (RuntimeException ex) { + logger.warning("Could not reconcile animal estate " + decision.deathId() + ": " + ex.getMessage()); + } + } + } + + private static final class Held extends RuntimeException { Held(String reason) { super(reason); } } + private static final class GuardChanged extends RuntimeException { } +} diff --git a/src/main/java/net/tfminecraft/breedingbuddies/husbandry/HusbandryExpBracket.java b/src/main/java/net/tfminecraft/breedingbuddies/husbandry/HusbandryExpBracket.java new file mode 100644 index 0000000..46750bc --- /dev/null +++ b/src/main/java/net/tfminecraft/breedingbuddies/husbandry/HusbandryExpBracket.java @@ -0,0 +1,39 @@ +package net.tfminecraft.breedingbuddies.husbandry; + +import java.util.concurrent.ThreadLocalRandom; +import java.util.random.RandomGenerator; + +public final class HusbandryExpBracket { + private final int min; + private final int max; + + private HusbandryExpBracket(int min, int max) { + this.min = min; + this.max = max; + } + + public static HusbandryExpBracket of(int min, int max) { + int low = Math.max(0, Math.min(min, max)); + int high = Math.max(0, Math.max(min, max)); + return new HusbandryExpBracket(low, high); + } + + public int min() { + return min; + } + + public int max() { + return max; + } + + public int roll(RandomGenerator random) { + if (min == max) { + return min; + } + return random.nextInt(min, max + 1); + } + + public int roll() { + return roll(ThreadLocalRandom.current()); + } +} diff --git a/src/main/java/net/tfminecraft/breedingbuddies/husbandry/HusbandryFeedQuality.java b/src/main/java/net/tfminecraft/breedingbuddies/husbandry/HusbandryFeedQuality.java new file mode 100644 index 0000000..4907bf4 --- /dev/null +++ b/src/main/java/net/tfminecraft/breedingbuddies/husbandry/HusbandryFeedQuality.java @@ -0,0 +1,134 @@ +package net.tfminecraft.breedingbuddies.husbandry; + +import java.util.OptionalInt; +import java.util.UUID; +import java.util.concurrent.ConcurrentHashMap; + +import org.bukkit.inventory.ItemStack; + +import net.tfminecraft.cooking.api.CookingFoodApi; + +/** + * Breeding-food stars scale the gene bonus above the parent average. + * Five stars, universal feed, and items with no cooking quality keep the full bonus. + */ +public final class HusbandryFeedQuality { + + public static final double FULL_SCALE = 1.0; + private static final long OFFER_WINDOW_MILLIS = 2_000L; + private static final long LOVE_WINDOW_MILLIS = 45_000L; + + private static final ConcurrentHashMap OFFERS = new ConcurrentHashMap<>(); + private static final ConcurrentHashMap FEEDS = new ConcurrentHashMap<>(); + + private HusbandryFeedQuality() {} + + public static double scaleForStars(int stars) { + int clamped = Math.max(1, Math.min(5, stars)); + return clamped / 5.0; + } + + public static double clampScale(double scale) { + if (Double.isNaN(scale)) { + return FULL_SCALE; + } + return Math.max(0, Math.min(FULL_SCALE, scale)); + } + + public static double combine(Double left, Double right) { + if (left == null && right == null) { + return FULL_SCALE; + } + if (left == null) { + return clampScale(right); + } + if (right == null) { + return clampScale(left); + } + return clampScale((left + right) / 2.0); + } + + public static double scaleOf(ItemStack stack) { + if (isUniversalFeed(stack)) { + return FULL_SCALE; + } + OptionalInt stars = CookingFoodApi.qualityOf(stack); + if (stars.isEmpty()) { + return FULL_SCALE; + } + return scaleForStars(stars.getAsInt()); + } + + public static void offer(UUID animalId, double scale, long nowMillis) { + if (animalId == null) { + return; + } + OFFERS.put(animalId, new TimedScale(clampScale(scale), nowMillis)); + } + + public static void discardOffer(UUID animalId) { + if (animalId != null) { + OFFERS.remove(animalId); + } + } + + public static void commitOffer(UUID animalId, long nowMillis) { + if (animalId == null) { + return; + } + TimedScale offer = OFFERS.remove(animalId); + if (offer == null || nowMillis - offer.atMillis > OFFER_WINDOW_MILLIS) { + return; + } + remember(animalId, offer.scale, nowMillis); + } + + public static void remember(UUID animalId, double scale, long nowMillis) { + if (animalId == null) { + return; + } + FEEDS.put(animalId, new TimedScale(clampScale(scale), nowMillis)); + } + + public static double consumeBreedingScale(UUID mother, UUID father, ItemStack bredWith, long nowMillis) { + Double motherScale = take(mother, nowMillis); + Double fatherScale = take(father, nowMillis); + if (motherScale == null && fatherScale == null) { + return scaleOf(bredWith); + } + return combine(motherScale, fatherScale); + } + + public static double takePair(UUID mother, UUID father, long nowMillis) { + return combine(take(mother, nowMillis), take(father, nowMillis)); + } + + static void clear() { + OFFERS.clear(); + FEEDS.clear(); + } + + private static Double take(UUID animalId, long nowMillis) { + if (animalId == null) { + return null; + } + TimedScale feed = FEEDS.remove(animalId); + if (feed == null || nowMillis - feed.atMillis > LOVE_WINDOW_MILLIS) { + return null; + } + return feed.scale; + } + + private static boolean isUniversalFeed(ItemStack stack) { + if (stack == null || stack.getType().isAir()) { + return false; + } + try { + return HusbandryItems.matches(stack, HusbandryConfig.feedItem()); + } catch (RuntimeException ignored) { + return false; + } + } + + private record TimedScale(double scale, long atMillis) {} +} diff --git a/src/main/java/net/tfminecraft/breedingbuddies/husbandry/HusbandryFx.java b/src/main/java/net/tfminecraft/breedingbuddies/husbandry/HusbandryFx.java new file mode 100644 index 0000000..738a356 --- /dev/null +++ b/src/main/java/net/tfminecraft/breedingbuddies/husbandry/HusbandryFx.java @@ -0,0 +1,53 @@ +package net.tfminecraft.breedingbuddies.husbandry; + +import org.bukkit.Material; +import org.bukkit.Particle; +import org.bukkit.Sound; +import org.bukkit.entity.LivingEntity; +import org.bukkit.inventory.ItemStack; + +final class HusbandryFx { + + private HusbandryFx() {} + + /** Feeding or brushing; the hearts for any trust it earns come from HusbandryTrust. */ + static void playCare(LivingEntity entity) { + entity.getWorld().spawnParticle(Particle.HAPPY_VILLAGER, entity.getLocation().add(0, entity.getHeight() * 0.7, 0), + 8, 0.35, 0.35, 0.35, 0.0); + entity.getWorld().playSound(entity.getLocation(), Sound.BLOCK_AMETHYST_BLOCK_STEP, 0.8f, 1.2f); + } + + /** The original BreedingBuddies flourish: cherry petals and a chime, for taming and sharing. */ + static void playBond(org.bukkit.entity.Entity entity) { + entity.getWorld().spawnParticle(Particle.CHERRY_LEAVES, entity.getLocation().add(0, entity.getHeight() * 0.7, 0), + 10, 0.5, 0.5, 0.5, 0.1); + entity.getWorld().playSound(entity.getLocation(), Sound.BLOCK_AMETHYST_CLUSTER_STEP, 1f, 1f); + } + + /** A foal's cry for horses, as BreedingBuddies had; a short yelp for other animals. */ + static void playNeuter(LivingEntity entity) { + Sound cry = entity instanceof org.bukkit.entity.AbstractHorse ? Sound.ENTITY_HORSE_DEATH : Sound.ENTITY_GENERIC_HURT; + entity.getWorld().playSound(entity.getLocation(), cry, 1.2f, 1.5f); + } + + /** A pig roots in the dirt, a chicken moults, a cow just leaves something for the fields. */ + static void playShed(LivingEntity entity) { + org.bukkit.Location at = entity.getLocation(); + switch (entity.getType()) { + case PIG -> { + entity.getWorld().spawnParticle(Particle.BLOCK, at, 16, 0.3, 0.05, 0.3, Material.DIRT.createBlockData()); + entity.getWorld().playSound(at, Sound.BLOCK_ROOTED_DIRT_BREAK, 0.8f, 1f); + } + case CHICKEN -> { + entity.getWorld().spawnParticle(Particle.ITEM, at.clone().add(0, 0.5, 0), 8, 0.2, 0.2, 0.2, 0.02, + new ItemStack(Material.FEATHER)); + entity.getWorld().playSound(at, Sound.ENTITY_CHICKEN_AMBIENT, 0.7f, 1.2f); + } + default -> entity.getWorld().playSound(at, Sound.BLOCK_MUD_PLACE, 0.6f, 0.9f); + } + } + + static void playTame(LivingEntity entity) { + playBond(entity); + } +} diff --git a/src/main/java/net/tfminecraft/breedingbuddies/husbandry/HusbandryGenetics.java b/src/main/java/net/tfminecraft/breedingbuddies/husbandry/HusbandryGenetics.java new file mode 100644 index 0000000..fce2fa5 --- /dev/null +++ b/src/main/java/net/tfminecraft/breedingbuddies/husbandry/HusbandryGenetics.java @@ -0,0 +1,42 @@ +package net.tfminecraft.breedingbuddies.husbandry; + +import java.util.Random; + +public final class HusbandryGenetics { + + private HusbandryGenetics() {} + + public static int roll( + int motherGenetics, + int fatherGenetics, + int motherCare, + int fatherCare, + Random random) { + return roll(motherGenetics, fatherGenetics, motherCare, fatherCare, random, HusbandryFeedQuality.FULL_SCALE); + } + + public static int roll( + int motherGenetics, + int fatherGenetics, + int motherCare, + int fatherCare, + Random random, + double feedScale) { + int maxGenetics = HusbandryConfig.maxGenetics(); + int avg = (motherGenetics + fatherGenetics) / 2; + double varianceBase = maxGenetics / 10.0; + int minVariance = Math.max(1, maxGenetics / 100); + double divider = 10.0 * Math.max(0.0001, HusbandryConfig.geneticSlowdownDivisor()); + int variance = Math.max( + minVariance, + (int) (HusbandryConfig.geneticVarianceMultiplier() * (varianceBase - avg / divider))); + int bonus = random == null || variance <= 0 ? 0 : random.nextInt(variance); + int careMax = HusbandryConfig.careMax(); + double careAvg = (motherCare + fatherCare) / 2.0; + double careRatio = careMax <= 0 ? 0 : Math.max(0, Math.min(1, careAvg / careMax)); + int careExtra = (int) (HusbandryConfig.careInfluence() * maxGenetics * careRatio); + int boost = (int) (bonus * careRatio) + careExtra; + int rolled = avg + (int) (boost * HusbandryFeedQuality.clampScale(feedScale)); + return Math.max(0, Math.min(maxGenetics, rolled)); + } +} diff --git a/src/main/java/net/tfminecraft/breedingbuddies/husbandry/HusbandryGrowth.java b/src/main/java/net/tfminecraft/breedingbuddies/husbandry/HusbandryGrowth.java new file mode 100644 index 0000000..40b5fe7 --- /dev/null +++ b/src/main/java/net/tfminecraft/breedingbuddies/husbandry/HusbandryGrowth.java @@ -0,0 +1,73 @@ +package net.tfminecraft.breedingbuddies.husbandry; + +import org.bukkit.entity.Ageable; +import org.bukkit.entity.Breedable; +import org.bukkit.entity.EntityType; +import org.bukkit.entity.LivingEntity; + +public final class HusbandryGrowth { + + public enum ModelAge { + BABY, + ADULT + } + + private HusbandryGrowth() {} + + public static boolean isMature(HusbandryAnimal animal, long nowMillis) { + if (animal == null) { + return true; + } + Long matureAt = animal.matureAt(); + return matureAt == null || nowMillis >= matureAt; + } + + /** Baby model until husbandry maturity. A missing record is already grown. */ + public static ModelAge modelAge(HusbandryAnimal animal, long nowMillis) { + return isMature(animal, nowMillis) ? ModelAge.ADULT : ModelAge.BABY; + } + + public static long computeMatureAt(EntityType type, long bornAtMillis) { + return bornAtMillis + HusbandryConfig.growUpSeconds(type) * 1000L; + } + + public static void applyMaturity(LivingEntity entity, HusbandryAnimal animal, long nowMillis) { + if (animal == null || entity == null) { + return; + } + if (modelAge(animal, nowMillis) == ModelAge.BABY) { + if (entity instanceof Breedable breedable) { + holdBaby(breedable); + } + return; + } + if (animal.matureAt() != null) { + animal.setMatureAt(null); + } + if (entity instanceof Breedable breedable) { + releaseAdult(breedable); + return; + } + if (entity instanceof Ageable ageable && !ageable.isAdult()) { + ageable.setAdult(); + } + } + + private static void holdBaby(Breedable breedable) { + if (breedable.isAdult()) { + breedable.setBaby(); + } + if (!breedable.getAgeLock()) { + breedable.setAgeLock(true); + } + } + + private static void releaseAdult(Breedable breedable) { + if (breedable.getAgeLock()) { + breedable.setAgeLock(false); + } + if (!breedable.isAdult()) { + breedable.setAdult(); + } + } +} diff --git a/src/main/java/net/tfminecraft/breedingbuddies/husbandry/HusbandryGuiBars.java b/src/main/java/net/tfminecraft/breedingbuddies/husbandry/HusbandryGuiBars.java new file mode 100644 index 0000000..a3e3bf4 --- /dev/null +++ b/src/main/java/net/tfminecraft/breedingbuddies/husbandry/HusbandryGuiBars.java @@ -0,0 +1,25 @@ +package net.tfminecraft.breedingbuddies.husbandry; + + +final class HusbandryGuiBars { + + static final int BAR_SLOTS = 5; + + private HusbandryGuiBars() {} + + static int yieldPercent(HusbandryAnimal animal) { + if (animal == null) { + return 0; + } + int effective = HusbandryConfig.effectiveGenetics(animal); + int max = HusbandryConfig.maxGenetics(); + return (int) Math.round(100.0 * effective / max); + } + + 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 * (double) value / max))); + } +} diff --git a/src/main/java/net/tfminecraft/breedingbuddies/husbandry/HusbandryHarvest.java b/src/main/java/net/tfminecraft/breedingbuddies/husbandry/HusbandryHarvest.java new file mode 100644 index 0000000..9934aa4 --- /dev/null +++ b/src/main/java/net/tfminecraft/breedingbuddies/husbandry/HusbandryHarvest.java @@ -0,0 +1,191 @@ +package net.tfminecraft.breedingbuddies.husbandry; + +import java.util.Random; +import java.util.concurrent.ThreadLocalRandom; + +import org.bukkit.Material; +import org.bukkit.Sound; +import org.bukkit.entity.EntityType; +import org.bukkit.entity.LivingEntity; +import org.bukkit.entity.Player; +import org.bukkit.inventory.ItemStack; + +import net.tfminecraft.tlibs.TLibs; +import net.tfminecraft.cooking.api.CookingFoodApi; + +public final class HusbandryHarvest { + + private HusbandryHarvest() {} + + public static void prepareNewAnimal(HusbandryAnimal animal, EntityType type) { + if (animal == null) { + return; + } + HusbandrySpecies species = HusbandryConfig.species(type); + if (species != null && species.canShear()) { + animal.setWoolReadyAt(System.currentTimeMillis()); + } + if (species != null && species.canShed()) { + animal.setShedReadyAt(System.currentTimeMillis()); + } + if (species != null && species.hasEgg()) { + animal.setEggReadyAt(System.currentTimeMillis()); + } + } + + public static int stars(HusbandryAnimal animal) { + if (animal == null) { + return 1; + } + return HusbandryConfig.starsForGenetics(animal.genetics()); + } + + public static int rollQuality(HusbandryAnimal animal, Random random) { + return HusbandryQualityRange.roll(HusbandryConfig.qualityRange(animal), random); + } + + public enum ShearResult { + IMMATURE, + UNWELL, + COOLDOWN, + DONE + } + + public static ShearResult shearReadiness( + HusbandryAnimal animal, + HusbandrySpecies species, + long nowMillis) { + if (animal == null || species == null || !species.canShear()) { + return ShearResult.COOLDOWN; + } + if (!HusbandryGrowth.isMature(animal, nowMillis)) { + return ShearResult.IMMATURE; + } + if (!HusbandrySimulator.isHappy(animal)) { + return ShearResult.UNWELL; + } + if (animal.woolReadyAt() != null && animal.woolReadyAt() > nowMillis) { + return ShearResult.COOLDOWN; + } + return ShearResult.DONE; + } + + public static ShearResult tryShear( + Player player, + LivingEntity living, + HusbandryAnimal animal, + HusbandrySpecies species, + HusbandryRepository repository, + long nowMillis) { + ShearResult readiness = shearReadiness(animal, species, nowMillis); + if (readiness == ShearResult.IMMATURE) { + if (player != null) { + player.sendMessage("§cThis animal is still growing up."); + } + return ShearResult.IMMATURE; + } + if (readiness == ShearResult.UNWELL) { + if (player != null) { + player.sendMessage("§cThis animal needs care before it gives wool."); + } + return ShearResult.UNWELL; + } + if (readiness != ShearResult.DONE) { + if (player != null) { + player.sendMessage("§cThis animal is not ready to be sheared."); + } + return ShearResult.COOLDOWN; + } + grantShearExtras(player, animal, nowMillis); + if (living != null && living.getWorld() != null) { + living.getWorld().playSound(living.getLocation(), Sound.ENTITY_SHEEP_SHEAR, 1f, 1f); + } + startWoolTimer(animal, species, repository, nowMillis); + return ShearResult.DONE; + } + + public static ShearResult trySheepBonusShear( + Player player, + HusbandryAnimal animal, + HusbandrySpecies species, + HusbandryRepository repository, + long nowMillis) { + ShearResult readiness = shearReadiness(animal, species, nowMillis); + if (readiness != ShearResult.DONE) { + return readiness; + } + grantShearExtras(player, animal, nowMillis); + startWoolTimer(animal, species, repository, nowMillis); + return ShearResult.DONE; + } + + private static void grantShearExtras(Player player, HusbandryAnimal animal, long nowMillis) { + if (player == null) { + return; + } + for (ItemStack drop : HusbandryDropRoller.rollShearDrops(animal, ThreadLocalRandom.current(), nowMillis)) { + ItemStack overflow = CookingFoodApi.addToInventory(player, drop); + if (overflow != null) { + player.getWorld().dropItemNaturally(player.getLocation(), overflow); + } + } + } + + private static void startWoolTimer( + HusbandryAnimal animal, + HusbandrySpecies species, + HusbandryRepository repository, + long nowMillis) { + long wait = HusbandryConfig.woolTimerSeconds(species == null ? null : species.type()) * 1000L; + animal.setWoolReadyAt(nowMillis + wait); + if (repository != null) { + repository.upsertAnimal(animal); + } + } + + public static Sound milkSound(EntityType type) { + if (type == EntityType.GOAT) { + return Sound.ENTITY_GOAT_MILK; + } + return Sound.ENTITY_COW_MILK; + } + + public static ItemStack buildMilk(HusbandryAnimal animal, EntityType type) { + if (animal == null) { + return null; + } + return CookingFoodApi.buildMilk(type, rollQuality(animal, ThreadLocalRandom.current())); + } + + public static ItemStack buildFood(HusbandryAnimal animal, String foodString) { + if (animal == null) { + return null; + } + int requested = HusbandryConfig.roastCutsFor(HusbandryConfig.effectiveGenetics(animal)); + return CookingFoodApi.buildRoast(foodString, rollQuality(animal, ThreadLocalRandom.current()), requested); + } + + public static ItemStack buildTlibs(String path, int amount) { + if (path == null || path.isBlank() || "vanilla".equalsIgnoreCase(path)) { + return null; + } + try { + ItemStack stack = TLibs.getItemAPI().getCreator().getItemFromPath(path); + if (stack == null || stack.getType().isAir()) { + return null; + } + stack.setAmount(Math.max(1, Math.min(64, amount))); + return stack; + } catch (RuntimeException ignored) { + return null; + } + } + + public static boolean isWoolDrop(ItemStack stack) { + if (stack == null) { + return false; + } + String name = stack.getType().name(); + return name.endsWith("_WOOL") || name.equals("WOOL") || stack.getType() == Material.WHITE_WOOL; + } +} diff --git a/src/main/java/net/tfminecraft/breedingbuddies/husbandry/HusbandryHarvestListener.java b/src/main/java/net/tfminecraft/breedingbuddies/husbandry/HusbandryHarvestListener.java new file mode 100644 index 0000000..31572f5 --- /dev/null +++ b/src/main/java/net/tfminecraft/breedingbuddies/husbandry/HusbandryHarvestListener.java @@ -0,0 +1,233 @@ +package net.tfminecraft.breedingbuddies.husbandry; + +import java.util.Optional; + +import org.bukkit.GameMode; +import org.bukkit.Material; +import org.bukkit.entity.Chicken; +import org.bukkit.entity.Entity; +import org.bukkit.entity.EntityType; +import org.bukkit.entity.LivingEntity; +import org.bukkit.entity.Player; +import org.bukkit.event.EventHandler; +import org.bukkit.event.EventPriority; +import org.bukkit.event.Listener; +import org.bukkit.event.entity.EntityDropItemEvent; +import org.bukkit.event.player.PlayerBucketFillEvent; +import org.bukkit.event.player.PlayerInteractEntityEvent; +import org.bukkit.event.player.PlayerShearEntityEvent; +import org.bukkit.inventory.EquipmentSlot; +import org.bukkit.inventory.ItemStack; +import org.bukkit.inventory.PlayerInventory; + +import net.tfminecraft.cooking.api.CookingFoodApi; + +public final class HusbandryHarvestListener implements Listener { + + @EventHandler(priority = EventPriority.HIGH, ignoreCancelled = true) + public void onMilk(PlayerInteractEntityEvent event) { + EquipmentSlot slot = event.getHand(); + if (slot != EquipmentSlot.HAND && slot != EquipmentSlot.OFF_HAND) { + return; + } + Player player = event.getPlayer(); + ItemStack used = player.getInventory().getItem(slot); + if (used == null || used.getType() != Material.BUCKET) { + return; + } + Entity clicked = event.getRightClicked(); + if (!(clicked instanceof LivingEntity living)) { + return; + } + HusbandrySpecies species = HusbandryConfig.species(living.getType()); + if (species == null || !species.canMilk()) { + return; + } + event.setCancelled(true); + if (slot == EquipmentSlot.OFF_HAND) { + ItemStack main = player.getInventory().getItemInMainHand(); + if (main != null && main.getType() == Material.BUCKET) { + return; + } + } + HusbandryRepository repository = HusbandryEntities.repository(); + if (repository == null) { + return; + } + Optional stored = HusbandryEntities.lookup(living.getUniqueId()); + if (stored.isEmpty()) { + return; + } + HusbandryAnimal animal = stored.get(); + long now = System.currentTimeMillis(); + if (!HusbandryGrowth.isMature(animal, now)) { + player.sendMessage("§cThis animal is still growing up."); + return; + } + if (!HusbandrySimulator.isHappy(animal)) { + player.sendMessage("§cThis animal needs care before it gives milk."); + return; + } + if (onMilkCooldown(animal, living.getType(), now)) { + if (shouldMessageCooldown(animal, now)) { + player.sendMessage("§cThis animal is not ready to be milked."); + } + return; + } + ItemStack milk = HusbandryHarvest.buildMilk(animal, living.getType()); + if (milk == null) { + return; + } + milk.setAmount(1); + consumeOneBucket(player, slot); + giveMilk(player, slot, milk); + if (living.getWorld() != null) { + living.getWorld().playSound(living.getLocation(), HusbandryHarvest.milkSound(living.getType()), 1f, 1f); + } + animal.setLastMilkAt(now); + repository.upsertAnimal(animal); + player.updateInventory(); + } + + @EventHandler(priority = EventPriority.HIGH) + public void onVanillaMilkFill(PlayerBucketFillEvent event) { + if (event.getBucket() == Material.MILK_BUCKET) { + event.setCancelled(true); + } + } + + @EventHandler(priority = EventPriority.HIGH, ignoreCancelled = true) + public void onShearsInteract(PlayerInteractEntityEvent event) { + if (event.getHand() != EquipmentSlot.HAND) { + return; + } + Player player = event.getPlayer(); + ItemStack hand = player.getInventory().getItemInMainHand(); + if (hand == null || hand.getType() != Material.SHEARS) { + return; + } + if (HusbandryItems.matches(hand, HusbandryConfig.neuterItem())) { + return; + } + Entity clicked = event.getRightClicked(); + if (!(clicked instanceof LivingEntity living)) { + return; + } + if (living.getType() == EntityType.SHEEP) { + return; + } + HusbandrySpecies species = HusbandryConfig.species(living.getType()); + if (species == null || !species.canShear()) { + return; + } + HusbandryRepository repository = HusbandryEntities.repository(); + if (repository == null) { + return; + } + Optional stored = HusbandryEntities.lookup(living.getUniqueId()); + if (stored.isEmpty()) { + return; + } + event.setCancelled(true); + HusbandryHarvest.tryShear(player, living, stored.get(), species, repository, System.currentTimeMillis()); + } + + @EventHandler(ignoreCancelled = true) + public void onShear(PlayerShearEntityEvent event) { + Entity sheared = event.getEntity(); + HusbandrySpecies species = HusbandryConfig.species(sheared.getType()); + if (species == null || !species.canShear()) { + return; + } + HusbandryRepository repository = HusbandryEntities.repository(); + if (repository == null) { + return; + } + Optional stored = HusbandryEntities.lookup(sheared.getUniqueId()); + if (stored.isEmpty()) { + return; + } + if (sheared.getType() == EntityType.SHEEP) { + HusbandryHarvest.trySheepBonusShear( + event.getPlayer(), + stored.get(), + species, + repository, + System.currentTimeMillis()); + return; + } + event.setCancelled(true); + if (sheared instanceof LivingEntity living) { + HusbandryHarvest.tryShear( + event.getPlayer(), + living, + stored.get(), + species, + repository, + System.currentTimeMillis()); + } + } + + private static boolean onMilkCooldown(HusbandryAnimal animal, EntityType type, long now) { + Long last = animal.lastMilkAt(); + if (last == null) { + return false; + } + long wait = HusbandryConfig.milkTimerSeconds(type) * 1000L; + return now - last < wait; + } + + private static boolean shouldMessageCooldown(HusbandryAnimal animal, long now) { + Long last = animal.lastMilkAt(); + return last == null || now - last > 1000L; + } + + private static void consumeOneBucket(Player player, EquipmentSlot slot) { + if (player.getGameMode() == GameMode.CREATIVE) { + return; + } + ItemStack used = player.getInventory().getItem(slot); + int amount = used.getAmount(); + if (amount <= 1) { + player.getInventory().setItem(slot, new ItemStack(Material.AIR)); + } else { + used.setAmount(amount - 1); + } + } + + private static void giveMilk(Player player, EquipmentSlot slot, ItemStack milk) { + PlayerInventory inv = player.getInventory(); + ItemStack current = inv.getItem(slot); + if (current == null || current.getType().isAir()) { + inv.setItem(slot, milk); + return; + } + ItemStack leftover = CookingFoodApi.addToInventory(player, milk); + if (leftover != null && leftover.getAmount() > 0 && player.getWorld() != null) { + player.getWorld().dropItemNaturally(player.getLocation(), leftover); + } + } + + @EventHandler(ignoreCancelled = true) + public void onDropEgg(EntityDropItemEvent event) { + if (!(event.getEntity() instanceof Chicken chicken)) { + return; + } + ItemStack stack = event.getItemDrop().getItemStack(); + if (!HusbandryEggs.isVanillaEgg(stack.getType())) { + return; + } + HusbandryRepository repository = HusbandryEntities.repository(); + if (repository == null) { + return; + } + if (HusbandryEntities.getLoaded(chicken.getUniqueId()).isEmpty() + && !repository.exists(chicken.getUniqueId())) { + return; + } + HusbandrySpecies species = HusbandryConfig.species(chicken.getType()); + if (species != null && species.hasEgg()) { + event.setCancelled(true); + } + } +} diff --git a/src/main/java/net/tfminecraft/breedingbuddies/husbandry/HusbandryIdentity.java b/src/main/java/net/tfminecraft/breedingbuddies/husbandry/HusbandryIdentity.java new file mode 100644 index 0000000..e54b053 --- /dev/null +++ b/src/main/java/net/tfminecraft/breedingbuddies/husbandry/HusbandryIdentity.java @@ -0,0 +1,56 @@ +package net.tfminecraft.breedingbuddies.husbandry; + +import java.util.Optional; +import java.util.UUID; +import org.bukkit.entity.Player; +import org.bukkit.plugin.Plugin; +import net.tfminecraft.tlibs.character.CharacterRef; +import net.tfminecraft.tlibs.character.CharacterSession; +import net.tfminecraft.tlibs.character.Characters; + +/** New heir and estate entry points are linked only after a capability probe. */ +public final class HusbandryIdentity { + private HusbandryIdentity() {} + + public static Optional session(Player player) { + return player == null ? Optional.empty() : Characters.currentSession(player).filter(session -> current(session, player)); + } + + public static boolean current(CharacterSession session, Player player) { + return session != null && player != null && session.ref().ownerId().equals(player.getUniqueId()) + && Characters.isCurrent(session); + } + + public static String name(CharacterRef ref) { + return Characters.info(ref.characterId()).filter(info -> info.ref().equals(ref)) + .map(info -> info.name()).orElse(ref.characterId().toString()); + } + + public static boolean supports(ClassLoader loader, boolean estates) { + try { + Class characters = Class.forName("net.tfminecraft.tlibs.character.Characters", false, loader); + characters.getMethod("legacyHeirsReady"); + characters.getMethod("legacyHeir", UUID.class); + if (estates) { + for (String name : new String[]{"Estates", "EstateParticipant", "EstateDecision", "Outcome", "Holdings"}) { + Class.forName("net.tfminecraft.tlibs.character.estate." + name, false, loader); + } + } + return true; + } catch (ReflectiveOperationException | LinkageError ex) { + return false; + } + } + + public static void start(Plugin plugin, HusbandryRepository repository) { + if (repository == null) return; + ClassLoader loader = HusbandryIdentity.class.getClassLoader(); + if (!supports(loader, false)) { + plugin.getLogger().warning("Reviewed animal heirs require TLibs 2.6.0; old owner slots remain held."); + return; + } + HusbandryMigration.start(plugin, repository); + if (supports(loader, true)) HusbandryEstate.start(plugin, repository); + else plugin.getLogger().warning("TLibs has no estate API; animal slots stay with dead characters."); + } +} diff --git a/src/main/java/net/tfminecraft/breedingbuddies/husbandry/HusbandryInspectGui.java b/src/main/java/net/tfminecraft/breedingbuddies/husbandry/HusbandryInspectGui.java new file mode 100644 index 0000000..343f0bc --- /dev/null +++ b/src/main/java/net/tfminecraft/breedingbuddies/husbandry/HusbandryInspectGui.java @@ -0,0 +1,234 @@ +package net.tfminecraft.breedingbuddies.husbandry; + +import java.util.ArrayList; +import java.util.List; +import java.util.UUID; + +import org.bukkit.Bukkit; +import org.bukkit.Material; +import org.bukkit.entity.EntityType; +import org.bukkit.entity.LivingEntity; +import org.bukkit.entity.Player; +import org.bukkit.inventory.Inventory; +import org.bukkit.inventory.ItemFlag; +import org.bukkit.inventory.ItemStack; +import org.bukkit.inventory.meta.ItemMeta; + +import net.kyori.adventure.text.Component; +import net.kyori.adventure.text.format.NamedTextColor; +import net.kyori.adventure.text.format.TextColor; +import net.kyori.adventure.text.format.TextDecoration; +import net.tfminecraft.tlibs.utils.TimeFormatter; + +/** + * The animal menu, laid out like the original BreedingBuddies one: a header row, a row of trust hearts, + * a row of genetics and, for mounts, a row of stats. The last slot lets an owner give the animal up. + */ +public final class HusbandryInspectGui { + + private static final long MILLIS_PER_HOUR = 3_600_000L; + static final int OWNERS_SLOT = 0; + static final int NAME_SLOT = 3; + static final int PRODUCTS_SLOT = 4; + static final int STATUS_SLOT = 5; + static final int NEUTERED_SLOT = 8; + static final int TRUST_START = 11; + static final int GENETICS_START = 20; + static final int HEALTH_SLOT = 30; + static final int SPEED_SLOT = 31; + static final int JUMP_SLOT = 32; + static final int SEGMENTS = 5; + private static final TextColor GOLD = TextColor.color(0xF2C14E); + private static final TextColor TRUST = TextColor.color(0xFF5577); + private static final TextColor GENES = TextColor.color(0x7ED957); + + private HusbandryInspectGui() {} + + /** The owner's give-up button sits in the last slot of the menu. */ + static int removeSlot(Inventory inventory) { + return inventory.getSize() - 1; + } + + public static void open(Player player, LivingEntity entity, HusbandryAnimal animal) { + if (player == null || entity == null || animal == null) { + return; + } + boolean mount = HusbandryMounts.isMount(entity); + HusbandryInspectHolder holder = new HusbandryInspectHolder(animal.uuid(), HusbandryIdentity.session(player).orElse(null)); + String name = displayName(animal, entity.getType()); + Inventory inv = Bukkit.createInventory(holder, mount ? 36 : 27, Component.text(name, GOLD)); + holder.setInventory(inv); + + inv.setItem(OWNERS_SLOT, ownersItem(animal.uuid())); + inv.setItem(NAME_SLOT, item(Material.NAME_TAG, Component.text(name, GOLD), + line(HusbandryEntities.displayName(entity.getType()), NamedTextColor.GRAY), + line(growingLine(animal), NamedTextColor.GRAY))); + ItemStack products = productsItem(entity.getType(), HusbandryGuiBars.yieldPercent(animal)); + if (products != null) { + inv.setItem(PRODUCTS_SLOT, products); + } + inv.setItem(STATUS_SLOT, statusItem(animal, mount)); + inv.setItem(NEUTERED_SLOT, item(Material.SHEARS, + Component.text(animal.neutered() ? "Neutered" : "Not neutered", + animal.neutered() ? NamedTextColor.RED : NamedTextColor.GREEN), + line(animal.neutered() ? "It cannot breed." : "It can breed when grown and happy.", NamedTextColor.GRAY))); + + int careMax = HusbandryConfig.careMax(); + int hearts = HusbandryTrust.filledHearts(animal.care()); + for (int i = 0; i < SEGMENTS; i++) { + inv.setItem(TRUST_START + i, item(i < hearts ? Material.RED_DYE : Material.GRAY_DYE, + Component.text("Trust ", TRUST).append(HusbandryTrust.hearts(animal.care())), + line(animal.care() + "/" + careMax, NamedTextColor.GRAY), + line(mount ? "Feed, brush and ride it each day." : "Feed, brush and pet it each day.", + NamedTextColor.DARK_GRAY))); + } + + int maxGenetics = HusbandryConfig.maxGenetics(); + int filled = HusbandryGuiBars.filledSegments(animal.genetics(), maxGenetics); + Component stars = stars(HusbandryConfig.starsForGenetics(animal.genetics())); + for (int i = 0; i < SEGMENTS; i++) { + inv.setItem(GENETICS_START + i, item(i < filled ? Material.SLIME_SPAWN_EGG : Material.VEX_SPAWN_EGG, + Component.text("Genetics ", GENES).append(stars), + line(animal.genetics() + "/" + maxGenetics, NamedTextColor.GRAY), + line("Inherited from its parents.", NamedTextColor.DARK_GRAY))); + } + + if (mount) { + inv.setItem(HEALTH_SLOT, statItem(Material.GOLDEN_APPLE, "Health", + String.format("%.1f hearts", HusbandryMounts.healthHearts(entity)), animal, HusbandryMountGenes.Stat.HEALTH)); + inv.setItem(SPEED_SLOT, statItem(Material.FEATHER, "Speed", + String.format("%.2f blocks/s", HusbandryMounts.speedBlocksPerSecond(entity)), animal, HusbandryMountGenes.Stat.SPEED)); + inv.setItem(JUMP_SLOT, statItem(Material.LEATHER_BOOTS, "Jump", + String.format("%.2f blocks", HusbandryMounts.jumpBlockHeight(entity)), animal, HusbandryMountGenes.Stat.JUMP)); + } + + if (HusbandryOwnershipService.isOwner(player, animal.uuid())) { + inv.setItem(removeSlot(inv), removeItem(false)); + } + player.openInventory(inv); + } + + /** The give-up button; once clicked it asks for a second click before anything happens. */ + static ItemStack removeItem(boolean confirming) { + return confirming + ? item(Material.WRITABLE_BOOK, Component.text("Click again to give it up", NamedTextColor.RED), + line("It will no longer be yours.", NamedTextColor.GRAY)) + : item(Material.WRITABLE_BOOK, Component.text("Remove ownership", NamedTextColor.DARK_RED), + line("Stop owning this animal.", NamedTextColor.GRAY)); + } + + static String displayName(HusbandryAnimal animal, EntityType type) { + String name = animal.name(); + if (name == null || name.isBlank() || "???".equals(name.trim())) { + return HusbandryEntities.displayName(type); + } + return name; + } + + private static ItemStack statItem(Material material, String label, String value, HusbandryAnimal animal, + HusbandryMountGenes.Stat stat) { + return item(material, Component.text(label + " ", GOLD).append(Component.text(value, NamedTextColor.WHITE)), + line("Gene " + HusbandryMountGenes.gene(animal, stat) + "/" + HusbandryConfig.maxGenetics(), + NamedTextColor.GRAY)); + } + + private static ItemStack productsItem(EntityType type, int yieldPct) { + HusbandrySpecies species = HusbandryConfig.species(type); + if (species == null || !HusbandryProducts.hasProducts(species)) { + return null; + } + List lore = new ArrayList<>(); + lore.add(line("Yield " + yieldPct + "%", NamedTextColor.GREEN)); + for (String mode : HusbandryProducts.modeLines(species)) { + lore.add(line(mode, NamedTextColor.GRAY)); + } + lore.add(line("Genetics set the quality, trust the amount.", NamedTextColor.DARK_GRAY)); + return item(Material.CHEST, Component.text("Products", GOLD), lore.toArray(Component[]::new)); + } + + private static String growingLine(HusbandryAnimal animal) { + if (HusbandryGrowth.isMature(animal, System.currentTimeMillis())) { + return "Grown up"; + } + long remaining = Math.max(0L, animal.matureAt() - System.currentTimeMillis()); + return "Grows up in " + TimeFormatter.formatTime((int) (remaining / 1000L)); + } + + private static ItemStack statusItem(HusbandryAnimal animal, boolean mount) { + boolean hungry = animal.hungrySince() != null; + boolean dirty = animal.dirtySince() != null; + if (!hungry && !dirty) { + return item(Material.TOTEM_OF_UNDYING, Component.text("Happy", NamedTextColor.GREEN), + line(mount ? "A ride a day keeps its trust growing." : "A pet a day keeps its trust growing.", + NamedTextColor.GRAY)); + } + List lore = new ArrayList<>(); + if (hungry) { + lore.add(line("Hungry: feed it Universal Feed.", NamedTextColor.GOLD)); + } + if (dirty) { + lore.add(line("Dirty: brush it with a Glove.", NamedTextColor.YELLOW)); + } + lore.add(decayLine(animal)); + return item(Material.ROTTEN_FLESH, Component.text("Needs care", NamedTextColor.RED), lore.toArray(Component[]::new)); + } + + private static Component decayLine(HusbandryAnimal animal) { + Long hungry = animal.hungrySince(); + Long dirty = animal.dirtySince(); + long earliest = hungry == null ? dirty : (dirty == null ? hungry : Math.min(hungry, dirty)); + long decayStart = earliest + HusbandryConfig.decayGraceSeconds() * 1000L; + long now = System.currentTimeMillis(); + if (now < decayStart) { + double hours = (decayStart - now) / (double) MILLIS_PER_HOUR; + return line("It starts losing trust in " + String.format("%.1f", Math.max(0, hours)) + "h.", NamedTextColor.YELLOW); + } + return line("Losing trust: -" + trim(HusbandryConfig.careDownAmount()) + " every " + + TimeFormatter.formatTime(HusbandryConfig.careDownIntervalSeconds()) + ".", NamedTextColor.RED); + } + + private static ItemStack ownersItem(UUID animalUuid) { + List lore = new ArrayList<>(); + for (HusbandryOwner owner : HusbandryOwnershipService.listOwners(animalUuid)) { + lore.add(line(owner.role() + ": " + HusbandryIdentity.name(owner.ref()), NamedTextColor.GRAY)); + } + HusbandryRepository repository = HusbandryEntities.repository(); + if (repository != null && repository.hasHeldOwner(animalUuid)) { + lore.add(line("Unresolved owner slot (staff review)", NamedTextColor.GRAY)); + } + if (lore.isEmpty()) { + lore.add(line("Nobody yet: tame it with a named token.", NamedTextColor.GRAY)); + } + return item(Material.PLAYER_HEAD, Component.text("Owners", NamedTextColor.YELLOW), lore.toArray(Component[]::new)); + } + + private static Component stars(int stars) { + int clamped = Math.max(0, Math.min(5, stars)); + return Component.text("★".repeat(clamped), GOLD).append(Component.text("★".repeat(5 - clamped), NamedTextColor.DARK_GRAY)); + } + + private static String trim(double value) { + if (value == (long) value) { + return String.valueOf((long) value); + } + return String.format("%.1f", value); + } + + private static Component line(String text, TextColor color) { + return Component.text(text, color); + } + + private static ItemStack item(Material material, Component name, Component... lore) { + ItemStack stack = new ItemStack(material); + ItemMeta meta = stack.getItemMeta(); + meta.displayName(name.decoration(TextDecoration.ITALIC, false)); + List lines = new ArrayList<>(); + for (Component component : lore) { + lines.add(component.decoration(TextDecoration.ITALIC, false)); + } + meta.lore(lines); + meta.addItemFlags(ItemFlag.values()); + stack.setItemMeta(meta); + return stack; + } +} diff --git a/src/main/java/net/tfminecraft/breedingbuddies/husbandry/HusbandryInspectHolder.java b/src/main/java/net/tfminecraft/breedingbuddies/husbandry/HusbandryInspectHolder.java new file mode 100644 index 0000000..2b9ca73 --- /dev/null +++ b/src/main/java/net/tfminecraft/breedingbuddies/husbandry/HusbandryInspectHolder.java @@ -0,0 +1,41 @@ +package net.tfminecraft.breedingbuddies.husbandry; + +import java.util.UUID; + +import org.bukkit.inventory.Inventory; +import org.bukkit.inventory.InventoryHolder; + +public final class HusbandryInspectHolder implements InventoryHolder { + + private final UUID animalUuid; + private Inventory inventory; + private final net.tfminecraft.tlibs.character.CharacterSession session; + private long confirmUntil; + + public HusbandryInspectHolder(UUID animalUuid, net.tfminecraft.tlibs.character.CharacterSession session) { + this.animalUuid = animalUuid; + this.session = session; + } + + public boolean isCurrent(org.bukkit.entity.Player player) { return HusbandryIdentity.current(session, player); } + + public boolean belongsTo(net.tfminecraft.tlibs.character.CharacterSession outgoing) { return outgoing.equals(session); } + + /** Giving an animal up takes a second click within five seconds. */ + boolean confirming(long nowMillis) { return nowMillis < confirmUntil; } + + void askToConfirm(long nowMillis) { confirmUntil = nowMillis + 5_000L; } + + public UUID animalUuid() { + return animalUuid; + } + + public void setInventory(Inventory inventory) { + this.inventory = inventory; + } + + @Override + public Inventory getInventory() { + return inventory; + } +} diff --git a/src/main/java/net/tfminecraft/breedingbuddies/husbandry/HusbandryInspectListener.java b/src/main/java/net/tfminecraft/breedingbuddies/husbandry/HusbandryInspectListener.java new file mode 100644 index 0000000..c78786f --- /dev/null +++ b/src/main/java/net/tfminecraft/breedingbuddies/husbandry/HusbandryInspectListener.java @@ -0,0 +1,134 @@ +package net.tfminecraft.breedingbuddies.husbandry; + +import java.util.Optional; + +import org.bukkit.Material; +import org.bukkit.entity.Entity; +import org.bukkit.entity.LivingEntity; +import org.bukkit.entity.Player; +import org.bukkit.event.EventHandler; +import org.bukkit.event.EventPriority; +import org.bukkit.event.Listener; +import org.bukkit.event.inventory.InventoryClickEvent; +import org.bukkit.event.player.PlayerInteractEntityEvent; +import org.bukkit.inventory.EquipmentSlot; +import org.bukkit.inventory.InventoryHolder; +import org.bukkit.inventory.ItemStack; + +public final class HusbandryInspectListener implements Listener { + + @EventHandler(priority = EventPriority.HIGH) + public void onInteract(PlayerInteractEntityEvent event) { + if (event.getHand() != EquipmentSlot.HAND) { + return; + } + Player player = event.getPlayer(); + ItemStack hand = player.getInventory().getItemInMainHand(); + Entity clicked = event.getRightClicked(); + if (!(clicked instanceof LivingEntity living)) { + return; + } + HusbandryRepository repository = HusbandryEntities.repository(); + if (repository == null) { + return; + } + HusbandryMounts.enrollIfNeeded(living); + Optional stored = HusbandryEntities.lookup(living.getUniqueId()); + if (stored.isEmpty()) { + return; + } + + if (!wantsInspect(player, hand)) { + return; + } + if (isBlockedItem(hand)) { + return; + } + openInspect(player, living, stored.get(), event); + } + + private static void openInspect( + Player player, + LivingEntity living, + HusbandryAnimal animal, + PlayerInteractEntityEvent event) { + event.setCancelled(true); + if (!canView(player, animal)) { + player.sendMessage("§cThis is not your animal."); + return; + } + HusbandryMounts.applyStats(living, animal); + HusbandryInspectGui.open(player, living, animal); + } + + private static boolean canView(Player player, HusbandryAnimal animal) { + return HusbandryOwnershipService.isOwner(player, animal.uuid()) + || HusbandryOwnershipService.isStaff(player) + || !HusbandryOwnershipService.hasAnyOwner(animal.uuid()); + } + + @EventHandler + public void onClick(InventoryClickEvent event) { + InventoryHolder holder = event.getInventory().getHolder(); + if (!(holder instanceof HusbandryInspectHolder inspect)) { + return; + } + event.setCancelled(true); + if (event.getClickedInventory() != event.getInventory() || event.getRawSlot() != HusbandryInspectGui.removeSlot(event.getInventory())) { + return; + } + if (!(event.getWhoClicked() instanceof Player player)) { + return; + } + if (!inspect.isCurrent(player)) { + player.closeInventory(); + return; + } + ItemStack clicked = event.getCurrentItem(); + if (clicked == null || clicked.getType() != Material.WRITABLE_BOOK) { + return; + } + long now = System.currentTimeMillis(); + if (!inspect.confirming(now)) { + inspect.askToConfirm(now); + event.getInventory().setItem(event.getRawSlot(), HusbandryInspectGui.removeItem(true)); + return; + } + HusbandryRepository repository = HusbandryEntities.repository(); + if (repository == null) { + return; + } + Optional stored = HusbandryEntities.lookup(inspect.animalUuid()); + if (stored.isEmpty()) { + player.closeInventory(); + return; + } + HusbandryOwnershipService.Result result = HusbandryOwnershipService.removeSelf(player, stored.get()); + player.closeInventory(); + if (result == HusbandryOwnershipService.Result.OK) { + player.sendMessage("§aYou no longer own this animal."); + } else { + player.sendMessage("§cCould not remove ownership."); + } + } + + private static boolean wantsInspect(Player player, ItemStack hand) { + boolean sneakEmpty = player.isSneaking() && (hand == null || hand.getType().isAir()); + boolean inspectItem = HusbandryItems.matches(hand, HusbandryConfig.inspectItem()); + return sneakEmpty || inspectItem; + } + + private static boolean isBlockedItem(ItemStack hand) { + if (hand == null || hand.getType().isAir()) { + return false; + } + if (hand.getType() == Material.BUCKET) { + return true; + } + return HusbandryItems.matches(hand, HusbandryConfig.feedItem()) + || HusbandryItems.matches(hand, HusbandryConfig.gloveItem()) + || HusbandryItems.matches(hand, HusbandryConfig.tameItem()) + || HusbandryItems.matches(hand, HusbandryConfig.coOwnItem()) + || HusbandryItems.matches(hand, HusbandryConfig.neuterItem()); + } +} diff --git a/src/main/java/net/tfminecraft/breedingbuddies/husbandry/HusbandryItems.java b/src/main/java/net/tfminecraft/breedingbuddies/husbandry/HusbandryItems.java new file mode 100644 index 0000000..2bc9173 --- /dev/null +++ b/src/main/java/net/tfminecraft/breedingbuddies/husbandry/HusbandryItems.java @@ -0,0 +1,112 @@ +package net.tfminecraft.breedingbuddies.husbandry; + +import org.bukkit.GameMode; +import org.bukkit.entity.Player; +import org.bukkit.inventory.ItemStack; +import org.bukkit.inventory.meta.Damageable; +import org.bukkit.inventory.meta.ItemMeta; +import org.bukkit.persistence.PersistentDataType; + +import net.tfminecraft.tlibs.TLibs; + +public final class HusbandryItems { + + private HusbandryItems() {} + + public static boolean matches(ItemStack stack, String path) { + if (stack == null || path == null || path.isBlank()) { + return false; + } + return TLibs.getItemAPI().getChecker().checkItemWithPath(stack, path); + } + + /** Applies one use to the player's main-hand item and writes it back to the inventory. */ + public static void useFromMainHand(Player player) { + if (player == null) { + return; + } + if (player.getGameMode() == GameMode.CREATIVE || player.getGameMode() == GameMode.SPECTATOR) { + return; + } + ItemStack hand = player.getInventory().getItemInMainHand(); + player.getInventory().setItemInMainHand(applyUse(hand)); + } + + public static void consumeOne(ItemStack stack) { + if (stack == null || stack.getAmount() <= 0) { + return; + } + stack.setAmount(stack.getAmount() - 1); + } + + static ItemStack applyUse(ItemStack stack) { + if (stack == null || stack.getType().isAir()) { + return stack; + } + if (stack.getItemMeta() instanceof Damageable damageable + && stack.getType().getMaxDurability() > 0 + && !stack.getItemMeta().isUnbreakable()) { + int nextDamage = damageable.getDamage() + 1; + if (nextDamage >= stack.getType().getMaxDurability()) { + return null; + } + damageable.setDamage(nextDamage); + stack.setItemMeta(damageable); + return stack; + } + if (stack.getAmount() <= 1) { + return null; + } + stack.setAmount(stack.getAmount() - 1); + return stack; + } + + public static String tameName(ItemStack stack) { + if (stack == null || !stack.hasItemMeta()) { + return null; + } + String stored = HusbandryKeys.read(stack.getItemMeta().getPersistentDataContainer(), + HusbandryKeys.TAME_NAME, PersistentDataType.STRING); + if (stored == null || stored.isBlank() || "???".equals(stored.trim())) { + return null; + } + return stored.trim(); + } + + public static void setTameName(ItemStack stack, String name) { + if (stack == null || name == null || name.isBlank()) { + return; + } + ItemMeta meta = stack.getItemMeta(); + if (meta == null) { + return; + } + meta.getPersistentDataContainer().set(HusbandryKeys.TAME_NAME, PersistentDataType.STRING, name.trim()); + stack.setItemMeta(meta); + } + + public static String linkedAnimal(ItemStack stack) { + if (stack == null || !stack.hasItemMeta()) { + return null; + } + return HusbandryKeys.read(stack.getItemMeta().getPersistentDataContainer(), + HusbandryKeys.LINKED_ANIMAL, PersistentDataType.STRING); + } + + // Keep the existing legacy text representation, formatting, and exact-string comparisons. + @SuppressWarnings("deprecation") + public static void setLinkedAnimal(ItemStack stack, String uuid, String loreLine) { + if (stack == null || uuid == null) { + return; + } + ItemMeta meta = stack.getItemMeta(); + if (meta == null) { + return; + } + meta.getPersistentDataContainer().set(HusbandryKeys.LINKED_ANIMAL, PersistentDataType.STRING, uuid); + if (loreLine != null && !loreLine.isBlank()) { + meta.setLore(java.util.List.of(loreLine)); + } + stack.setItemMeta(meta); + } +} diff --git a/src/main/java/net/tfminecraft/breedingbuddies/husbandry/HusbandryKeys.java b/src/main/java/net/tfminecraft/breedingbuddies/husbandry/HusbandryKeys.java new file mode 100644 index 0000000..d5c4980 --- /dev/null +++ b/src/main/java/net/tfminecraft/breedingbuddies/husbandry/HusbandryKeys.java @@ -0,0 +1,33 @@ +package net.tfminecraft.breedingbuddies.husbandry; + +import org.bukkit.NamespacedKey; +import org.bukkit.persistence.PersistentDataContainer; +import org.bukkit.persistence.PersistentDataType; + +import net.tfminecraft.breedingbuddies.BreedingBuddies; + +public final class HusbandryKeys { + + public static final NamespacedKey MANAGED = + new NamespacedKey(BreedingBuddies.plugin, "managed"); + + public static final NamespacedKey TAME_NAME = + new NamespacedKey(BreedingBuddies.plugin, "tame_name"); + + public static final NamespacedKey LINKED_ANIMAL = + new NamespacedKey(BreedingBuddies.plugin, "linked_animal"); + + /** CompanionPets marks its pets' bodies with their pet ID; husbandry never runs them. */ + static final NamespacedKey COMPANION_PET = new NamespacedKey("companionpets", "pet"); + + /** Cooking ran livestock before BreedingBuddies; its entities and tokens keep these keys. */ + static final String COOKING = "cooking"; + + private HusbandryKeys() {} + + /** Reads a BreedingBuddies key, or the same key Cooking wrote when the value predates the takeover. */ + static T read(PersistentDataContainer data, NamespacedKey key, PersistentDataType type) { + T value = data.get(key, type); + return value != null ? value : data.get(new NamespacedKey(COOKING, key.getKey()), type); + } +} diff --git a/src/main/java/net/tfminecraft/breedingbuddies/husbandry/HusbandryLifecycleListener.java b/src/main/java/net/tfminecraft/breedingbuddies/husbandry/HusbandryLifecycleListener.java new file mode 100644 index 0000000..7e4b396 --- /dev/null +++ b/src/main/java/net/tfminecraft/breedingbuddies/husbandry/HusbandryLifecycleListener.java @@ -0,0 +1,222 @@ +package net.tfminecraft.breedingbuddies.husbandry; + +import java.util.ArrayList; +import java.util.HashMap; +import java.util.List; +import java.util.Map; +import java.util.Optional; +import java.util.UUID; +import java.util.concurrent.ThreadLocalRandom; + +import org.bukkit.Bukkit; +import org.bukkit.World; +import org.bukkit.entity.Entity; +import org.bukkit.entity.LivingEntity; +import org.bukkit.event.EventHandler; +import org.bukkit.event.EventPriority; +import org.bukkit.event.Listener; +import org.bukkit.event.entity.EntityRemoveEvent; +import org.bukkit.event.world.EntitiesLoadEvent; +import org.bukkit.event.world.EntitiesUnloadEvent; + +import net.tfminecraft.tlibs.database.SqliteDatabaseException; + +public final class HusbandryLifecycleListener implements Listener { + + @EventHandler + public void onEntitiesLoad(EntitiesLoadEvent event) { + for (Entity entity : event.getEntities()) { + handleLoad(entity); + } + } + + @EventHandler + public void onEntitiesUnload(EntitiesUnloadEvent event) { + for (Entity entity : event.getEntities()) { + handleUnload(entity); + } + } + + @EventHandler(priority = EventPriority.MONITOR) + public void onEntityRemove(EntityRemoveEvent event) { + handleRemove(event.getEntity().getUniqueId(), event.getCause()); + } + + static void handleRemove(UUID uuid, EntityRemoveEvent.Cause cause) { + if (HusbandrySnapshots.keepsSnapshot(cause) || HusbandryEntities.getLoaded(uuid).isEmpty()) { + return; + } + HusbandryRepository repository = HusbandryEntities.repository(); + if (repository == null) { + return; + } + try { + repository.deleteSnapshot(uuid); + } catch (SqliteDatabaseException ex) { + Bukkit.getLogger().severe("[BreedingBuddies] Failed to drop the snapshot of " + uuid + ": " + ex.getMessage()); + } + } + + public static void applyStatsRevision() { + HusbandryRepository repository = HusbandryEntities.repository(); + if (repository == null) { + return; + } + String revision = HusbandryConfig.statsRevision(); + int count; + try { + count = repository.resetStaleStats(revision, ThreadLocalRandom.current()); + } catch (SqliteDatabaseException ex) { + Bukkit.getLogger().severe("[BreedingBuddies] Failed to reset husbandry stats: " + ex.getMessage()); + return; + } + if (count > 0) { + Bukkit.getLogger().info( + "[BreedingBuddies] Reset " + count + " husbandry animals to wild stats (revision " + revision + ")."); + } + refreshLoadedAfterReset(repository); + } + + private static void refreshLoadedAfterReset(HusbandryRepository repository) { + List loaded = new ArrayList<>(HusbandryEntities.loadedIds()); + if (loaded.isEmpty()) { + return; + } + Map livingById = new HashMap<>(); + for (World world : Bukkit.getWorlds()) { + for (Entity entity : world.getEntities()) { + if (entity instanceof LivingEntity living) { + livingById.put(living.getUniqueId(), living); + } + } + } + for (UUID uuid : loaded) { + Optional stored = repository.getAnimal(uuid); + if (stored.isEmpty()) { + HusbandryEntities.evict(uuid); + continue; + } + HusbandryAnimal animal = stored.get(); + HusbandryEntities.putLoaded(animal); + LivingEntity living = livingById.get(uuid); + if (living != null) { + HusbandryMounts.applyStats(living, animal); + } + } + } + + public static void resumeLoadedWorlds() { + for (World world : Bukkit.getWorlds()) { + for (Entity entity : world.getEntities()) { + handleLoad(entity); + } + } + } + + public static void flushLoadedForDisable() { + HusbandryRepository repository = HusbandryEntities.repository(); + List snapshot = HusbandryEntities.snapshotLoaded(); + HusbandryEntities.clearLoaded(); + if (repository == null || snapshot.isEmpty()) { + return; + } + long now = System.currentTimeMillis(); + List toSave = new ArrayList<>(snapshot.size()); + Map copies = new HashMap<>(); + for (HusbandryAnimal animal : snapshot) { + Entity entity = Bukkit.getEntity(animal.uuid()); + if (entity != null) { + HusbandryLocation.remember(animal, entity); + if (HusbandrySnapshots.shouldCapture(animal)) { + byte[] copy = HusbandrySnapshots.capture(entity, false); + if (copy != null) { + copies.put(animal.uuid(), copy); + } + } + } + animal.setUnloadedAt(now); + toSave.add(animal); + } + try { + repository.upsertAnimals(toSave); + } catch (SqliteDatabaseException ex) { + Bukkit.getLogger().severe("[BreedingBuddies] Failed to flush husbandry animals on disable: " + ex.getMessage()); + } + HusbandrySnapshots.save(repository, copies); + } + + static void handleLoad(Entity entity) { + if (!(entity instanceof LivingEntity living)) { + return; + } + // CompanionPets owns these bodies, including when it has not enabled yet at startup. + if (HusbandryEntities.isCompanionPet(entity)) { + return; + } + HusbandryRepository repository = HusbandryEntities.repository(); + if (repository == null) { + return; + } + UUID uuid = entity.getUniqueId(); + Optional stored = repository.getAnimal(uuid); + boolean hasRow = stored.isPresent(); + + if (HusbandryMounts.shouldWipeUnowned( + HusbandryConfig.isRemoveUnowned(entity.getType()), + HusbandryOwnershipService.hasAnyOwner(uuid), + hasRow, + HusbandryMounts.hasConfiguredStats(entity))) { + if (hasRow) { + repository.deleteAnimal(uuid); + } + HusbandryEntities.evict(uuid); + entity.remove(); + return; + } + if (!hasRow) { + return; + } + + HusbandryEntities.applyPersistFlags(living); + HusbandryEntities.stampManaged(living); + + HusbandryAnimal animal = stored.get(); + long now = System.currentTimeMillis(); + HusbandryLocation.remember(animal, living); + HusbandryLocator.markFound(uuid); + HusbandrySimulator.catchUp(animal, now, java.util.concurrent.ThreadLocalRandom.current()); + HusbandryGrowth.applyMaturity(living, animal, now); + HusbandryMounts.applyStats(living, animal); + animal.setUnloadedAt(null); + animal.setLoadedVisitStart(now); + HusbandryEntities.putLoaded(animal); + repository.upsertAnimal(animal); + HusbandryStateDisplay.sync(living, animal); + } + + static void handleUnload(Entity entity) { + if (!(entity instanceof LivingEntity)) { + return; + } + UUID uuid = entity.getUniqueId(); + HusbandryRepository repository = HusbandryEntities.repository(); + Optional stored = HusbandryEntities.getLoaded(uuid); + if (stored.isEmpty() && repository != null) { + stored = repository.getAnimal(uuid); + } + HusbandryEntities.evict(uuid); + if (repository == null || stored.isEmpty()) { + return; + } + HusbandryAnimal animal = stored.get(); + HusbandryLocation.remember(animal, entity); + animal.setUnloadedAt(System.currentTimeMillis()); + repository.upsertAnimal(animal); + if (HusbandrySnapshots.shouldCapture(animal)) { + byte[] copy = HusbandrySnapshots.capture(entity, true); + if (copy != null) { + HusbandrySnapshots.save(repository, Map.of(uuid, copy)); + } + } + } +} diff --git a/src/main/java/net/tfminecraft/breedingbuddies/husbandry/HusbandryLoader.java b/src/main/java/net/tfminecraft/breedingbuddies/husbandry/HusbandryLoader.java new file mode 100644 index 0000000..deea1aa --- /dev/null +++ b/src/main/java/net/tfminecraft/breedingbuddies/husbandry/HusbandryLoader.java @@ -0,0 +1,338 @@ +package net.tfminecraft.breedingbuddies.husbandry; + +import java.io.File; +import java.io.IOException; +import java.util.ArrayList; +import java.util.Comparator; +import java.util.EnumMap; +import java.util.EnumSet; +import java.util.List; +import java.util.Locale; +import java.util.Map; +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; +import org.bukkit.entity.EntityType; + +import net.tfminecraft.tlibs.utils.TimeFormatter; + +public final class HusbandryLoader { + + public void load(File file) { + FileConfiguration config = new YamlConfiguration(); + try { + config.load(file); + } catch (IOException | InvalidConfigurationException ex) { + ex.printStackTrace(); + applyDefaults(); + return; + } + + HusbandryConfig.apply( + config.getInt("max-animals", 15), + config.getInt("care-max", 200), + HusbandryDuration.parseCareIntervalSeconds(config, "care.down", "care-down-per-hour", 3600), + HusbandryDuration.parseCareAmount(config, "care.down", "care-down-per-hour", 1), + HusbandryDuration.parseSeconds(config, "decay-grace", "decay-grace-hours", 3600, 86400), + HusbandryDuration.parseSeconds(config, "long-unload-force", "long-unload-force-hours", 3600, 28800), + HusbandryDuration.parseSeconds(config, "min-loaded", "min-loaded-seconds", 1, 60), + HusbandryDuration.parseAfflictionHours(config, "mean", "affliction-mean-hours", 6), + HusbandryDuration.parseAfflictionHours(config, "min", "affliction-min-hours", 4), + HusbandryDuration.parseAfflictionHours(config, "max", "affliction-max-hours", 8), + parseMilkTimerSeconds(config), + config.getInt("initial-genetic-max", 20), + config.getInt("max-genetics", 1000), + config.getInt("min-roast-cuts", 1), + HusbandryDuration.parseSeconds(config, "wool-timer", "wool-timer-hours", 3600, 28800), + HusbandryDuration.parseSeconds(config, "grow-up", null, 1, 3600), + HusbandryDuration.parseSeconds(config, "shed-timer", null, 1, 28800), + config.getDouble("shed-chance", 0.15), + HusbandryDuration.parseSeconds(config, "egg-timer", null, 1, 3600), + config.getString("items.tame", ""), + config.getString("items.co-own", ""), + config.getString("items.feed", ""), + config.getString("items.glove", ""), + config.getString("items.neuter", ""), + config.getString("items.inspect", ""), + config.getString("items.mount-stats", ""), + parseEntityTypes(config.getStringList("remove-unowned")), + parseSpecies(config.getConfigurationSection("species")), + config.getBoolean("damage.other-players", true), + config.getBoolean("damage.owner", true), + config.getBoolean("damage.mobs", true), + config.getBoolean("damage.environment", true), + parseQualityBands(config.getMapList("quality-from-genetics")), + parseMounts(config.getConfigurationSection("mounts")), + config.getDouble("breeding.genetic-variance-multiplier", 1), + config.getDouble("breeding.genetic-slowdown-divisor", 1), + parseAmountBands(config.getMapList("amount-from-genetics"))); + HusbandryConfig.setMountShares( + mountShare(config, "min-pct", 0.40), + mountShare(config, "genetics-pct", 0.30), + mountShare(config, "care-pct", 0.30)); + HusbandryConfig.setBreeding( + config.getDouble("breeding.genetic-variance-multiplier", 0.4), + config.getDouble("breeding.genetic-slowdown-divisor", 0.4), + config.getDouble("breeding.care-influence", 0.02)); + HusbandryConfig.setStatsRevision(config.getString("stats-revision", "1")); + HusbandryConfig.setRestoreLostAnimals(config.getBoolean("restore-lost-animals", true)); + HusbandryConfig.setTrust( + config.getInt("trust.feed", 10), + config.getInt("trust.glove", 10), + config.getInt("trust.pet", 5), + HusbandryDuration.parseSeconds(config, "trust.pet-cooldown", null, 1, 72000)); + HusbandryConfig.setProfessionExp( + config.getString("profession", "farming"), + parseExpBracket(config.getConfigurationSection("exp"), 6, 8)); + } + + private static Set parseEntityTypes(List raw) { + Set types = EnumSet.noneOf(EntityType.class); + for (String entry : raw) { + EntityType type = parseEntityType(entry); + if (type != null) { + types.add(type); + } + } + return types; + } + + private static Map parseSpecies(ConfigurationSection section) { + Map species = new EnumMap<>(EntityType.class); + if (section == null) { + return species; + } + for (String key : section.getKeys(false)) { + EntityType type = parseEntityType(key); + if (type == null) { + continue; + } + ConfigurationSection slaughter = section.getConfigurationSection(key + ".slaughter"); + ConfigurationSection shear = section.getConfigurationSection(key + ".shear"); + ConfigurationSection shed = section.getConfigurationSection(key + ".shed"); + species.put(type, new HusbandrySpecies( + type, + section.getBoolean(key + ".milk", false), + slaughter != null ? slaughter.getString("meat", "") : "", + slaughter != null + ? parseDropTable(slaughter.getConfigurationSection("drops")) + : HusbandryDropTable.empty(), + shear != null + ? parseDropTable(shear.getConfigurationSection("drops")) + : HusbandryDropTable.empty(), + shed != null + ? parseDropTable(shed.getConfigurationSection("drops")) + : HusbandryDropTable.empty(), + section.getString(key + ".egg", ""), + parseSpeciesGrowUp(section, key), + parseSpeciesDuration(section, key, "wool-timer"), + parseSpeciesDuration(section, key, "milk-timer"), + parseOptionalExp(section.getConfigurationSection(key + ".exp")))); + } + return species; + } + + private static HusbandryDropTable parseDropTable(ConfigurationSection section) { + if (section == null) { + return HusbandryDropTable.empty(); + } + return new HusbandryDropTable( + parseDropEntries(section.getMapList("common")), + parseDropEntries(section.getMapList("rare")), + parseDropEntries(section.getMapList("epic")), + parseDropEntries(section.getMapList("legendary")), + "counted".equalsIgnoreCase(section.getString("mode", ""))); + } + + private static List parseDropEntries(List> raw) { + List entries = new ArrayList<>(); + for (Map entry : raw) { + Object pathRaw = entry.get("path"); + String path = pathRaw == null ? "" : String.valueOf(pathRaw).trim(); + int weight = intValue(entry.get("weight"), 1); + if (weight <= 0) { + continue; + } + entries.add(new HusbandryDropEntry( + path, + intValue(entry.get("amount"), 1), + weight)); + } + return entries; + } + + private static int parseSpeciesGrowUp(ConfigurationSection section, String key) { + return parseSpeciesDuration(section, key, "grow-up"); + } + + private static int parseMilkTimerSeconds(FileConfiguration config) { + if (config.contains("milk-timer")) { + return TimeFormatter.parseSeconds(config.getString("milk-timer")); + } + return HusbandryDuration.parseSeconds(config, "milk-cooldown", "milk-cooldown-minutes", 60, 1200); + } + + private static int parseSpeciesDuration(ConfigurationSection section, String key, String field) { + if (section == null || !section.contains(key + "." + field)) { + return 0; + } + return Math.max(1, TimeFormatter.parseSeconds(section.getString(key + "." + field))); + } + + private static List parseQualityBands(List> raw) { + List bands = new ArrayList<>(); + for (Map entry : raw) { + int min = intValue(entry.get("min"), 0); + int stars = intValue(entry.get("stars"), 1); + bands.add(new HusbandryQualityBand(min, Math.max(1, Math.min(5, stars)))); + } + bands.sort(Comparator.comparingInt(HusbandryQualityBand::minGenetics)); + return bands; + } + + private static List parseAmountBands(List> raw) { + List bands = new ArrayList<>(); + for (Map entry : raw) { + bands.add(new HusbandryAmountBand( + intValue(entry.get("min"), 0), + Math.max(1, intValue(entry.get("roast-cuts"), 1)), + Math.max(1, intValue(entry.get("wool"), 1)), + intValue(entry.get("secondary-extra"), 0))); + } + bands.sort(Comparator.comparingInt(HusbandryAmountBand::minGenetics)); + return bands; + } + + /** Mount stat shares live in mounts.shares; Cooking named them mounts.speed although they apply to every stat. */ + private static double mountShare(FileConfiguration config, String key, double fallback) { + if (config.contains("mounts.shares." + key)) { + return config.getDouble("mounts.shares." + key); + } + if (config.contains("mounts.speed." + key)) { + Bukkit.getLogger().warning("[BreedingBuddies] config.yml uses mounts.speed." + key + + "; rename it to mounts.shares." + key + ", which applies to health, speed and jump."); + return config.getDouble("mounts.speed." + key); + } + return fallback; + } + + private static Map parseMounts(ConfigurationSection section) { + Map mounts = new EnumMap<>(EntityType.class); + if (section == null) { + return mounts; + } + for (String key : section.getKeys(false)) { + if ("shares".equalsIgnoreCase(key) + || "speed".equalsIgnoreCase(key) + || "nerf".equalsIgnoreCase(key) + || "nerf-divisor".equalsIgnoreCase(key)) { + continue; + } + EntityType type = parseEntityType(key); + ConfigurationSection stats = section.getConfigurationSection(key); + if (type == null || stats == null) { + continue; + } + mounts.put(type, new HusbandryMountStats( + type, + stats.getDouble("min-health", 15), + stats.getDouble("max-health", 30), + stats.getDouble("min-speed", 0.1125), + stats.getDouble("max-speed", 0.3375), + stats.getDouble("min-jump", 0.4), + stats.getDouble("max-jump", 1.0))); + } + return mounts; + } + + private static EntityType parseEntityType(String raw) { + if (raw == null || raw.isBlank()) { + return null; + } + try { + return EntityType.valueOf(raw.trim().toUpperCase(Locale.ROOT)); + } catch (IllegalArgumentException ex) { + Bukkit.getLogger().warning("[BreedingBuddies] Invalid husbandry entity type: " + raw); + return null; + } + } + + private static int intValue(Object raw, int fallback) { + if (raw instanceof Number number) { + return number.intValue(); + } + if (raw != null) { + try { + return Integer.parseInt(String.valueOf(raw)); + } catch (NumberFormatException ignored) { + Bukkit.getLogger().warning("[BreedingBuddies] Invalid husbandry number: " + raw); + } + } + return fallback; + } + + private static HusbandryExpBracket parseExpBracket(ConfigurationSection section, int defaultMin, int defaultMax) { + if (section == null) { + return HusbandryExpBracket.of(defaultMin, defaultMax); + } + return HusbandryExpBracket.of(section.getInt("min", defaultMin), section.getInt("max", defaultMax)); + } + + private static HusbandryExpBracket parseOptionalExp(ConfigurationSection section) { + if (section == null) { + return null; + } + return HusbandryExpBracket.of(section.getInt("min", 6), section.getInt("max", 8)); + } + + private static void applyDefaults() { + HusbandryConfig.apply( + 15, + 200, + 3600, + 1, + 86400, + 28800, + 60, + 6, + 4, + 8, + 1200, + 20, + 1000, + 1, + 28800, + 3600, + 28800, + 0.15, + 3600, + "", + "", + "", + "", + "", + "", + "", + Set.of(), + Map.of(), + true, + true, + true, + true, + List.of(new HusbandryQualityBand(0, 1)), + Map.of(), + 1, + 1, + List.of(new HusbandryAmountBand(0, 1, 1))); + HusbandryConfig.setMountShares(0.40, 0.30, 0.30); + HusbandryConfig.setBreeding(0.4, 0.4, 0.02); + HusbandryConfig.setStatsRevision("1"); + HusbandryConfig.setRestoreLostAnimals(true); + HusbandryConfig.setTrust(10, 10, 5, 72000); + HusbandryConfig.setProfessionExp("farming", HusbandryExpBracket.of(6, 8)); + } +} diff --git a/src/main/java/net/tfminecraft/breedingbuddies/husbandry/HusbandryLocation.java b/src/main/java/net/tfminecraft/breedingbuddies/husbandry/HusbandryLocation.java new file mode 100644 index 0000000..89bd0a1 --- /dev/null +++ b/src/main/java/net/tfminecraft/breedingbuddies/husbandry/HusbandryLocation.java @@ -0,0 +1,22 @@ +package net.tfminecraft.breedingbuddies.husbandry; + +import org.bukkit.Location; +import org.bukkit.World; +import org.bukkit.entity.Entity; + +public final class HusbandryLocation { + + private HusbandryLocation() {} + + public static void remember(HusbandryAnimal animal, Entity entity) { + if (animal == null || entity == null) { + return; + } + World world = entity.getWorld(); + if (world == null) { + return; + } + Location location = entity.getLocation(); + animal.setLastLocation(world.getName(), location.getBlockX(), location.getBlockY(), location.getBlockZ()); + } +} diff --git a/src/main/java/net/tfminecraft/breedingbuddies/husbandry/HusbandryLocator.java b/src/main/java/net/tfminecraft/breedingbuddies/husbandry/HusbandryLocator.java new file mode 100644 index 0000000..29396b6 --- /dev/null +++ b/src/main/java/net/tfminecraft/breedingbuddies/husbandry/HusbandryLocator.java @@ -0,0 +1,238 @@ +package net.tfminecraft.breedingbuddies.husbandry; + +import java.io.File; +import java.util.ArrayList; +import java.util.Collection; +import java.util.HashMap; +import java.util.List; +import java.util.Map; +import java.util.Objects; +import java.util.Optional; +import java.util.Set; +import java.util.UUID; +import java.util.concurrent.ConcurrentHashMap; +import java.util.stream.Collectors; + +import org.bukkit.Bukkit; +import org.bukkit.World; + +import net.tfminecraft.breedingbuddies.BreedingBuddies; +import net.tfminecraft.tlibs.database.SqliteDatabaseException; + +/** + * Finds owned animals sitting in unloaded chunks by reading the saved entity chunks, + * so {@code /animals} can point at them. An owned animal that is not in any saved chunk + * of a fully scanned world, nor under a logged-out rider, was lost from the save: it is + * respawned from its snapshot, or, without one, its row is deleted as a ghost and logged. + */ +public final class HusbandryLocator { + + private static final Set MISSING = ConcurrentHashMap.newKeySet(); + + private HusbandryLocator() {} + + public static boolean isMissing(UUID uuid) { + return uuid != null && MISSING.contains(uuid); + } + + static void markFound(UUID uuid) { + if (uuid != null) { + MISSING.remove(uuid); + } + } + + static void markMissing(UUID uuid) { + if (uuid != null) { + MISSING.add(uuid); + } + } + + /** Scans once in the background. Call on the main thread after loaded worlds have resumed. */ + public static void scanUnloaded() { + HusbandryRepository repository = HusbandryEntities.repository(); + if (repository == null || BreedingBuddies.plugin == null) { + return; + } + Map targets = new HashMap<>(); + for (HusbandryAnimal animal : repository.listOwnedAnimals()) { + if (HusbandryEntities.getLoaded(animal.uuid()).isEmpty()) { + targets.put(animal.uuid(), animal.unloadedAt()); + } + } + if (targets.isEmpty()) { + return; + } + List worlds = new ArrayList<>(); + for (World world : Bukkit.getWorlds()) { + worlds.add(new HusbandryEntityScan.WorldDir(world.getName(), entitiesFolder(world))); + } + File playerdata = new File(Bukkit.getWorlds().get(0).getWorldFolder(), "playerdata"); + Bukkit.getScheduler().runTaskAsynchronously(BreedingBuddies.plugin, () -> { + HusbandryEntityScan.Result result = HusbandryEntityScan.scan(worlds, playerdata, targets.keySet()); + if (BreedingBuddies.plugin != null && BreedingBuddies.plugin.isEnabled()) { + Bukkit.getScheduler().runTask(BreedingBuddies.plugin, () -> apply(targets, result, worlds)); + } + }); + } + + private static File entitiesFolder(World world) { + File folder = world.getWorldFolder(); + return switch (world.getEnvironment()) { + case NETHER -> new File(folder, "DIM-1/entities"); + case THE_END -> new File(folder, "DIM1/entities"); + default -> new File(folder, "entities"); + }; + } + + private static void apply( + Map targets, + HusbandryEntityScan.Result result, + List worlds) { + HusbandryRepository repository = HusbandryEntities.repository(); + if (repository == null) { + return; + } + int located = 0; + int restoring = 0; + int dropped = 0; + int missing = 0; + int held = 0; + List scannedWorlds = worlds.stream().map(HusbandryEntityScan.WorldDir::world).toList(); + for (Map.Entry target : targets.entrySet()) { + UUID uuid = target.getKey(); + if (HusbandryEntities.getLoaded(uuid).isPresent()) { + continue; + } + Optional stored = repository.getAnimal(uuid); + if (stored.isEmpty() || !Objects.equals(stored.get().unloadedAt(), target.getValue())) { + // Loaded or unloaded again during the scan; the stored location is newer. + continue; + } + if (result.ridden().contains(uuid)) { + // Saved with a rider who logged out; it comes back when they join. + MISSING.remove(uuid); + continue; + } + HusbandryEntityScan.Found found = result.found().get(uuid); + if (found == null) { + HusbandryAnimal animal = stored.get(); + // An animal last seen in a world that was not scanned may still be there. + if (!isConfirmedGhost(result.complete(), animal.world(), scannedWorlds)) { + continue; + } + try { + HusbandrySnapshots.RestoreResult restoration = + HusbandrySnapshots.restore(repository, animal, repository.listOwners(uuid)); + if (restoration == HusbandrySnapshots.RestoreResult.SCHEDULED) { + MISSING.remove(uuid); + restoring++; + continue; + } + if (restoration == HusbandrySnapshots.RestoreResult.FAILED) { + MISSING.add(uuid); + missing++; + continue; + } + } catch (SqliteDatabaseException ex) { + Bukkit.getLogger().severe("[BreedingBuddies] Failed to read the snapshot of " + uuid + + ": " + ex.getMessage()); + MISSING.add(uuid); + missing++; + continue; + } + try { + String line = deleteGhost(repository, animal); + if (line == null) { + held++; + continue; + } + Bukkit.getLogger().warning(line); + dropped++; + } catch (SqliteDatabaseException ex) { + Bukkit.getLogger().severe("[BreedingBuddies] Failed to drop ghost animal " + uuid + + ": " + ex.getMessage()); + MISSING.add(uuid); + missing++; + } + continue; + } + MISSING.remove(uuid); + if (sameLocation(stored.get(), found)) { + continue; + } + HusbandryAnimal animal = stored.get(); + animal.setLastLocation(found.world(), found.x(), found.y(), found.z()); + try { + repository.upsertAnimal(animal); + located++; + } catch (SqliteDatabaseException ex) { + Bukkit.getLogger().severe("[BreedingBuddies] Failed to save husbandry animal location: " + ex.getMessage()); + } + } + if (located > 0 || restoring > 0 || dropped > 0 || missing > 0 || held > 0 || !result.complete()) { + String failed = missing > 0 + ? ", " + missing + " still marked missing after errors" + : ""; + Bukkit.getLogger().info("[BreedingBuddies] Animal scan: updated " + located + " locations, restoring " + + restoring + " lost animals from snapshots, dropped " + dropped + " ghost animals" + failed + + (held > 0 ? ", held " + held + " missing animal records for staff review" : "") + + (result.complete() ? "." : " (some chunks could not be read, none dropped).")); + } + } + + /** + * A ghost is an owned animal absent from every saved chunk after a complete scan of its world. + * An incomplete scan, or a last world that was not scanned, is not enough to drop the row. + */ + static boolean isConfirmedGhost(boolean scanComplete, String storedWorld, Collection scannedWorlds) { + if (!scanComplete) { + return false; + } + if (storedWorld == null || storedWorld.isBlank()) { + return true; + } + return scannedWorlds != null && scannedWorlds.contains(storedWorld); + } + + /** Deletes the animal and its owners, or returns null without mutation for an estate hold. */ + static String deleteGhost(HusbandryRepository repository, HusbandryAnimal animal) { + if (repository.isEstateHeld(animal.uuid())) { + MISSING.add(animal.uuid()); + return null; + } + List owners = repository.listOwners(animal.uuid()); + // Foreign-key cascades remove account owners, character owners and snapshots together. + repository.deleteAnimal(animal.uuid()); + HusbandryEntities.evict(animal.uuid()); + MISSING.remove(animal.uuid()); + return ghostLog(animal, owners); + } + + static String ghostLog(HusbandryAnimal animal, List owners) { + String place = animal.hasLocation() + ? animal.world() + " " + animal.x() + ", " + animal.y() + ", " + animal.z() + : "unknown"; + return "[BreedingBuddies] Dropped ghost animal " + describe(animal, owners) + " last seen " + place; + } + + /** Name, type, uuid and owners, as the ghost and restore log lines print them. */ + static String describe(HusbandryAnimal animal, List owners) { + String name = animal.name() == null || animal.name().isBlank() ? "(unnamed)" : animal.name(); + String type = animal.type() == null || animal.type().isBlank() ? "unknown" : animal.type(); + String ownerText = owners == null || owners.isEmpty() + ? "none" + : owners.stream() + .sorted((left, right) -> left.playerUuid().compareTo(right.playerUuid())) + .map(owner -> owner.playerUuid() + " (" + owner.role() + ")") + .collect(Collectors.joining(", ")); + return name + " (" + type + ") " + animal.uuid() + " owners=" + ownerText; + } + + private static boolean sameLocation(HusbandryAnimal animal, HusbandryEntityScan.Found found) { + return animal.hasLocation() + && found.world().equals(animal.world()) + && found.x() == animal.x() + && found.y() == animal.y() + && found.z() == animal.z(); + } +} diff --git a/src/main/java/net/tfminecraft/breedingbuddies/husbandry/HusbandryMigration.java b/src/main/java/net/tfminecraft/breedingbuddies/husbandry/HusbandryMigration.java new file mode 100644 index 0000000..cf24101 --- /dev/null +++ b/src/main/java/net/tfminecraft/breedingbuddies/husbandry/HusbandryMigration.java @@ -0,0 +1,46 @@ +package net.tfminecraft.breedingbuddies.husbandry; + +import org.bukkit.plugin.Plugin; +import net.tfminecraft.tlibs.character.Characters; + +/** Kept out of the old-API classloading path. Every pass uses the same archived plan. */ +final class HusbandryMigration { + private int quietMinutes; + private String lastProblem; + private HusbandryMigration() {} + static void start(Plugin plugin, HusbandryRepository repository) { + HusbandryMigration migration = new HusbandryMigration(); + plugin.getServer().getScheduler().runTaskTimer(plugin, () -> { + if (migration.quietMinutes > 0) { + migration.quietMinutes--; + return; + } + if (migration.attempt(plugin, repository)) migration.quietMinutes = 59; + }, 1, 20L * 60); + } + static void reconcile(Plugin plugin, HusbandryRepository repository) { + new HusbandryMigration().attempt(plugin, repository); + } + private boolean attempt(Plugin plugin, HusbandryRepository repository) { + try { + if (!repository.migrateOwners(Characters::legacyHeirsReady, Characters::legacyHeir)) { + warnChanged(plugin, "Animal ownership waits for reviewed heirs; legacy slots remain held."); + return false; + } + int held = repository.heldOwners(); + if (held != 0) { + plugin.getLogger().warning("Animal ownership holds " + held + + " legacy slots for staff; see husbandry_owner_plan in Data/husbandry.db."); + } + lastProblem = null; + return true; + } catch (RuntimeException ex) { + warnChanged(plugin, "Animal ownership migration rolled back; retrying: " + ex.getMessage()); + return false; + } + } + private void warnChanged(Plugin plugin, String message) { + if (!message.equals(lastProblem)) plugin.getLogger().warning(message); + lastProblem = message; + } +} diff --git a/src/main/java/net/tfminecraft/breedingbuddies/husbandry/HusbandryMountGenes.java b/src/main/java/net/tfminecraft/breedingbuddies/husbandry/HusbandryMountGenes.java new file mode 100644 index 0000000..da42994 --- /dev/null +++ b/src/main/java/net/tfminecraft/breedingbuddies/husbandry/HusbandryMountGenes.java @@ -0,0 +1,75 @@ +package net.tfminecraft.breedingbuddies.husbandry; + +import java.util.Random; + +/** + * Mounts carry one gene per stat, so a foal can take its speed from one parent and its jump from the + * other. The animal's genetics is their average, which drives stars and the menu. Records from before + * per-stat genes use that genetics for all three until they are bred or reset. + */ +public final class HusbandryMountGenes { + + public enum Stat { HEALTH, SPEED, JUMP } + + private HusbandryMountGenes() {} + + public static int gene(HusbandryAnimal animal, Stat stat) { + Integer value = switch (stat) { + case HEALTH -> animal.geneHealth(); + case SPEED -> animal.geneSpeed(); + case JUMP -> animal.geneJump(); + }; + return value == null ? animal.genetics() : value; + } + + public static void set(HusbandryAnimal animal, int health, int speed, int jump) { + int max = HusbandryConfig.maxGenetics(); + int h = clamp(health, max), s = clamp(speed, max), j = clamp(jump, max); + animal.setMountGenes(h, s, j); + animal.setGenetics((int) Math.round((h + s + j) / 3.0)); + } + + /** Wild and spawn-egg mounts roll each stat on its own, like other wild genetics. */ + public static void rollWild(HusbandryAnimal animal, Random random) { + int max = HusbandryConfig.initialGeneticMax(); + set(animal, roll(max, random), roll(max, random), roll(max, random)); + } + + /** + * Each stat is inherited on its own. Parents' trust and the breeding food's stars open the room above + * the better parent; without them a foal cannot beat the better parent. + */ + public static void inherit(HusbandryAnimal foal, HusbandryAnimal mother, HusbandryAnimal father, + double feedScale, Random random) { + int careMax = HusbandryConfig.careMax(); + double trust = careMax <= 0 ? 0 : Math.max(0, Math.min(1, (mother.care() + father.care()) / 2.0 / careMax)); + double room = trust * HusbandryFeedQuality.clampScale(feedScale); + set(foal, + inheritStat(gene(mother, Stat.HEALTH), gene(father, Stat.HEALTH), room, random), + inheritStat(gene(mother, Stat.SPEED), gene(father, Stat.SPEED), room, random), + inheritStat(gene(mother, Stat.JUMP), gene(father, Stat.JUMP), room, random)); + } + + /** + * BreedingBuddies' inheritance: a uniform roll from a little below the weaker parent up to a quarter of the + * headroom above the better one, scaled by room. Very different parents always lower that ceiling. + */ + static int inheritStat(int mother, int father, double room, Random random) { + double max = Math.max(1, HusbandryConfig.maxGenetics()); + double low = Math.min(mother, father) / max; + double high = Math.max(mother, father) / max; + double offset = Math.pow(high - low, 3) * 3.5; + double top = high + ((1 - high) * room - offset) / 4.0; + double bottom = low - (1 - low) / 5.5; + double result = Math.min(top, bottom) + random.nextDouble() * Math.abs(top - bottom); + return clamp((int) Math.round(result * max), (int) max); + } + + private static int roll(int max, Random random) { + return max <= 0 ? 0 : random.nextInt(max + 1); + } + + private static int clamp(int value, int max) { + return Math.max(0, Math.min(max, value)); + } +} diff --git a/src/main/java/net/tfminecraft/breedingbuddies/husbandry/HusbandryMountListener.java b/src/main/java/net/tfminecraft/breedingbuddies/husbandry/HusbandryMountListener.java new file mode 100644 index 0000000..1d1c658 --- /dev/null +++ b/src/main/java/net/tfminecraft/breedingbuddies/husbandry/HusbandryMountListener.java @@ -0,0 +1,123 @@ +package net.tfminecraft.breedingbuddies.husbandry; + +import org.bukkit.entity.Entity; +import org.bukkit.entity.LivingEntity; +import org.bukkit.entity.Player; +import org.bukkit.event.EventHandler; +import org.bukkit.event.EventPriority; +import org.bukkit.event.Listener; +import org.bukkit.event.entity.EntityMountEvent; +import org.bukkit.event.entity.EntityTameEvent; +import org.bukkit.event.player.PlayerQuitEvent; + +public final class HusbandryMountListener implements Listener { + + @EventHandler + public void onMount(EntityMountEvent event) { + Entity mount = event.getMount(); + if (!HusbandryMounts.isMount(mount)) { + return; + } + if (!(event.getEntity() instanceof Player player)) { + return; + } + if (HusbandryEntities.repository() == null || HusbandryIdentity.session(player).isEmpty()) { + event.setCancelled(true); + player.sendMessage("§cYour character's animal records are not ready."); + return; + } + if (mount instanceof LivingEntity living) { + HusbandryAnimal animal = HusbandryMounts.enrollIfNeeded(living); + if (animal != null) { + HusbandryMounts.applyStats(living, animal); + } + } + if (!HusbandryOwnershipService.hasAnyOwner(mount.getUniqueId())) { + HusbandryClaimHint.remind(player, mount); + return; + } + if (HusbandryOwnershipService.isOwner(player, mount.getUniqueId())) { + return; + } + if (HusbandryOwnershipService.isStaff(player)) { + return; + } + event.setCancelled(true); + player.sendMessage("§cThis is not your animal."); + } + + /** Reward only after other plugins have had their chance to cancel the ride. */ + @EventHandler(priority = EventPriority.MONITOR, ignoreCancelled = true) + public void onRide(EntityMountEvent event) { + if (!(event.getEntity() instanceof Player player) + || !(event.getMount() instanceof LivingEntity living) + || !HusbandryMounts.isMount(living) + || !HusbandryOwnershipService.isOwner(player, living.getUniqueId())) { + return; + } + rideCountsAsPet(player, living); + } + + /** Time in the saddle is a mount's daily pet; the right-click that mounts it never reaches petting. */ + private static void rideCountsAsPet(Player player, LivingEntity living) { + HusbandryAnimal animal = HusbandryEntities.lookup(living.getUniqueId()).orElse(null); + long now = System.currentTimeMillis(); + if (animal == null || !HusbandryTrust.canPet(animal, now)) { + return; + } + HusbandryTrust.show(player, living, animal, HusbandryTrust.reward(animal, HusbandryTrust.Gesture.PET, now)); + HusbandryEntities.repository().upsertAnimal(animal); + } + + @EventHandler + public void onCharacterEnd(net.tfminecraft.tlibs.character.CharacterSessionEndEvent event) { + // The event's session is already noncurrent; clean up the outgoing body directly. + Player player = event.getPlayer(); + if (!event.getSession().ref().ownerId().equals(player.getUniqueId())) return; + Entity vehicle = player.getVehicle(); + if (vehicle != null && HusbandryMounts.isMount(vehicle)) player.leaveVehicle(); + org.bukkit.inventory.Inventory open = player.getOpenInventory().getTopInventory(); + if (open != null && open.getHolder() instanceof HusbandryInspectHolder holder + && holder.belongsTo(event.getSession())) player.closeInventory(); + HusbandryClaimHint.forget(player.getUniqueId()); + } + + @EventHandler + public void onCharacterStart(net.tfminecraft.tlibs.character.CharacterSessionStartEvent event) { + if (!HusbandryIdentity.current(event.getSession(), event.getPlayer())) return; + Entity vehicle = event.getPlayer().getVehicle(); + if (vehicle != null && HusbandryOwnershipService.hasAnyOwner(vehicle.getUniqueId()) + && !HusbandryOwnershipService.isOwner(event.getPlayer(), vehicle.getUniqueId()) + && !HusbandryOwnershipService.isStaff(event.getPlayer())) event.getPlayer().leaveVehicle(); + } + + /** Vanilla sitting, feeding and mount-inventory interactions cannot use an account's tame owner. */ + @EventHandler(priority = EventPriority.HIGH, ignoreCancelled = true) + public void protectInteract(org.bukkit.event.player.PlayerInteractEntityEvent event) { + if (event.getRightClicked() instanceof org.bukkit.entity.Tameable + && HusbandryOwnershipService.hasAnyOwner(event.getRightClicked().getUniqueId()) + && !HusbandryOwnershipService.isOwner(event.getPlayer(), event.getRightClicked().getUniqueId()) + && !HusbandryOwnershipService.isStaff(event.getPlayer())) event.setCancelled(true); + } + + @EventHandler(priority = EventPriority.HIGH, ignoreCancelled = true) + public void protectTame(EntityTameEvent event) { + if (event.getOwner() instanceof Player player + && HusbandryOwnershipService.hasAnyOwner(event.getEntity().getUniqueId()) + && !HusbandryOwnershipService.isOwner(player, event.getEntity().getUniqueId()) + && !HusbandryOwnershipService.isStaff(player)) event.setCancelled(true); + } + + @EventHandler(priority = EventPriority.MONITOR, ignoreCancelled = true) + public void onTame(EntityTameEvent event) { + if (event.getOwner() instanceof Player player) { + // The event fires before the animal is tamed, so it cannot report itself as tamed yet. + HusbandryClaimHint.send(player, event.getEntity(), false); + } + } + + @EventHandler + public void onQuit(PlayerQuitEvent event) { + HusbandryClaimHint.forget(event.getPlayer().getUniqueId()); + } +} diff --git a/src/main/java/net/tfminecraft/breedingbuddies/husbandry/HusbandryMountStats.java b/src/main/java/net/tfminecraft/breedingbuddies/husbandry/HusbandryMountStats.java new file mode 100644 index 0000000..6127dc4 --- /dev/null +++ b/src/main/java/net/tfminecraft/breedingbuddies/husbandry/HusbandryMountStats.java @@ -0,0 +1,59 @@ +package net.tfminecraft.breedingbuddies.husbandry; + +import org.bukkit.entity.EntityType; + +public final class HusbandryMountStats { + + private final EntityType type; + private final double minHealth; + private final double maxHealth; + private final double minSpeed; + private final double maxSpeed; + private final double minJump; + private final double maxJump; + + public HusbandryMountStats( + EntityType type, + double minHealth, + double maxHealth, + double minSpeed, + double maxSpeed, + double minJump, + double maxJump) { + this.type = type; + this.minHealth = minHealth; + this.maxHealth = maxHealth; + this.minSpeed = minSpeed; + this.maxSpeed = maxSpeed; + this.minJump = minJump; + this.maxJump = maxJump; + } + + public EntityType type() { + return type; + } + + public double minHealth() { + return minHealth; + } + + public double maxHealth() { + return maxHealth; + } + + public double minSpeed() { + return minSpeed; + } + + public double maxSpeed() { + return maxSpeed; + } + + public double minJump() { + return minJump; + } + + public double maxJump() { + return maxJump; + } +} diff --git a/src/main/java/net/tfminecraft/breedingbuddies/husbandry/HusbandryMounts.java b/src/main/java/net/tfminecraft/breedingbuddies/husbandry/HusbandryMounts.java new file mode 100644 index 0000000..c02ba32 --- /dev/null +++ b/src/main/java/net/tfminecraft/breedingbuddies/husbandry/HusbandryMounts.java @@ -0,0 +1,205 @@ +package net.tfminecraft.breedingbuddies.husbandry; + +import java.util.Optional; + +import org.bukkit.attribute.Attribute; +import org.bukkit.attribute.AttributeInstance; +import org.bukkit.entity.AbstractHorse; +import org.bukkit.entity.Entity; +import org.bukkit.entity.EntityType; +import org.bukkit.entity.LivingEntity; + +public final class HusbandryMounts { + + private HusbandryMounts() {} + + public static boolean isMount(Entity entity) { + return entity instanceof AbstractHorse; + } + + public static boolean hasConfiguredStats(Entity entity) { + return entity != null && HusbandryConfig.mountStats(entity.getType()) != null; + } + + public static boolean shouldWipeUnowned( + boolean removeUnownedType, boolean hasOwner, boolean hasRow, boolean configuredMount) { + return removeUnownedType && !hasOwner && !(hasRow && configuredMount); + } + + public static boolean shouldCreateEnrollRow(boolean configuredMount, boolean hasRow) { + return configuredMount && !hasRow; + } + + public static HusbandryAnimal enrollIfNeeded(LivingEntity entity) { + if (entity == null || !hasConfiguredStats(entity)) { + return null; + } + HusbandryRepository repository = HusbandryEntities.repository(); + if (repository == null) { + return null; + } + Optional loaded = HusbandryEntities.getLoaded(entity.getUniqueId()); + boolean hasRow = loaded.isPresent() || repository.exists(entity.getUniqueId()); + if (!shouldCreateEnrollRow(true, hasRow)) { + return loaded.orElseGet(() -> repository.getAnimal(entity.getUniqueId()).orElse(null)); + } + return HusbandrySpawner.createWildRecord(entity); + } + + public static void setMaxHealth(LivingEntity entity, double health) { + AttributeInstance attr = entity.getAttribute(Attribute.MAX_HEALTH); + if (attr == null) { + return; + } + attr.setBaseValue(health); + entity.setHealth(Math.min(health, entity.getHealth())); + } + + public static void setSpeed(LivingEntity entity, double speed) { + AttributeInstance attr = entity.getAttribute(Attribute.MOVEMENT_SPEED); + if (attr == null) { + return; + } + if (Math.abs(attr.getBaseValue() - speed) < 0.0000001) { + return; + } + attr.setBaseValue(speed); + } + + public static double mountFraction( + int genetics, + int care, + int maxGenetics, + int careMax, + double minPct, + double geneticsPct, + double carePct) { + double min = Math.max(0, minPct); + double gShare = Math.max(0, geneticsPct); + double cShare = Math.max(0, carePct); + double g = maxGenetics <= 0 ? 0 : Math.max(0, Math.min(1, genetics / (double) maxGenetics)); + double c = careMax <= 0 ? 0 : Math.max(0, Math.min(1, care / (double) careMax)); + return min + gShare * g + cShare * c; + } + + public static double scaledStat(double max, double min, double fraction) { + return Math.max(min, max * fraction); + } + + public static double speedFor( + double maxSpeed, + int genetics, + int care, + int maxGenetics, + int careMax, + double minPct, + double geneticsPct, + double carePct) { + return maxSpeed * mountFraction( + genetics, care, maxGenetics, careMax, minPct, geneticsPct, carePct); + } + + public static double speedFor(HusbandryAnimal animal, EntityType type) { + HusbandryMountStats stats = statsFor(animal, type); + if (stats == null) { + return -1; + } + return scaledStat(stats.maxSpeed(), stats.minSpeed(), fractionFor(animal, HusbandryMountGenes.Stat.SPEED)); + } + + public static double healthFor(HusbandryAnimal animal, EntityType type) { + HusbandryMountStats stats = statsFor(animal, type); + if (stats == null) { + return -1; + } + return scaledStat(stats.maxHealth(), stats.minHealth(), fractionFor(animal, HusbandryMountGenes.Stat.HEALTH)); + } + + public static double jumpFor(HusbandryAnimal animal, EntityType type) { + HusbandryMountStats stats = statsFor(animal, type); + if (stats == null) { + return -1; + } + return scaledStat(stats.maxJump(), stats.minJump(), fractionFor(animal, HusbandryMountGenes.Stat.JUMP)); + } + + public static void applyStats(LivingEntity entity, HusbandryAnimal animal) { + if (entity == null || animal == null || !isMount(entity)) { + return; + } + EntityType type = entity.getType(); + double health = healthFor(animal, type); + if (health > 0) { + setMaxHealth(entity, health); + } + double speed = speedFor(animal, type); + if (speed > 0) { + setSpeed(entity, speed); + } + double jump = jumpFor(animal, type); + if (jump >= 0) { + setJump(entity, jump); + } + } + + public static void setJump(Entity entity, double jump) { + if (entity instanceof AbstractHorse horse) { + horse.setJumpStrength(jump); + } + } + + public static double maxHealth(LivingEntity entity) { + AttributeInstance attr = entity.getAttribute(Attribute.MAX_HEALTH); + return attr != null ? attr.getBaseValue() : -1; + } + + public static double speed(LivingEntity entity) { + AttributeInstance attr = entity.getAttribute(Attribute.MOVEMENT_SPEED); + return attr != null ? attr.getBaseValue() : -1; + } + + public static double jump(Entity entity) { + if (entity instanceof AbstractHorse horse) { + return horse.getJumpStrength(); + } + return -1; + } + + public static double healthHearts(LivingEntity entity) { + return maxHealth(entity) / 2.0; + } + + public static double speedBlocksPerSecond(LivingEntity entity) { + return speed(entity) * 42.16; + } + + public static double jumpBlockHeight(Entity entity) { + double strength = jump(entity); + if (strength < 0) { + return -1; + } + return -0.1817584952 * Math.pow(strength, 3) + + 3.689713992 * Math.pow(strength, 2) + + 2.128599134 * strength + - 0.343930367; + } + + private static HusbandryMountStats statsFor(HusbandryAnimal animal, EntityType type) { + if (animal == null || type == null) { + return null; + } + return HusbandryConfig.mountStats(type); + } + + /** Each stat follows its own gene; trust adds the configured share on top. */ + private static double fractionFor(HusbandryAnimal animal, HusbandryMountGenes.Stat stat) { + return mountFraction( + HusbandryMountGenes.gene(animal, stat), + animal.care(), + HusbandryConfig.maxGenetics(), + HusbandryConfig.careMax(), + HusbandryConfig.mountMinPct(), + HusbandryConfig.mountGeneticsPct(), + HusbandryConfig.mountCarePct()); + } +} diff --git a/src/main/java/net/tfminecraft/breedingbuddies/husbandry/HusbandryNeuterListener.java b/src/main/java/net/tfminecraft/breedingbuddies/husbandry/HusbandryNeuterListener.java new file mode 100644 index 0000000..eceec7d --- /dev/null +++ b/src/main/java/net/tfminecraft/breedingbuddies/husbandry/HusbandryNeuterListener.java @@ -0,0 +1,56 @@ +package net.tfminecraft.breedingbuddies.husbandry; + +import java.util.Optional; + +import org.bukkit.entity.Entity; +import org.bukkit.entity.LivingEntity; +import org.bukkit.entity.Player; +import org.bukkit.event.EventHandler; +import org.bukkit.event.Listener; +import org.bukkit.event.player.PlayerInteractEntityEvent; +import org.bukkit.inventory.EquipmentSlot; +import org.bukkit.inventory.ItemStack; + +public final class HusbandryNeuterListener implements Listener { + + @EventHandler + public void onInteract(PlayerInteractEntityEvent event) { + if (event.getHand() != EquipmentSlot.HAND) { + return; + } + Player player = event.getPlayer(); + ItemStack hand = player.getInventory().getItemInMainHand(); + if (!HusbandryItems.matches(hand, HusbandryConfig.neuterItem())) { + return; + } + Entity clicked = event.getRightClicked(); + if (!(clicked instanceof LivingEntity living)) { + return; + } + event.setCancelled(true); + HusbandryRepository repository = HusbandryEntities.repository(); + if (repository == null) { + return; + } + Optional stored = HusbandryEntities.lookup(living.getUniqueId()); + if (stored.isEmpty()) { + player.sendMessage("§cThis animal cannot be neutered."); + return; + } + if (!HusbandryOwnershipService.isOwner(player, living.getUniqueId()) + && !HusbandryOwnershipService.isStaff(player)) { + player.sendMessage("§cThis is not your animal."); + return; + } + HusbandryAnimal animal = stored.get(); + if (animal.neutered()) { + player.sendMessage("§eThis animal is already neutered."); + return; + } + animal.setNeutered(true); + repository.upsertAnimal(animal); + HusbandryItems.useFromMainHand(player); + HusbandryFx.playNeuter(living); + player.sendMessage("§aNeutered."); + } +} diff --git a/src/main/java/net/tfminecraft/breedingbuddies/husbandry/HusbandryOwned.java b/src/main/java/net/tfminecraft/breedingbuddies/husbandry/HusbandryOwned.java new file mode 100644 index 0000000..45dba25 --- /dev/null +++ b/src/main/java/net/tfminecraft/breedingbuddies/husbandry/HusbandryOwned.java @@ -0,0 +1,14 @@ +package net.tfminecraft.breedingbuddies.husbandry; + +import java.util.Locale; + +public record HusbandryOwned(HusbandryAnimal animal, String role) { + + public HusbandryOwned { + if (role == null || role.isBlank()) { + role = "owner"; + } else { + role = role.toLowerCase(Locale.ROOT); + } + } +} diff --git a/src/main/java/net/tfminecraft/breedingbuddies/husbandry/HusbandryOwner.java b/src/main/java/net/tfminecraft/breedingbuddies/husbandry/HusbandryOwner.java new file mode 100644 index 0000000..403c538 --- /dev/null +++ b/src/main/java/net/tfminecraft/breedingbuddies/husbandry/HusbandryOwner.java @@ -0,0 +1,42 @@ +package net.tfminecraft.breedingbuddies.husbandry; + +import java.util.UUID; + +public final class HusbandryOwner { + + private final UUID animalUuid; + private final UUID playerUuid; + private final UUID characterUuid; + private final String role; + + /** Legacy metadata remains representable, but cannot grant or mutate character ownership. */ + @Deprecated + public HusbandryOwner(UUID animalUuid, UUID playerUuid, String role) { + this(animalUuid, playerUuid, null, role); + } + + public HusbandryOwner(UUID animalUuid, UUID playerUuid, UUID characterUuid, String role) { + this.animalUuid = animalUuid; + this.playerUuid = playerUuid; + this.characterUuid = characterUuid; + this.role = role; + } + + public UUID animalUuid() { + return animalUuid; + } + + public UUID playerUuid() { + return playerUuid; + } + + public UUID characterUuid() { return characterUuid; } + + public net.tfminecraft.tlibs.character.CharacterRef ref() { + return new net.tfminecraft.tlibs.character.CharacterRef(playerUuid, characterUuid); + } + + public String role() { + return role; + } +} diff --git a/src/main/java/net/tfminecraft/breedingbuddies/husbandry/HusbandryOwnershipService.java b/src/main/java/net/tfminecraft/breedingbuddies/husbandry/HusbandryOwnershipService.java new file mode 100644 index 0000000..59ed802 --- /dev/null +++ b/src/main/java/net/tfminecraft/breedingbuddies/husbandry/HusbandryOwnershipService.java @@ -0,0 +1,171 @@ +package net.tfminecraft.breedingbuddies.husbandry; + +import java.util.List; +import java.util.UUID; + +import org.bukkit.entity.LivingEntity; +import org.bukkit.entity.Player; + +public final class HusbandryOwnershipService { + + public enum Result { + OK, + NO_REPOSITORY, + AT_CAP, + ALREADY_OWNER, + NOT_OWNER, + TARGET_AT_CAP, + ALREADY_TARGET_OWNER, + CHARACTER_REQUIRED, + HELD_OWNER + } + + private HusbandryOwnershipService() {} + + public static boolean isStaff(Player player) { + return player != null && player.hasPermission("breedingbuddies.admin"); + } + + public static boolean canAccept(Player player) { + if (player == null || HusbandryIdentity.session(player).isEmpty()) { + return false; + } + if (isStaff(player)) { + return true; + } + HusbandryRepository repository = HusbandryEntities.repository(); + if (repository == null) { + return false; + } + return repository.countForPlayer(player.getUniqueId()) < HusbandryConfig.maxAnimals(); + } + + private static boolean canAccept(Player player, UUID animal) { + HusbandryRepository repository = HusbandryEntities.repository(); + return repository != null && (repository.accountHasAnimal(player.getUniqueId(), animal) || canAccept(player)); + } + + public static boolean isOwner(Player player, UUID animalUuid) { + return HusbandryIdentity.session(player).map(session -> isOwner(session.ref(), animalUuid)).orElse(false); + } + + /** Account-only callers cannot acquire a character's permission. */ + @Deprecated + public static boolean isOwner(UUID account, UUID animal) { return false; } + + public static boolean isOwner(net.tfminecraft.tlibs.character.CharacterRef ref, UUID animalUuid) { + HusbandryRepository repository = HusbandryEntities.repository(); + if (repository == null || ref == null || animalUuid == null) return false; + return repository.listOwners(animalUuid).stream().anyMatch(owner -> ref.equals(owner.ref())); + } + + public static boolean hasAnyOwner(UUID animalUuid) { + HusbandryRepository repository = HusbandryEntities.repository(); + if (repository == null || animalUuid == null) { + return false; + } + return repository.hasAnyOwner(animalUuid); + } + + public static List listOwners(UUID animalUuid) { + HusbandryRepository repository = HusbandryEntities.repository(); + if (repository == null) { + return List.of(); + } + return repository.listOwners(animalUuid); + } + + public static Result claimOwner(Player player, HusbandryAnimal animal, LivingEntity entity, String name) { + HusbandryRepository repository = HusbandryEntities.repository(); + if (repository == null || player == null || animal == null) { + return Result.NO_REPOSITORY; + } + var session = HusbandryIdentity.session(player).orElse(null); + if (session == null) return Result.CHARACTER_REQUIRED; + if (repository.isEstateHeld(animal.uuid())) return Result.HELD_OWNER; + if (isOwner(player, animal.uuid())) { + applyName(animal, entity, name); + repository.upsertAnimal(animal); + return Result.OK; + } + if (hasAnyOwner(animal.uuid())) { + return Result.NOT_OWNER; + } + if (!canAccept(player)) { + return Result.AT_CAP; + } + applyName(animal, entity, name); + animal.setState(HusbandryAnimalState.OWNED); + repository.saveAnimalAndOwner(animal, + new HusbandryOwner(animal.uuid(), player.getUniqueId(), session.ref().characterId(), "owner")); + return Result.OK; + } + + public static Result addCoOwner(Player actor, Player target, HusbandryAnimal animal) { + HusbandryRepository repository = HusbandryEntities.repository(); + if (repository == null || actor == null || target == null || animal == null) { + return Result.NO_REPOSITORY; + } + var actorSession = HusbandryIdentity.session(actor).orElse(null); + var targetSession = HusbandryIdentity.session(target).orElse(null); + if (actorSession == null || targetSession == null) return Result.CHARACTER_REQUIRED; + if (repository.isEstateHeld(animal.uuid())) return Result.HELD_OWNER; + if (!hasAnyOwner(animal.uuid())) { + return Result.NOT_OWNER; + } + boolean selfClaim = actorSession.ref().equals(targetSession.ref()); + if (selfClaim && repository.hasHeldOwner(animal.uuid())) return Result.HELD_OWNER; + if (!selfClaim && !isOwner(actor, animal.uuid())) { + return Result.NOT_OWNER; + } + if (isOwner(target, animal.uuid())) { + return Result.ALREADY_TARGET_OWNER; + } + if (!canAccept(target, animal.uuid())) { + return Result.TARGET_AT_CAP; + } + animal.setState(HusbandryAnimalState.OWNED); + repository.saveAnimalAndOwner(animal, + new HusbandryOwner(animal.uuid(), target.getUniqueId(), targetSession.ref().characterId(), "coowner")); + return Result.OK; + } + + public static Result removeSelf(Player player, HusbandryAnimal animal) { + HusbandryRepository repository = HusbandryEntities.repository(); + if (repository == null || player == null || animal == null) { + return Result.NO_REPOSITORY; + } + if (repository.isEstateHeld(animal.uuid())) return Result.HELD_OWNER; + if (!isOwner(player, animal.uuid())) { + return Result.NOT_OWNER; + } + repository.deleteCharacterOwner(animal.uuid(), HusbandryIdentity.session(player).orElseThrow().ref().characterId()); + if (!hasAnyOwner(animal.uuid())) { + animal.setState(HusbandryAnimalState.UNTAMED); + repository.upsertAnimal(animal); + } + return Result.OK; + } + + public static void applyName(HusbandryAnimal animal, LivingEntity entity, String name) { + String sanitized = sanitizeName(name, animal, entity); + animal.setName(sanitized); + if (entity != null && entity.isValid()) { + HusbandryStateDisplay.sync(entity, animal); + } + } + + private static String sanitizeName(String name, HusbandryAnimal animal, LivingEntity entity) { + if (name != null && !name.isBlank() && !"???".equals(name.trim())) { + return name.trim(); + } + if (animal != null && animal.name() != null && !animal.name().isBlank() + && !"???".equals(animal.name().trim())) { + return animal.name(); + } + if (entity != null) { + return HusbandryEntities.displayName(entity.getType()); + } + return "Animal"; + } +} diff --git a/src/main/java/net/tfminecraft/breedingbuddies/husbandry/HusbandryOwnershipStore.java b/src/main/java/net/tfminecraft/breedingbuddies/husbandry/HusbandryOwnershipStore.java new file mode 100644 index 0000000..0d0cb58 --- /dev/null +++ b/src/main/java/net/tfminecraft/breedingbuddies/husbandry/HusbandryOwnershipStore.java @@ -0,0 +1,274 @@ +package net.tfminecraft.breedingbuddies.husbandry; + +import java.sql.Connection; +import java.sql.PreparedStatement; +import java.sql.ResultSet; +import java.sql.SQLException; +import java.util.ArrayList; +import java.util.List; +import java.util.Optional; +import java.util.UUID; +import java.util.function.BooleanSupplier; +import java.util.function.Function; + +import net.tfminecraft.tlibs.character.CharacterRef; +import net.tfminecraft.tlibs.database.SqliteDatabase; +import net.tfminecraft.tlibs.database.SqliteDatabaseException; + +/** Character slots; the account remains a routing identity and a distinct-animal capacity key. */ +final class HusbandryOwnershipStore { + private final SqliteDatabase database; + + HusbandryOwnershipStore(SqliteDatabase database) { + this.database = database; + database.execute(""" + CREATE TABLE IF NOT EXISTS character_owners ( + animal_uuid TEXT NOT NULL, account_uuid TEXT NOT NULL, + character_uuid TEXT NOT NULL, role TEXT NOT NULL, + PRIMARY KEY(animal_uuid, character_uuid), + FOREIGN KEY(animal_uuid) REFERENCES animals(uuid) ON DELETE CASCADE) + """); + database.execute(""" + CREATE TABLE IF NOT EXISTS husbandry_owner_plan ( + animal_uuid TEXT NOT NULL, account_uuid TEXT NOT NULL, + character_uuid TEXT, role TEXT, status TEXT NOT NULL, reason TEXT NOT NULL, + PRIMARY KEY(animal_uuid, account_uuid)) + """); + database.execute("CREATE TABLE IF NOT EXISTS husbandry_owner_meta (key TEXT PRIMARY KEY, value TEXT NOT NULL)"); + database.execute(""" + CREATE TABLE IF NOT EXISTS husbandry_estate_intents ( + death_id TEXT NOT NULL, revision INTEGER NOT NULL, decision TEXT NOT NULL, + from_account TEXT NOT NULL, from_character TEXT NOT NULL, + to_account TEXT NOT NULL, to_character TEXT NOT NULL, status TEXT NOT NULL, + PRIMARY KEY(death_id, revision)) + """); + database.execute(""" + CREATE TABLE IF NOT EXISTS husbandry_estate_slots ( + death_id TEXT NOT NULL, revision INTEGER NOT NULL, animal_uuid TEXT NOT NULL, + source_role TEXT NOT NULL, recipient_before TEXT, recipient_after TEXT NOT NULL, + PRIMARY KEY(death_id, revision, animal_uuid)) + """); + } + + /** One consistent SQLite snapshot, archive, reviewed plan and publication in one FULL transaction. */ + boolean migrate(BooleanSupplier ready, Function> heirs) { + if (completed()) return true; + if (!ready.getAsBoolean()) return false; + database.runTransaction(connection -> { + try { + // CTAS preserves every legacy owner column and has no cascading foreign keys. + update(connection, "CREATE TABLE husbandry_owner_archive AS SELECT * FROM owners"); + if (scalar(connection, "SELECT COUNT(*) FROM owners") + != scalar(connection, "SELECT COUNT(*) FROM husbandry_owner_archive")) { + throw new SQLException("Owner archive verification failed"); + } + for (String action : List.of("INSERT", "UPDATE", "DELETE")) { + update(connection, "CREATE TRIGGER husbandry_archive_" + action + + " BEFORE " + action + " ON husbandry_owner_archive BEGIN " + + "SELECT RAISE(ABORT, 'Immutable husbandry ownership archive'); END"); + } + List source = rows(connection, + "SELECT animal_uuid, player_uuid, role FROM husbandry_owner_archive"); + for (String[] row : source) { + CharacterRef destination = null; + String reason = "No reviewed heir"; + try { + UUID animal = UUID.fromString(row[0]); + UUID account = UUID.fromString(row[1]); + destination = heirs.apply(account).filter(ref -> ref.ownerId().equals(account)).orElse(null); + if (!recognized(row[2])) { + destination = null; + reason = "Unknown owner role"; + } else if (scalar(connection, "SELECT COUNT(*) FROM animals WHERE uuid = ?", animal.toString()) == 0) { + destination = null; + reason = "Animal row missing"; + } + } catch (IllegalArgumentException ex) { + reason = "Malformed legacy identity"; + } + if (destination != null) { + String[] existing = slot(connection, row[0], destination.characterId().toString()); + if (existing != null && (!destination.ownerId().toString().equals(existing[0]) + || !recognized(existing[1]))) { + destination = null; + reason = "Conflicting character slot"; + } else { + put(connection, row[0], destination, + existing == null ? row[2] : stronger(row[2], existing[1])); + } + } + update(connection, "INSERT INTO husbandry_owner_plan VALUES (?, ?, ?, ?, ?, ?)", + row[0], row[1], destination == null ? null : destination.characterId().toString(), row[2], + destination == null ? "held" : "resolved", destination == null ? reason : ""); + } + update(connection, "INSERT INTO husbandry_owner_meta VALUES ('completed', '1')"); + } catch (SQLException ex) { + throw new SqliteDatabaseException("Could not migrate husbandry owners", ex); + } + }); + return true; + } + + boolean completed() { + return count("SELECT COUNT(*) FROM husbandry_owner_meta WHERE key = 'completed'") != 0; + } + + void put(HusbandryOwner owner) { + if (owner == null || owner.animalUuid() == null || owner.playerUuid() == null) return; + if (owner.characterUuid() == null) throw new IllegalArgumentException("Character ownership is required"); + if (!recognized(owner.role())) throw new IllegalArgumentException("Unrecognized owner role"); + if (estateHeld(owner.animalUuid())) throw new IllegalStateException("Animal estate intent requires staff review"); + database.runTransaction(connection -> { + try { + String[] before = slot(connection, owner.animalUuid().toString(), owner.characterUuid().toString()); + if (before != null && (!owner.playerUuid().toString().equals(before[0]) || !recognized(before[1]))) { + throw new IllegalArgumentException("Conflicting owner slot"); + } + put(connection, owner.animalUuid().toString(), owner.ref(), owner.role()); + } catch (SQLException ex) { + throw new SqliteDatabaseException("Could not write character owner", ex); + } + }); + } + + void remove(UUID animal, UUID character) { + if (animal == null || character == null) return; + if (estateHeld(animal)) throw new IllegalStateException("Animal estate intent requires staff review"); + database.executeUpdate( + "DELETE FROM character_owners WHERE animal_uuid = ? AND character_uuid = ?", animal.toString(), character.toString()); + } + + List owners(UUID animal) { + if (animal == null) return List.of(); + synchronized (database) { + try { + List result = new ArrayList<>(); + for (String[] row : rows(database.getConnection(), + "SELECT account_uuid, character_uuid, role FROM character_owners WHERE animal_uuid = ?", animal.toString())) { + // Unknown/malformed current slots remain durable and block unowned claims. + try { + if (recognized(row[2])) result.add(new HusbandryOwner(animal, + UUID.fromString(row[0]), UUID.fromString(row[1]), row[2])); + } catch (IllegalArgumentException ignored) { } + } + return result; + } catch (SQLException ex) { + throw new SqliteDatabaseException("Could not read character owners", ex); + } + } + } + + boolean hasAny(UUID animal) { + return animal != null && count(""" + SELECT COUNT(*) FROM ( + SELECT animal_uuid FROM character_owners WHERE animal_uuid = ? + UNION SELECT o.animal_uuid FROM owners o LEFT JOIN husbandry_owner_plan p + ON p.animal_uuid = o.animal_uuid AND p.account_uuid = o.player_uuid + WHERE o.animal_uuid = ? AND (p.status IS NULL OR p.status = 'held')) + """, animal.toString(), animal.toString()) != 0; + } + + boolean held(UUID animal) { + if (animal == null) return false; + if (count("SELECT COUNT(*) FROM character_owners WHERE animal_uuid = ?", animal.toString()) != owners(animal).size()) return true; + return count(""" + SELECT COUNT(*) FROM owners o LEFT JOIN husbandry_owner_plan p + ON p.animal_uuid = o.animal_uuid AND p.account_uuid = o.player_uuid + WHERE o.animal_uuid = ? AND (p.status IS NULL OR p.status = 'held') + """, animal.toString()) != 0; + } + + int countAccount(UUID account) { + if (account == null) return 0; + return count(""" + SELECT COUNT(*) FROM ( + SELECT animal_uuid FROM character_owners WHERE account_uuid = ? + UNION SELECT o.animal_uuid FROM owners o LEFT JOIN husbandry_owner_plan p + ON p.animal_uuid = o.animal_uuid AND p.account_uuid = o.player_uuid + WHERE o.player_uuid = ? AND (p.status IS NULL OR p.status = 'held')) + """, account.toString(), account.toString()); + } + + boolean accountHas(UUID account, UUID animal) { + return account != null && animal != null && count(""" + SELECT COUNT(*) FROM ( + SELECT animal_uuid FROM character_owners WHERE animal_uuid = ? AND account_uuid = ? + UNION SELECT o.animal_uuid FROM owners o LEFT JOIN husbandry_owner_plan p + ON p.animal_uuid = o.animal_uuid AND p.account_uuid = o.player_uuid + WHERE o.animal_uuid = ? AND o.player_uuid = ? AND (p.status IS NULL OR p.status = 'held')) + """, animal.toString(), account.toString(), animal.toString(), account.toString()) != 0; + } + + boolean estateHeld(UUID animal) { + return animal != null && count(""" + SELECT COUNT(*) FROM husbandry_estate_intents i + WHERE i.status != 'done' AND ( + EXISTS (SELECT 1 FROM husbandry_estate_slots s WHERE s.death_id = i.death_id + AND s.revision = i.revision AND s.animal_uuid = ?) + OR EXISTS (SELECT 1 FROM character_owners o WHERE o.animal_uuid = ? + AND o.character_uuid = i.from_character)) + """, animal.toString(), animal.toString()) != 0; + } + + int heldCount() { + return count("SELECT COUNT(*) FROM husbandry_owner_plan WHERE status = 'held'"); + } + + int count(String sql, Object... parameters) { + synchronized (database) { + try { return scalar(database.getConnection(), sql, parameters); } + catch (SQLException ex) { throw new SqliteDatabaseException("Could not count owner slots", ex); } + } + } + + static boolean recognized(String role) { return "owner".equals(role) || "coowner".equals(role); } + static String stronger(String left, String right) { return "owner".equals(left) || "owner".equals(right) ? "owner" : "coowner"; } + + static void put(Connection connection, String animal, CharacterRef ref, String role) throws SQLException { + update(connection, """ + INSERT INTO character_owners (animal_uuid, account_uuid, character_uuid, role) VALUES (?, ?, ?, ?) + ON CONFLICT(animal_uuid, character_uuid) DO UPDATE SET role = excluded.role + """, animal, ref.ownerId().toString(), ref.characterId().toString(), role); + } + + static String[] slot(Connection connection, String animal, String character) throws SQLException { + List rows = rows(connection, + "SELECT account_uuid, role FROM character_owners WHERE animal_uuid = ? AND character_uuid = ?", animal, character); + return rows.isEmpty() ? null : rows.get(0); + } + + static int update(Connection connection, String sql, Object... parameters) throws SQLException { + try (PreparedStatement statement = statement(connection, sql, parameters)) { return statement.executeUpdate(); } + } + + static int scalar(Connection connection, String sql, Object... parameters) throws SQLException { + try (PreparedStatement statement = statement(connection, sql, parameters); ResultSet result = statement.executeQuery()) { + result.next(); + return result.getInt(1); + } + } + + static List rows(Connection connection, String sql, Object... parameters) throws SQLException { + try (PreparedStatement statement = statement(connection, sql, parameters); ResultSet result = statement.executeQuery()) { + List rows = new ArrayList<>(); + int columns = result.getMetaData().getColumnCount(); + while (result.next()) { + String[] row = new String[columns]; + for (int i = 0; i < columns; i++) row[i] = result.getString(i + 1); + rows.add(row); + } + return rows; + } + } + + private static PreparedStatement statement(Connection connection, String sql, Object... parameters) throws SQLException { + PreparedStatement statement = connection.prepareStatement(sql); + try { + for (int i = 0; i < parameters.length; i++) statement.setObject(i + 1, parameters[i]); + return statement; + } catch (SQLException ex) { + statement.close(); + throw ex; + } + } +} diff --git a/src/main/java/net/tfminecraft/breedingbuddies/husbandry/HusbandryProducts.java b/src/main/java/net/tfminecraft/breedingbuddies/husbandry/HusbandryProducts.java new file mode 100644 index 0000000..48bb833 --- /dev/null +++ b/src/main/java/net/tfminecraft/breedingbuddies/husbandry/HusbandryProducts.java @@ -0,0 +1,43 @@ +package net.tfminecraft.breedingbuddies.husbandry; + +import java.util.ArrayList; +import java.util.List; + +public final class HusbandryProducts { + + private HusbandryProducts() {} + + public static boolean hasProducts(HusbandrySpecies species) { + if (species == null) { + return false; + } + return species.canSlaughter() + || species.canShear() + || species.canShed() + || species.canMilk() + || species.hasEgg(); + } + + public static List modeLines(HusbandrySpecies species) { + List lines = new ArrayList<>(); + if (species == null) { + return lines; + } + if (species.canSlaughter()) { + lines.add("On slaughter"); + } + if (species.canShear()) { + lines.add("Shear"); + } + if (species.canShed()) { + lines.add("Shed"); + } + if (species.canMilk()) { + lines.add("Milk"); + } + if (species.hasEgg()) { + lines.add("Eggs"); + } + return lines; + } +} diff --git a/src/main/java/net/tfminecraft/breedingbuddies/husbandry/HusbandryProfessionGrant.java b/src/main/java/net/tfminecraft/breedingbuddies/husbandry/HusbandryProfessionGrant.java new file mode 100644 index 0000000..1c9c644 --- /dev/null +++ b/src/main/java/net/tfminecraft/breedingbuddies/husbandry/HusbandryProfessionGrant.java @@ -0,0 +1,23 @@ +package net.tfminecraft.breedingbuddies.husbandry; + +import org.bukkit.entity.Player; + +import net.Indyuce.mmocore.MMOCore; +import net.Indyuce.mmocore.api.player.PlayerData; +import net.Indyuce.mmocore.experience.EXPSource; +import net.Indyuce.mmocore.experience.Profession; + +public final class HusbandryProfessionGrant { + private HusbandryProfessionGrant() {} + + public static void give(Player player, String professionId, int amount) { + if (player == null || professionId == null || professionId.isBlank() || amount <= 0) { + return; + } + Profession profession = MMOCore.plugin.professionManager.get(professionId); + if (profession == null) { + return; + } + PlayerData.get(player).getCollectionSkills().giveExperience(profession, amount, EXPSource.SOURCE); + } +} diff --git a/src/main/java/net/tfminecraft/breedingbuddies/husbandry/HusbandryProfessionXp.java b/src/main/java/net/tfminecraft/breedingbuddies/husbandry/HusbandryProfessionXp.java new file mode 100644 index 0000000..916b682 --- /dev/null +++ b/src/main/java/net/tfminecraft/breedingbuddies/husbandry/HusbandryProfessionXp.java @@ -0,0 +1,36 @@ +package net.tfminecraft.breedingbuddies.husbandry; + +import java.lang.reflect.InvocationTargetException; + +import org.bukkit.Bukkit; +import org.bukkit.entity.EntityType; +import org.bukkit.entity.Player; + +public final class HusbandryProfessionXp { + private HusbandryProfessionXp() {} + + public static void tryGive(Player player, EntityType type) { + if (player == null || !Bukkit.getPluginManager().isPluginEnabled("MMOCore")) { + return; + } + String profession = HusbandryConfig.professionId(); + if (profession == null || profession.isBlank()) { + return; + } + int amount = HusbandryConfig.expFor(type).roll(); + if (amount <= 0) { + return; + } + try { + Class grant = Class.forName( + "net.tfminecraft.breedingbuddies.husbandry.HusbandryProfessionGrant", + true, + HusbandryProfessionXp.class.getClassLoader()); + grant.getMethod("give", Player.class, String.class, int.class) + .invoke(null, player, profession, amount); + } catch (ClassNotFoundException | NoClassDefFoundError | IllegalAccessException + | InvocationTargetException | NoSuchMethodException ignored) { + // MMOCore is present but the profession grant could not run. + } + } +} diff --git a/src/main/java/net/tfminecraft/breedingbuddies/husbandry/HusbandryQualityBand.java b/src/main/java/net/tfminecraft/breedingbuddies/husbandry/HusbandryQualityBand.java new file mode 100644 index 0000000..1897f0d --- /dev/null +++ b/src/main/java/net/tfminecraft/breedingbuddies/husbandry/HusbandryQualityBand.java @@ -0,0 +1,20 @@ +package net.tfminecraft.breedingbuddies.husbandry; + +public final class HusbandryQualityBand { + + private final int minGenetics; + private final int stars; + + public HusbandryQualityBand(int minGenetics, int stars) { + this.minGenetics = minGenetics; + this.stars = stars; + } + + public int minGenetics() { + return minGenetics; + } + + public int stars() { + return stars; + } +} diff --git a/src/main/java/net/tfminecraft/breedingbuddies/husbandry/HusbandryQualityRange.java b/src/main/java/net/tfminecraft/breedingbuddies/husbandry/HusbandryQualityRange.java new file mode 100644 index 0000000..d0a97b7 --- /dev/null +++ b/src/main/java/net/tfminecraft/breedingbuddies/husbandry/HusbandryQualityRange.java @@ -0,0 +1,53 @@ +package net.tfminecraft.breedingbuddies.husbandry; + +import java.util.List; +import java.util.Random; + +public final class HusbandryQualityRange { + + public record Bounds(int min, int max) {} + + private HusbandryQualityRange() {} + + public static int starsForGenetics(int genetics, List bands) { + int stars = 1; + if (bands != null) { + for (HusbandryQualityBand band : bands) { + if (genetics >= band.minGenetics()) { + stars = band.stars(); + } + } + } + return Math.max(1, Math.min(5, stars)); + } + + public static int effectiveGenetics(int genetics, int care, int careMax) { + int maxCare = Math.max(1, careMax); + int clampedCare = Math.max(0, care); + return (int) Math.floor(genetics * (clampedCare / (double) maxCare)); + } + + public static Bounds of(int genetics, int care, int careMax, List bands) { + int max = starsForGenetics(genetics, bands); + int min = starsForGenetics(effectiveGenetics(genetics, care, careMax), bands); + if (min > max) { + int swap = min; + min = max; + max = swap; + } + return new Bounds(min, max); + } + + public static int roll(Bounds bounds, Random random) { + if (bounds == null) { + return 1; + } + int min = bounds.min(); + int max = bounds.max(); + if (min >= max) { + return min; + } + Random rng = random == null ? new Random() : random; + return min + rng.nextInt(max - min + 1); + } +} diff --git a/src/main/java/net/tfminecraft/breedingbuddies/husbandry/HusbandryRepository.java b/src/main/java/net/tfminecraft/breedingbuddies/husbandry/HusbandryRepository.java new file mode 100644 index 0000000..e25ef40 --- /dev/null +++ b/src/main/java/net/tfminecraft/breedingbuddies/husbandry/HusbandryRepository.java @@ -0,0 +1,626 @@ +package net.tfminecraft.breedingbuddies.husbandry; + +import java.io.File; +import java.sql.PreparedStatement; +import java.sql.ResultSet; +import java.sql.SQLException; +import java.sql.Types; +import java.util.ArrayList; +import java.util.Collection; +import java.util.List; +import java.util.Map; +import java.util.Optional; +import java.util.Random; +import java.util.UUID; +import java.util.concurrent.ThreadLocalRandom; + +import net.tfminecraft.tlibs.database.SqliteDatabase; +import net.tfminecraft.tlibs.database.SqliteDatabaseException; +import net.tfminecraft.tlibs.database.SqliteProvider; + +public final class HusbandryRepository { + + private static final String CREATE_ANIMALS = """ + CREATE TABLE IF NOT EXISTS animals ( + uuid TEXT PRIMARY KEY, + type TEXT NOT NULL, + name TEXT NOT NULL DEFAULT '', + state TEXT NOT NULL DEFAULT 'untamed', + genetics INTEGER NOT NULL DEFAULT 0, + care INTEGER NOT NULL DEFAULT 0, + hungry_since INTEGER, + dirty_since INTEGER, + last_processed_at INTEGER NOT NULL, + unloaded_at INTEGER, + affliction_elapsed REAL NOT NULL DEFAULT 0, + affliction_at REAL NOT NULL DEFAULT 0, + last_milk_at INTEGER, + wool_ready_at INTEGER, + neutered INTEGER NOT NULL DEFAULT 0, + loaded_visit_start INTEGER, + mature_at INTEGER, + shed_ready_at INTEGER, + egg_ready_at INTEGER, + care_up_remainder INTEGER NOT NULL DEFAULT 0, + care_down_remainder INTEGER NOT NULL DEFAULT 0, + stats_revision TEXT, + last_pet_at INTEGER, + gene_health INTEGER, + gene_speed INTEGER, + gene_jump INTEGER + ) + """; + + private static final String CREATE_OWNERS = """ + CREATE TABLE IF NOT EXISTS owners ( + animal_uuid TEXT NOT NULL, + player_uuid TEXT NOT NULL, + role TEXT NOT NULL DEFAULT 'owner', + PRIMARY KEY (animal_uuid, player_uuid), + FOREIGN KEY (animal_uuid) REFERENCES animals(uuid) ON DELETE CASCADE + ) + """; + + private static final String CREATE_SNAPSHOTS = """ + CREATE TABLE IF NOT EXISTS snapshots ( + animal_uuid TEXT PRIMARY KEY, + data BLOB NOT NULL, + saved_at INTEGER NOT NULL, + FOREIGN KEY (animal_uuid) REFERENCES animals(uuid) ON DELETE CASCADE + ) + """; + + private static final String INDEX_OWNERS_PLAYER = """ + CREATE INDEX IF NOT EXISTS owners_by_player + ON owners(player_uuid) + """; + + private static final String UPSERT_ANIMAL = """ + INSERT INTO animals ( + uuid, type, name, state, genetics, care, + hungry_since, dirty_since, last_processed_at, unloaded_at, + affliction_elapsed, affliction_at, last_milk_at, wool_ready_at, + neutered, loaded_visit_start, mature_at, shed_ready_at, egg_ready_at, + care_up_remainder, care_down_remainder, stats_revision, + world, x, y, z, last_pet_at, gene_health, gene_speed, gene_jump + ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) + ON CONFLICT(uuid) DO UPDATE SET + type = excluded.type, + name = excluded.name, + state = excluded.state, + genetics = excluded.genetics, + care = excluded.care, + hungry_since = excluded.hungry_since, + dirty_since = excluded.dirty_since, + last_processed_at = excluded.last_processed_at, + unloaded_at = excluded.unloaded_at, + affliction_elapsed = excluded.affliction_elapsed, + affliction_at = excluded.affliction_at, + last_milk_at = excluded.last_milk_at, + wool_ready_at = excluded.wool_ready_at, + neutered = excluded.neutered, + loaded_visit_start = excluded.loaded_visit_start, + mature_at = excluded.mature_at, + shed_ready_at = excluded.shed_ready_at, + egg_ready_at = excluded.egg_ready_at, + care_up_remainder = excluded.care_up_remainder, + care_down_remainder = excluded.care_down_remainder, + stats_revision = excluded.stats_revision, + world = excluded.world, + x = excluded.x, + y = excluded.y, + z = excluded.z, + last_pet_at = excluded.last_pet_at, + gene_health = excluded.gene_health, + gene_speed = excluded.gene_speed, + gene_jump = excluded.gene_jump + """; + + private static final String SELECT_ANIMAL = "SELECT * FROM animals WHERE uuid = ?"; + private static final String EXISTS_ANIMAL = "SELECT 1 FROM animals WHERE uuid = ? LIMIT 1"; + private static final String DELETE_ANIMAL = "DELETE FROM animals WHERE uuid = ?"; + // Skips animals whose row is already gone, so a late snapshot cannot trip the foreign key. + private static final String UPSERT_SNAPSHOT = """ + INSERT INTO snapshots (animal_uuid, data, saved_at) + SELECT ?, ?, ? WHERE EXISTS (SELECT 1 FROM animals WHERE uuid = ?) + ON CONFLICT(animal_uuid) DO UPDATE SET data = excluded.data, saved_at = excluded.saved_at + """; + private static final String SELECT_SNAPSHOT = "SELECT data, saved_at FROM snapshots WHERE animal_uuid = ?"; + private static final String DELETE_SNAPSHOT = "DELETE FROM snapshots WHERE animal_uuid = ?"; + + private final SqliteDatabase database; + private HusbandryOwnershipStore ownership; + + private HusbandryRepository(SqliteDatabase database) { + this.database = database; + } + + public static HusbandryRepository open(File dbFile) { + SqliteProvider.ensureDriverLoaded(); + SqliteDatabase sqlite = new SqliteDatabase(dbFile); + HusbandryRepository repository = new HusbandryRepository(sqlite); + try { + repository.initSchema(); + } catch (RuntimeException ex) { + try { + sqlite.close(); + } catch (Exception closeError) { + ex.addSuppressed(closeError); + } + throw ex; + } + return repository; + } + + private void initSchema() { + database.execute("PRAGMA journal_mode=WAL"); + database.execute("PRAGMA foreign_keys=ON"); + database.execute("PRAGMA synchronous=FULL"); + database.execute("PRAGMA busy_timeout=5000"); + database.execute(CREATE_ANIMALS); + database.execute(CREATE_OWNERS); + database.execute(INDEX_OWNERS_PLAYER); + database.execute(CREATE_SNAPSHOTS); + migrateSchema(); + ownership = new HusbandryOwnershipStore(database); + } + + private void migrateSchema() { + int version = queryOne("PRAGMA user_version", result -> result.getInt(1)).orElse(0); + if (version < 2) { + database.execute("UPDATE animals SET genetics = MIN(genetics / 10, 1000)"); + database.execute("PRAGMA user_version = 2"); + version = 2; + } + if (version < 3) { + if (!hasColumn("animals", "mature_at")) { + database.execute("ALTER TABLE animals ADD COLUMN mature_at INTEGER"); + } + database.execute("PRAGMA user_version = 3"); + version = 3; + } + if (version < 4) { + if (!hasColumn("animals", "shed_ready_at")) { + database.execute("ALTER TABLE animals ADD COLUMN shed_ready_at INTEGER"); + } + database.execute("PRAGMA user_version = 4"); + version = 4; + } + if (version < 5) { + if (!hasColumn("animals", "egg_ready_at")) { + database.execute("ALTER TABLE animals ADD COLUMN egg_ready_at INTEGER"); + } + database.execute("PRAGMA user_version = 5"); + version = 5; + } + if (version < 6) { + if (!hasColumn("animals", "care_up_remainder")) { + database.execute("ALTER TABLE animals ADD COLUMN care_up_remainder INTEGER NOT NULL DEFAULT 0"); + } + if (!hasColumn("animals", "care_down_remainder")) { + database.execute("ALTER TABLE animals ADD COLUMN care_down_remainder INTEGER NOT NULL DEFAULT 0"); + } + database.execute("PRAGMA user_version = 6"); + } + if (version < 7) { + if (!hasColumn("animals", "stats_revision")) { + database.execute("ALTER TABLE animals ADD COLUMN stats_revision TEXT"); + } + database.execute("PRAGMA user_version = 7"); + } + if (version < 8) { + if (!hasColumn("animals", "world")) { + database.execute("ALTER TABLE animals ADD COLUMN world TEXT"); + } + if (!hasColumn("animals", "x")) { + database.execute("ALTER TABLE animals ADD COLUMN x INTEGER"); + } + if (!hasColumn("animals", "y")) { + database.execute("ALTER TABLE animals ADD COLUMN y INTEGER"); + } + if (!hasColumn("animals", "z")) { + database.execute("ALTER TABLE animals ADD COLUMN z INTEGER"); + } + database.execute("PRAGMA user_version = 8"); + } + // BreedingBuddies columns are checked by name, not user_version, so Cooking's numbering can never skip them. + if (!hasColumn("animals", "last_pet_at")) { + database.execute("ALTER TABLE animals ADD COLUMN last_pet_at INTEGER"); + } + for (String gene : List.of("gene_health", "gene_speed", "gene_jump")) { + if (!hasColumn("animals", gene)) { + database.execute("ALTER TABLE animals ADD COLUMN " + gene + " INTEGER"); + } + } + } + + private boolean hasColumn(String table, String column) { + return queryList("PRAGMA table_info(" + table + ")", result -> result.getString("name")) + .contains(column); + } + + public int resetStaleStats(String revision, Random random) { + if (revision == null || revision.isBlank()) { + return 0; + } + String current = revision.trim(); + List stale = queryList( + """ + SELECT * FROM animals + WHERE stats_revision IS NULL + OR TRIM(stats_revision) = '' + OR stats_revision != ? + """, + HusbandryRepository::mapAnimal, + current); + if (stale.isEmpty()) { + return 0; + } + Random rng = random == null ? ThreadLocalRandom.current() : random; + int max = HusbandryConfig.initialGeneticMax(); + for (HusbandryAnimal animal : stale) { + animal.setGenetics(max <= 0 ? 0 : rng.nextInt(max + 1)); + animal.setMountGenes(null, null, null); + animal.setCare(0); + animal.setCareUpRemainderSeconds(0); + animal.setCareDownRemainderSeconds(0); + animal.setStatsRevision(current); + } + upsertAnimals(stale); + return stale.size(); + } + + public void upsertAnimal(HusbandryAnimal animal) { + if (animal == null || animal.uuid() == null) { + return; + } + upsertAnimals(List.of(animal)); + } + + public void upsertAnimals(Collection animals) { + if (animals == null || animals.isEmpty()) { + return; + } + List valid = new ArrayList<>(); + for (HusbandryAnimal animal : animals) { + if (animal == null || animal.uuid() == null) { + continue; + } + valid.add(animal); + } + if (valid.isEmpty()) { + return; + } + database.runTransaction(connection -> { + try (PreparedStatement statement = connection.prepareStatement(UPSERT_ANIMAL)) { + for (HusbandryAnimal animal : valid) { + bindAnimal(statement, animal); + statement.executeUpdate(); + } + } catch (SQLException e) { + throw new SqliteDatabaseException("Failed to upsert animals", e); + } + }); + } + + public Optional getAnimal(UUID uuid) { + if (uuid == null) { + return Optional.empty(); + } + return queryOne(SELECT_ANIMAL, HusbandryRepository::mapAnimal, uuid.toString()); + } + + public boolean exists(UUID uuid) { + if (uuid == null) { + return false; + } + return queryOne(EXISTS_ANIMAL, result -> true, uuid.toString()).isPresent(); + } + + public void deleteAnimal(UUID uuid) { + if (uuid == null) { + return; + } + executeUpdate(DELETE_ANIMAL, uuid.toString()); + } + + public void upsertOwner(HusbandryOwner owner) { ownership.put(owner); } + + public void saveAnimalAndOwner(HusbandryAnimal animal, HusbandryOwner owner) { + if (!animal.uuid().equals(owner.animalUuid()) || !HusbandryOwnershipStore.recognized(owner.role())) { + throw new IllegalArgumentException("Invalid animal ownership publication"); + } + if (ownership.estateHeld(animal.uuid())) throw new IllegalStateException("Animal estate intent requires staff review"); + database.runTransaction(connection -> { + try (PreparedStatement statement = connection.prepareStatement(UPSERT_ANIMAL)) { + String[] before = HusbandryOwnershipStore.slot(connection, animal.uuid().toString(), owner.characterUuid().toString()); + if (before != null && (!owner.playerUuid().toString().equals(before[0]) + || !HusbandryOwnershipStore.recognized(before[1]))) throw new IllegalArgumentException("Conflicting owner slot"); + bindAnimal(statement, animal); + statement.executeUpdate(); + HusbandryOwnershipStore.put(connection, animal.uuid().toString(), owner.ref(), owner.role()); + } catch (SQLException ex) { + throw new SqliteDatabaseException("Could not publish animal ownership", ex); + } + }); + } + + /** The second UUID names a character, never an account. */ + public void deleteCharacterOwner(UUID animalUuid, UUID characterUuid) { ownership.remove(animalUuid, characterUuid); } + + /** An account is ambiguous once several of its characters can share the same animal. */ + @Deprecated + public void deleteOwner(UUID animalUuid, UUID accountUuid) { + throw new IllegalArgumentException("Use an explicit character owner"); + } + + public int countForPlayer(UUID playerUuid) { return ownership.countAccount(playerUuid); } + + public List listOwners(UUID animalUuid) { return ownership.owners(animalUuid); } + + public boolean hasAnyOwner(UUID animalUuid) { return ownership.hasAny(animalUuid); } + public boolean hasHeldOwner(UUID animalUuid) { return ownership.held(animalUuid); } + public boolean accountHasAnimal(UUID account, UUID animal) { return ownership.accountHas(account, animal); } + public boolean isEstateHeld(UUID animal) { return ownership.estateHeld(animal); } + public int heldOwners() { return ownership.heldCount(); } + public boolean migrateOwners(java.util.function.BooleanSupplier ready, + java.util.function.Function> heirs) { + return ownership.migrate(ready, heirs); + } + SqliteDatabase ownershipDatabase() { return database; } + + public List listForCharacter(net.tfminecraft.tlibs.character.CharacterRef ref) { + if (ref == null) return List.of(); + return queryList(""" + SELECT a.*, o.role AS owner_role FROM animals a + INNER JOIN character_owners o ON o.animal_uuid = a.uuid + WHERE o.account_uuid = ? AND o.character_uuid = ? AND o.role IN ('owner','coowner') + """, result -> new HusbandryOwned(mapAnimal(result), result.getString("owner_role")), + ref.ownerId().toString(), ref.characterId().toString()); + } + + public List listForPlayer(UUID playerUuid) { + if (playerUuid == null) { + return List.of(); + } + return queryList( + """ + SELECT a.*, MAX(o.role) AS owner_role + FROM animals a + INNER JOIN character_owners o ON o.animal_uuid = a.uuid + WHERE o.account_uuid = ? AND o.role IN ('owner','coowner') + GROUP BY a.uuid + """, + result -> new HusbandryOwned(mapAnimal(result), result.getString("owner_role")), + playerUuid.toString()); + } + + public List listOwnedAnimals() { + return queryList( + """ + SELECT a.* + FROM animals a + WHERE EXISTS (SELECT 1 FROM character_owners o WHERE o.animal_uuid = a.uuid) + OR EXISTS (SELECT 1 FROM owners o LEFT JOIN husbandry_owner_plan p + ON p.animal_uuid = o.animal_uuid AND p.account_uuid = o.player_uuid + WHERE o.animal_uuid = a.uuid AND (p.status IS NULL OR p.status = 'held')) + """, + HusbandryRepository::mapAnimal); + } + + /** Stores the serialized entities in one transaction; animals without a row are skipped. */ + public void upsertSnapshots(Map snapshots, long savedAt) { + if (snapshots == null || snapshots.isEmpty()) { + return; + } + database.runTransaction(connection -> { + try (PreparedStatement statement = connection.prepareStatement(UPSERT_SNAPSHOT)) { + for (Map.Entry snapshot : snapshots.entrySet()) { + if (snapshot.getKey() == null || snapshot.getValue() == null) { + continue; + } + String uuid = snapshot.getKey().toString(); + bindParams(statement, uuid, snapshot.getValue(), savedAt, uuid); + statement.executeUpdate(); + } + } catch (SQLException e) { + throw new SqliteDatabaseException("Failed to upsert snapshots", e); + } + }); + } + + public Optional getSnapshot(UUID uuid) { + if (uuid == null) { + return Optional.empty(); + } + return queryOne( + SELECT_SNAPSHOT, + result -> new HusbandrySnapshot(result.getBytes("data"), result.getLong("saved_at")), + uuid.toString()); + } + + public void deleteSnapshot(UUID uuid) { + if (uuid == null) { + return; + } + executeUpdate(DELETE_SNAPSHOT, uuid.toString()); + } + + public void checkpointWal(boolean truncate) { + String mode = truncate ? "TRUNCATE" : "PASSIVE"; + database.execute("PRAGMA wal_checkpoint(" + mode + ")"); + } + + public void close() { + try { + checkpointWal(true); + } catch (RuntimeException ignored) { + // still close + } + database.close(); + } + + private static void bindAnimal(PreparedStatement statement, HusbandryAnimal animal) throws SQLException { + bindParams( + statement, + animal.uuid().toString(), + animal.type() == null ? "" : animal.type(), + animal.name() == null ? "" : animal.name(), + animal.state().storage(), + animal.genetics(), + animal.care(), + animal.hungrySince(), + animal.dirtySince(), + animal.lastProcessedAt(), + animal.unloadedAt(), + animal.afflictionElapsed(), + animal.afflictionAt(), + animal.lastMilkAt(), + animal.woolReadyAt(), + animal.neutered() ? 1 : 0, + animal.loadedVisitStart(), + animal.matureAt(), + animal.shedReadyAt(), + animal.eggReadyAt(), + animal.careUpRemainderSeconds(), + animal.careDownRemainderSeconds(), + blankToNull(animal.statsRevision()), + animal.hasLocation() ? animal.world() : null, + animal.hasLocation() ? animal.x() : null, + animal.hasLocation() ? animal.y() : null, + animal.hasLocation() ? animal.z() : null, + animal.lastPetAt(), + animal.geneHealth(), + animal.geneSpeed(), + animal.geneJump()); + } + + private static HusbandryAnimal mapAnimal(ResultSet result) throws SQLException { + HusbandryAnimal animal = new HusbandryAnimal( + UUID.fromString(result.getString("uuid")), + result.getString("type"), + result.getString("name")); + animal.setState(HusbandryAnimalState.fromStorage(result.getString("state"))); + animal.setGenetics(result.getInt("genetics")); + animal.setCare(result.getInt("care")); + animal.setHungrySince(nullableLong(result, "hungry_since")); + animal.setDirtySince(nullableLong(result, "dirty_since")); + animal.setLastProcessedAt(result.getLong("last_processed_at")); + animal.setUnloadedAt(nullableLong(result, "unloaded_at")); + animal.setAfflictionElapsed(result.getDouble("affliction_elapsed")); + animal.setAfflictionAt(result.getDouble("affliction_at")); + animal.setLastMilkAt(nullableLong(result, "last_milk_at")); + animal.setWoolReadyAt(nullableLong(result, "wool_ready_at")); + animal.setNeutered(result.getInt("neutered") != 0); + animal.setLoadedVisitStart(nullableLong(result, "loaded_visit_start")); + animal.setMatureAt(nullableLong(result, "mature_at")); + animal.setShedReadyAt(nullableLong(result, "shed_ready_at")); + animal.setEggReadyAt(nullableLong(result, "egg_ready_at")); + animal.setCareUpRemainderSeconds(intOrZero(result, "care_up_remainder")); + animal.setCareDownRemainderSeconds(intOrZero(result, "care_down_remainder")); + animal.setStatsRevision(nullableString(result, "stats_revision")); + animal.setLastPetAt(nullableLong(result, "last_pet_at")); + animal.setMountGenes(nullableInt(result, "gene_health"), nullableInt(result, "gene_speed"), + nullableInt(result, "gene_jump")); + animal.setStoredLocation( + nullableString(result, "world"), + nullableInt(result, "x"), + nullableInt(result, "y"), + nullableInt(result, "z")); + return animal; + } + + private static Long nullableLong(ResultSet result, String column) throws SQLException { + long value = result.getLong(column); + return result.wasNull() ? null : value; + } + + private static String blankToNull(String value) { + if (value == null || value.isBlank()) { + return null; + } + return value; + } + + private static String nullableString(ResultSet result, String column) throws SQLException { + try { + String value = result.getString(column); + if (value == null || value.isBlank()) { + return null; + } + return value; + } catch (SQLException ex) { + return null; + } + } + + private static Integer nullableInt(ResultSet result, String column) throws SQLException { + try { + int value = result.getInt(column); + return result.wasNull() ? null : value; + } catch (SQLException ex) { + return null; + } + } + + private static int intOrZero(ResultSet result, String column) throws SQLException { + try { + int value = result.getInt(column); + return result.wasNull() ? 0 : value; + } catch (SQLException ex) { + return 0; + } + } + + private int executeUpdate(String sql, Object... params) { + synchronized (database) { + try (PreparedStatement statement = database.getConnection().prepareStatement(sql)) { + bindParams(statement, params); + return statement.executeUpdate(); + } catch (SQLException e) { + throw new SqliteDatabaseException("Failed to execute update: " + sql, e); + } + } + } + + private Optional queryOne(String sql, ResultSetFunction mapper, Object... params) { + List rows = queryList(sql, mapper, params); + if (rows.isEmpty()) { + return Optional.empty(); + } + return Optional.of(rows.get(0)); + } + + private List queryList(String sql, ResultSetFunction mapper, Object... params) { + List rows = new ArrayList<>(); + synchronized (database) { + try (PreparedStatement statement = database.getConnection().prepareStatement(sql)) { + bindParams(statement, params); + try (ResultSet result = statement.executeQuery()) { + while (result.next()) { + rows.add(mapper.apply(result)); + } + } + } catch (SQLException e) { + throw new SqliteDatabaseException("Failed to query: " + sql, e); + } + } + return rows; + } + + private static void bindParams(PreparedStatement statement, Object... params) throws SQLException { + for (int i = 0; i < params.length; i++) { + Object value = params[i]; + if (value == null) { + statement.setNull(i + 1, Types.NULL); + } else { + statement.setObject(i + 1, value); + } + } + } + + @FunctionalInterface + private interface ResultSetFunction { + T apply(ResultSet result) throws SQLException; + } +} diff --git a/src/main/java/net/tfminecraft/breedingbuddies/husbandry/HusbandryRoster.java b/src/main/java/net/tfminecraft/breedingbuddies/husbandry/HusbandryRoster.java new file mode 100644 index 0000000..885373c --- /dev/null +++ b/src/main/java/net/tfminecraft/breedingbuddies/husbandry/HusbandryRoster.java @@ -0,0 +1,162 @@ +package net.tfminecraft.breedingbuddies.husbandry; + +import java.util.ArrayList; +import java.util.Comparator; +import java.util.List; +import java.util.Locale; +import java.util.UUID; +import java.util.function.Predicate; + +import net.kyori.adventure.text.Component; +import net.kyori.adventure.text.event.ClickEvent; +import net.kyori.adventure.text.event.HoverEvent; +import net.kyori.adventure.text.format.NamedTextColor; +import net.kyori.adventure.text.format.TextDecoration; + +public final class HusbandryRoster { + + private HusbandryRoster() {} + + public static List render( + String ownerName, + boolean self, + List owned, + int maxAnimals, + long nowMillis) { + return render(ownerName, self, owned, maxAnimals, nowMillis, uuid -> false); + } + + public static List render( + String ownerName, + boolean self, + List owned, + int maxAnimals, + long nowMillis, + Predicate missing) { + List rows = owned == null ? List.of() : owned; + int shown = rows.size(); + int cap = Math.max(1, maxAnimals); + String shownName = plain(ownerName); + String title = self || shownName.isEmpty() ? "Your animals" : shownName + "'s animals"; + List lines = new ArrayList<>(); + lines.add(Component.text(title + " ", NamedTextColor.GOLD) + .append(Component.text("(" + shown + "/" + cap + ")", NamedTextColor.GRAY))); + if (rows.isEmpty()) { + if (self) { + lines.add(Component.text("You do not own any animals yet.", NamedTextColor.GRAY)); + lines.add(Component.text("Claim one with a named Ownership Token.", NamedTextColor.GRAY)); + } else { + String who = shownName.isEmpty() ? "That player" : shownName; + lines.add(Component.text(who + " does not own any animals.", NamedTextColor.GRAY)); + } + return lines; + } + List sorted = new ArrayList<>(rows); + sorted.sort(Comparator + .comparing((HusbandryOwned row) -> sortLabel(row).toLowerCase(Locale.ROOT)) + .thenComparing(row -> speciesLabel(row.animal().type()).toLowerCase(Locale.ROOT)) + .thenComparing(row -> row.animal().uuid() == null ? "" : row.animal().uuid().toString())); + for (HusbandryOwned row : sorted) { + lines.add(line(row, nowMillis, missing.test(row.animal().uuid()))); + } + return lines; + } + + static String speciesLabel(String type) { + if (type == null || type.isBlank()) { + return "Animal"; + } + String raw = type.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)); + } + } + return out.isEmpty() ? "Animal" : out.toString(); + } + + private static String sortLabel(HusbandryOwned owned) { + String name = plain(owned.animal().name()); + if (!name.isEmpty()) { + return name; + } + return speciesLabel(owned.animal().type()); + } + + private static Component line(HusbandryOwned owned, long nowMillis, boolean missing) { + HusbandryAnimal animal = owned.animal(); + String species = speciesLabel(animal.type()); + String name = plain(animal.name()); + boolean named = !name.isEmpty() && !name.equalsIgnoreCase(species); + Component row = Component.text("• ", NamedTextColor.GOLD) + .append(Component.text(named ? name : species, NamedTextColor.WHITE)); + if (named) { + row = row.append(sep()).append(Component.text(species, NamedTextColor.GRAY)); + } + if ("coowner".equals(owned.role())) { + row = row.append(sep()).append(Component.text("Co-owner", NamedTextColor.GRAY)); + } + if (!HusbandryGrowth.isMature(animal, nowMillis)) { + row = row.append(sep()).append(Component.text("Growing", NamedTextColor.GRAY)); + } + row = row.append(sep()).append(status(animal)); + if (missing) { + row = row.append(sep()).append(Component.text("Missing", NamedTextColor.RED) + .hoverEvent(HoverEvent.showText(Component.text( + "Not found in any saved chunk when the server started.")))); + if (!animal.hasLocation()) { + return row; + } + return row.append(sep()).append(Component.text("last seen ", NamedTextColor.GRAY)).append(place(animal)); + } + return row.append(sep()).append(place(animal)); + } + + private static Component status(HusbandryAnimal animal) { + boolean hungry = animal.hungrySince() != null; + boolean dirty = animal.dirtySince() != null; + if (hungry && dirty) { + return Component.text("Hungry, Dirty", NamedTextColor.RED); + } + if (hungry) { + return Component.text("Hungry", NamedTextColor.GOLD); + } + if (dirty) { + return Component.text("Dirty", NamedTextColor.YELLOW); + } + return Component.text("Happy", NamedTextColor.GREEN); + } + + private static Component place(HusbandryAnimal animal) { + if (!animal.hasLocation()) { + return Component.text("location not recorded yet", NamedTextColor.GRAY) + .decorate(TextDecoration.ITALIC); + } + String coords = animal.x() + ", " + animal.y() + ", " + animal.z(); + String copy = animal.world() + " " + coords; + return Component.text(animal.world(), NamedTextColor.GRAY) + .append(Component.space()) + .append(Component.text(coords, NamedTextColor.WHITE) + .clickEvent(ClickEvent.copyToClipboard(copy)) + .hoverEvent(HoverEvent.showText(Component.text("Click to copy coordinates")))); + } + + private static Component sep() { + return Component.text(" · ", NamedTextColor.DARK_GRAY); + } + + static String plain(String value) { + if (value == null || value.isBlank()) { + return ""; + } + return value.replaceAll("(?i)§[0-9a-fk-or]", "").replace('\n', ' ').replace('\r', ' ').trim(); + } +} diff --git a/src/main/java/net/tfminecraft/breedingbuddies/husbandry/HusbandryShed.java b/src/main/java/net/tfminecraft/breedingbuddies/husbandry/HusbandryShed.java new file mode 100644 index 0000000..84b57ba --- /dev/null +++ b/src/main/java/net/tfminecraft/breedingbuddies/husbandry/HusbandryShed.java @@ -0,0 +1,45 @@ +package net.tfminecraft.breedingbuddies.husbandry; + +import java.util.concurrent.ThreadLocalRandom; + +import org.bukkit.entity.LivingEntity; +import org.bukkit.inventory.ItemStack; + +final class HusbandryShed { + + private HusbandryShed() {} + + static void tryShed(LivingEntity entity, HusbandryAnimal animal, long nowMillis) { + if (entity == null || animal == null) { + return; + } + if (!HusbandryGrowth.isMature(animal, nowMillis) || !HusbandrySimulator.isHappy(animal)) { + return; + } + org.bukkit.entity.EntityType type; + try { + type = org.bukkit.entity.EntityType.valueOf(animal.type()); + } catch (IllegalArgumentException ex) { + return; + } + HusbandrySpecies species = HusbandryConfig.species(type); + if (species == null || !species.canShed()) { + return; + } + Long readyAt = animal.shedReadyAt(); + if (readyAt != null && readyAt > nowMillis) { + return; + } + if (Math.random() >= HusbandryConfig.shedChance()) { + animal.setShedReadyAt(nowMillis + HusbandryConfig.shedTimerSeconds() * 1000L); + return; + } + HusbandryFx.playShed(entity); + for (ItemStack stack : HusbandryDropRoller.rollShedDrops(animal, ThreadLocalRandom.current())) { + if (stack != null) { + entity.getWorld().dropItemNaturally(entity.getLocation(), stack); + } + } + animal.setShedReadyAt(nowMillis + HusbandryConfig.shedTimerSeconds() * 1000L); + } +} diff --git a/src/main/java/net/tfminecraft/breedingbuddies/husbandry/HusbandrySimulator.java b/src/main/java/net/tfminecraft/breedingbuddies/husbandry/HusbandrySimulator.java new file mode 100644 index 0000000..70014d0 --- /dev/null +++ b/src/main/java/net/tfminecraft/breedingbuddies/husbandry/HusbandrySimulator.java @@ -0,0 +1,199 @@ +package net.tfminecraft.breedingbuddies.husbandry; + +import java.util.Random; +import java.util.concurrent.ThreadLocalRandom; + +public final class HusbandrySimulator { + + private static final long MILLIS_PER_HOUR = 3_600_000L; + + private HusbandrySimulator() {} + + public static boolean visitLongEnough(HusbandryAnimal animal, long nowMillis) { + if (animal == null) { + return false; + } + Long visitStart = animal.loadedVisitStart(); + if (visitStart == null) { + return false; + } + long minMillis = HusbandryConfig.minLoadedSeconds() * 1000L; + return nowMillis - visitStart >= minMillis; + } + + public static boolean isHappy(HusbandryAnimal animal) { + return animal != null && animal.hungrySince() == null && animal.dirtySince() == null; + } + + public static void catchUp(HusbandryAnimal animal, long nowMillis, Random random) { + simulate(animal, nowMillis, random, true); + } + + public static void tickLoaded(HusbandryAnimal animal, long nowMillis) { + tickLoaded(animal, nowMillis, ThreadLocalRandom.current()); + } + + public static void tickLoaded(HusbandryAnimal animal, long nowMillis, Random random) { + simulate(animal, nowMillis, random, false); + } + + public static void resetAfflictionCycle(HusbandryAnimal animal, Random random) { + if (animal == null) { + return; + } + animal.setAfflictionElapsed(0); + animal.setAfflictionAt(rollAfflictionHours(random)); + } + + public static void clearHungry(HusbandryAnimal animal, Random random) { + if (animal == null) { + return; + } + animal.setHungrySince(null); + if (isHappy(animal)) { + resetAfflictionCycle(animal, random); + } + } + + public static void clearDirty(HusbandryAnimal animal, Random random) { + if (animal == null) { + return; + } + animal.setDirtySince(null); + if (isHappy(animal)) { + resetAfflictionCycle(animal, random); + } + } + + private static void simulate(HusbandryAnimal animal, long nowMillis, Random random, boolean includeUnloaded) { + if (animal == null) { + return; + } + Random rng = random == null ? ThreadLocalRandom.current() : random; + if (nowMillis < animal.lastProcessedAt()) { + animal.setLastProcessedAt(nowMillis); + return; + } + + long elapsedMillis = nowMillis - animal.lastProcessedAt(); + long unloadedMillis = 0L; + if (includeUnloaded && animal.unloadedAt() != null) { + unloadedMillis = Math.min(elapsedMillis, Math.max(0L, nowMillis - animal.unloadedAt())); + } + long loadedMillis = Math.max(0L, elapsedMillis - unloadedMillis); + double loadedHours = loadedMillis / (double) MILLIS_PER_HOUR; + + if (unloadedMillis / 1000L > HusbandryConfig.longUnloadForceSeconds()) { + applyLongUnloadForce(animal, nowMillis, rng); + } + applyAffliction(animal, loadedHours, nowMillis, rng); + applyDecay(animal, animal.lastProcessedAt(), nowMillis); + animal.setLastProcessedAt(nowMillis); + } + + private static void applyDecay(HusbandryAnimal animal, long lastProcessed, long nowMillis) { + Long hungry = animal.hungrySince(); + Long dirty = animal.dirtySince(); + if (hungry == null && dirty == null) { + return; + } + long earliest = hungry == null ? dirty : (dirty == null ? hungry : Math.min(hungry, dirty)); + long graceMillis = HusbandryConfig.decayGraceSeconds() * 1000L; + long decayStart = earliest + graceMillis; + long decayFrom = Math.max(lastProcessed, decayStart); + if (nowMillis <= decayFrom) { + return; + } + long decaySeconds = (nowMillis - decayFrom) / 1000L; + applyDecayJumps(animal, decaySeconds, HusbandryConfig.careDownIntervalSeconds(), HusbandryConfig.careDownAmount()); + } + + /** Trust is only earned by care actions (HusbandryTrust); time only takes it away from neglected animals. */ + private static void applyDecayJumps(HusbandryAnimal animal, long elapsedSeconds, int intervalSeconds, double amount) { + if (elapsedSeconds <= 0 || intervalSeconds <= 0 || amount <= 0) { + return; + } + long remainder = animal.careDownRemainderSeconds() + elapsedSeconds; + long jumps = remainder / intervalSeconds; + animal.setCareDownRemainderSeconds(remainder % intervalSeconds); + if (jumps <= 0) { + return; + } + addCare(animal, -jumps * amount); + } + + private static void applyLongUnloadForce(HusbandryAnimal animal, long nowMillis, Random random) { + boolean hungry = animal.hungrySince() != null; + boolean dirty = animal.dirtySince() != null; + if (hungry && dirty) { + resetAfflictionCycle(animal, random); + return; + } + if (!hungry && !dirty) { + if (random.nextBoolean()) { + animal.setHungrySince(nowMillis); + } else { + animal.setDirtySince(nowMillis); + } + } else if (!hungry) { + animal.setHungrySince(nowMillis); + } else { + animal.setDirtySince(nowMillis); + } + resetAfflictionCycle(animal, random); + } + + private static void applyAffliction(HusbandryAnimal animal, double loadedHours, long nowMillis, Random random) { + if (loadedHours <= 0) { + return; + } + if (animal.afflictionAt() <= 0) { + animal.setAfflictionAt(rollAfflictionHours(random)); + } + animal.setAfflictionElapsed(animal.afflictionElapsed() + loadedHours); + while (animal.hungrySince() == null || animal.dirtySince() == null) { + if (animal.afflictionElapsed() < animal.afflictionAt()) { + break; + } + applyMissingState(animal, nowMillis, random); + animal.setAfflictionElapsed(animal.afflictionElapsed() - animal.afflictionAt()); + animal.setAfflictionAt(rollAfflictionHours(random)); + } + if (animal.hungrySince() != null && animal.dirtySince() != null) { + animal.setAfflictionElapsed(0); + } + } + + private static void applyMissingState(HusbandryAnimal animal, long nowMillis, Random random) { + boolean hungry = animal.hungrySince() != null; + boolean dirty = animal.dirtySince() != null; + if (!hungry && !dirty) { + if (random.nextBoolean()) { + animal.setHungrySince(nowMillis); + } else { + animal.setDirtySince(nowMillis); + } + return; + } + if (!hungry) { + animal.setHungrySince(nowMillis); + } else { + animal.setDirtySince(nowMillis); + } + } + + private static double rollAfflictionHours(Random random) { + double min = HusbandryConfig.afflictionMinHours(); + double max = HusbandryConfig.afflictionMaxHours(); + if (max <= min) { + return min; + } + return min + random.nextDouble() * (max - min); + } + + private static void addCare(HusbandryAnimal animal, double delta) { + int next = (int) Math.round(animal.care() + delta); + int max = HusbandryConfig.careMax(); + animal.setCare(Math.max(0, Math.min(max, next))); + } +} diff --git a/src/main/java/net/tfminecraft/breedingbuddies/husbandry/HusbandrySlaughterDrops.java b/src/main/java/net/tfminecraft/breedingbuddies/husbandry/HusbandrySlaughterDrops.java new file mode 100644 index 0000000..8bc1fc8 --- /dev/null +++ b/src/main/java/net/tfminecraft/breedingbuddies/husbandry/HusbandrySlaughterDrops.java @@ -0,0 +1,14 @@ +package net.tfminecraft.breedingbuddies.husbandry; + +public final class HusbandrySlaughterDrops { + + private HusbandrySlaughterDrops() {} + + public static boolean shouldReplaceVanilla(boolean hasSlaughter, boolean mature) { + return hasSlaughter && mature; + } + + public static boolean shouldAddRoast(boolean hasSlaughter, boolean mature) { + return hasSlaughter && mature; + } +} diff --git a/src/main/java/net/tfminecraft/breedingbuddies/husbandry/HusbandrySnapshot.java b/src/main/java/net/tfminecraft/breedingbuddies/husbandry/HusbandrySnapshot.java new file mode 100644 index 0000000..91d4716 --- /dev/null +++ b/src/main/java/net/tfminecraft/breedingbuddies/husbandry/HusbandrySnapshot.java @@ -0,0 +1,4 @@ +package net.tfminecraft.breedingbuddies.husbandry; + +/** A serialized copy of an owned animal's entity, taken while it was loaded. */ +public record HusbandrySnapshot(byte[] data, long savedAt) {} diff --git a/src/main/java/net/tfminecraft/breedingbuddies/husbandry/HusbandrySnapshots.java b/src/main/java/net/tfminecraft/breedingbuddies/husbandry/HusbandrySnapshots.java new file mode 100644 index 0000000..c5c9664 --- /dev/null +++ b/src/main/java/net/tfminecraft/breedingbuddies/husbandry/HusbandrySnapshots.java @@ -0,0 +1,143 @@ +package net.tfminecraft.breedingbuddies.husbandry; + +import java.time.Instant; +import java.util.List; +import java.util.Map; +import java.util.Optional; +import java.util.UUID; + +import org.bukkit.Bukkit; +import org.bukkit.Location; +import org.bukkit.World; +import org.bukkit.entity.Entity; +import org.bukkit.event.entity.CreatureSpawnEvent; +import org.bukkit.event.entity.EntityRemoveEvent; + +import io.papermc.paper.entity.EntitySerializationFlag; +import net.tfminecraft.breedingbuddies.BreedingBuddies; +import net.tfminecraft.tlibs.database.SqliteDatabaseException; + +/** + * Keeps a serialized copy of every loaded owned animal so one that a crash or forced stop + * drops from the world save can be spawned again, instead of being deleted as a ghost. + */ +public final class HusbandrySnapshots { + + enum RestoreResult { SKIPPED, SCHEDULED, FAILED } + + private HusbandrySnapshots() {} + + public static boolean shouldCapture(HusbandryAnimal animal) { + return animal != null && animal.state() == HusbandryAnimalState.OWNED; + } + + /** Returns the entity bytes, or null when Paper refuses to serialize it. */ + @SuppressWarnings("deprecation") + public static byte[] capture(Entity entity, boolean force) { + if (entity == null) { + return null; + } + try { + return force + ? Bukkit.getUnsafe().serializeEntity(entity, EntitySerializationFlag.FORCE) + : Bukkit.getUnsafe().serializeEntity(entity); + } catch (RuntimeException ex) { + return null; + } + } + + public static void save(HusbandryRepository repository, Map snapshots) { + if (repository == null || snapshots.isEmpty()) { + return; + } + try { + repository.upsertSnapshots(snapshots, System.currentTimeMillis()); + } catch (SqliteDatabaseException ex) { + Bukkit.getLogger().severe("[BreedingBuddies] Failed to save husbandry snapshots: " + ex.getMessage()); + } + } + + /** + * Chunk unloads and riders logging out leave the animal in a save file, so its snapshot stays. + * Any other removal is deliberate, and restoring the animal later would bring it back. + */ + public static boolean keepsSnapshot(EntityRemoveEvent.Cause cause) { + return cause == EntityRemoveEvent.Cause.UNLOAD || cause == EntityRemoveEvent.Cause.PLAYER_QUIT; + } + + /** + * Spawns a lost animal from its snapshot at the place it was captured. + * SKIPPED means restoration is disabled or there is no snapshot. FAILED preserves the records for + * retry or staff review; SCHEDULED means the snapshot is waiting for its chunk to load. + */ + @SuppressWarnings("deprecation") + static RestoreResult restore(HusbandryRepository repository, HusbandryAnimal animal, List owners) { + if (repository == null || animal == null || BreedingBuddies.plugin == null) { + return RestoreResult.FAILED; + } + if (!HusbandryConfig.restoreLostAnimals()) { + return RestoreResult.SKIPPED; + } + Optional snapshot = repository.getSnapshot(animal.uuid()); + if (snapshot.isEmpty()) { + return RestoreResult.SKIPPED; + } + World world = animal.world() == null ? null : Bukkit.getWorld(animal.world()); + if (world == null) { + Bukkit.getLogger().severe("[BreedingBuddies] Snapshot of " + animal.uuid() + + " has no loaded world to restore into; records kept for staff review."); + return RestoreResult.FAILED; + } + Entity entity; + try { + entity = Bukkit.getUnsafe().deserializeEntity(snapshot.get().data(), world, true); + } catch (RuntimeException ex) { + Bukkit.getLogger().severe("[BreedingBuddies] Could not read the snapshot of " + animal.uuid() + + ": " + ex.getMessage()); + return RestoreResult.FAILED; + } + if (entity == null || !entity.getUniqueId().equals(animal.uuid())) { + Bukkit.getLogger().severe("[BreedingBuddies] Snapshot of " + animal.uuid() + + " does not contain the expected entity; records kept for staff review."); + return RestoreResult.FAILED; + } + Location at = entity.getLocation(); + at.setWorld(world); + world.getChunkAtAsync(at.getBlockX() >> 4, at.getBlockZ() >> 4).whenComplete((chunk, error) -> { + if (BreedingBuddies.plugin == null || !BreedingBuddies.plugin.isEnabled()) { + return; + } + Bukkit.getScheduler().runTask(BreedingBuddies.plugin, () -> { + if (error != null) { + Bukkit.getLogger().severe("[BreedingBuddies] Could not load the chunk to restore " + animal.uuid() + + ": " + error.getMessage()); + HusbandryLocator.markMissing(animal.uuid()); + return; + } + finish(animal, owners, entity, at, snapshot.get().savedAt()); + }); + }); + return RestoreResult.SCHEDULED; + } + + static void finish(HusbandryAnimal animal, List owners, Entity entity, Location at, long savedAt) { + if (Bukkit.getEntity(animal.uuid()) != null) { + // It came back with its chunk after all, so spawning the copy would duplicate it. + return; + } + if (!entity.spawnAt(at, CreatureSpawnEvent.SpawnReason.CUSTOM)) { + Bukkit.getLogger().warning("[BreedingBuddies] Could not respawn lost animal " + animal.uuid() + + "; it stays missing and is retried at the next start."); + HusbandryLocator.markMissing(animal.uuid()); + return; + } + HusbandryLifecycleListener.handleLoad(entity); + Bukkit.getLogger().warning(restoreLog(animal, owners, at, savedAt)); + } + + static String restoreLog(HusbandryAnimal animal, List owners, Location at, long savedAt) { + return "[BreedingBuddies] Restored lost animal " + HusbandryLocator.describe(animal, owners) + + " at " + at.getWorld().getName() + " " + at.getBlockX() + ", " + at.getBlockY() + ", " + + at.getBlockZ() + " from its snapshot of " + Instant.ofEpochMilli(savedAt); + } +} diff --git a/src/main/java/net/tfminecraft/breedingbuddies/husbandry/HusbandrySpawner.java b/src/main/java/net/tfminecraft/breedingbuddies/husbandry/HusbandrySpawner.java new file mode 100644 index 0000000..57ddc8e --- /dev/null +++ b/src/main/java/net/tfminecraft/breedingbuddies/husbandry/HusbandrySpawner.java @@ -0,0 +1,84 @@ +package net.tfminecraft.breedingbuddies.husbandry; + +import java.util.concurrent.ThreadLocalRandom; + +import org.bukkit.Location; +import org.bukkit.entity.Entity; +import org.bukkit.entity.EntityType; +import org.bukkit.entity.LivingEntity; +import org.bukkit.entity.Player; + +public final class HusbandrySpawner { + + private HusbandrySpawner() {} + + // Keep the existing legacy text representation, formatting, and exact-string comparisons. + @SuppressWarnings("deprecation") + public static LivingEntity spawn(Player player, EntityType type, int genetics, int care) { + if (player == null || type == null || type.getEntityClass() == null + || !LivingEntity.class.isAssignableFrom(type.getEntityClass())) { + return null; + } + Location at = player.getLocation(); + 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)); + HusbandryAnimal animal = createRecord(living, clampedGenetics, clampedCare); + if (animal == null) { + living.remove(); + return null; + } + living.setCustomName(animal.name()); + living.setCustomNameVisible(false); + return living; + } + + public static HusbandryAnimal createWildRecord(LivingEntity living) { + int max = HusbandryConfig.initialGeneticMax(); + int genetics = max <= 0 ? 0 : ThreadLocalRandom.current().nextInt(max + 1); + return create(living, genetics, 0, true); + } + + /** Staff-chosen genetics; a mount gets it for all three stats. */ + public static HusbandryAnimal createRecord(LivingEntity living, int genetics, int care) { + return create(living, genetics, care, false); + } + + private static HusbandryAnimal create(LivingEntity living, int genetics, int care, boolean wild) { + if (living == null) { + return null; + } + HusbandryRepository repository = HusbandryEntities.repository(); + if (repository == null) { + return null; + } + int clampedGenetics = Math.max(0, Math.min(HusbandryConfig.maxGenetics(), genetics)); + int clampedCare = Math.max(0, Math.min(HusbandryConfig.careMax(), care)); + String name = HusbandryEntities.displayName(living.getType()); + HusbandryEntities.applyPersistFlags(living); + HusbandryEntities.stampManaged(living); + + long now = System.currentTimeMillis(); + HusbandryAnimal animal = new HusbandryAnimal(living.getUniqueId(), living.getType().name(), name); + animal.setState(HusbandryAnimalState.UNTAMED); + animal.setGenetics(clampedGenetics); + if (HusbandryMounts.isMount(living)) { + if (wild) { + HusbandryMountGenes.rollWild(animal, ThreadLocalRandom.current()); + } else { + HusbandryMountGenes.set(animal, clampedGenetics, clampedGenetics, clampedGenetics); + } + } + animal.setCare(clampedCare); + animal.setStatsRevision(HusbandryConfig.statsRevision()); + animal.setLastProcessedAt(now); + animal.setLoadedVisitStart(now); + animal.setUnloadedAt(null); + HusbandryHarvest.prepareNewAnimal(animal, living.getType()); + repository.upsertAnimal(animal); + HusbandryEntities.putLoaded(animal); + HusbandryMounts.applyStats(living, animal); + return animal; + } +} diff --git a/src/main/java/net/tfminecraft/breedingbuddies/husbandry/HusbandrySpecies.java b/src/main/java/net/tfminecraft/breedingbuddies/husbandry/HusbandrySpecies.java new file mode 100644 index 0000000..5e058b9 --- /dev/null +++ b/src/main/java/net/tfminecraft/breedingbuddies/husbandry/HusbandrySpecies.java @@ -0,0 +1,122 @@ +package net.tfminecraft.breedingbuddies.husbandry; + +import org.bukkit.entity.EntityType; + +public final class HusbandrySpecies { + + private final EntityType type; + private final boolean milk; + private final String slaughterMeat; + private final HusbandryDropTable slaughterDrops; + private final HusbandryDropTable shearDrops; + private final HusbandryDropTable shedDrops; + private final String egg; + private final int growUpSeconds; + private final int woolTimerSeconds; + private final int milkTimerSeconds; + private final HusbandryExpBracket exp; + + public HusbandrySpecies( + EntityType type, + boolean milk, + String slaughterMeat, + HusbandryDropTable slaughterDrops, + HusbandryDropTable shearDrops, + HusbandryDropTable shedDrops, + String egg, + int growUpSeconds, + int woolTimerSeconds, + int milkTimerSeconds) { + this(type, milk, slaughterMeat, slaughterDrops, shearDrops, shedDrops, egg, + growUpSeconds, woolTimerSeconds, milkTimerSeconds, null); + } + + public HusbandrySpecies( + EntityType type, + boolean milk, + String slaughterMeat, + HusbandryDropTable slaughterDrops, + HusbandryDropTable shearDrops, + HusbandryDropTable shedDrops, + String egg, + int growUpSeconds, + int woolTimerSeconds, + int milkTimerSeconds, + HusbandryExpBracket exp) { + this.type = type; + this.milk = milk; + this.slaughterMeat = slaughterMeat == null ? "" : slaughterMeat; + this.slaughterDrops = slaughterDrops == null ? HusbandryDropTable.empty() : slaughterDrops; + this.shearDrops = shearDrops == null ? HusbandryDropTable.empty() : shearDrops; + this.shedDrops = shedDrops == null ? HusbandryDropTable.empty() : shedDrops; + this.egg = egg == null ? "" : egg; + this.growUpSeconds = Math.max(0, growUpSeconds); + this.woolTimerSeconds = Math.max(0, woolTimerSeconds); + this.milkTimerSeconds = Math.max(0, milkTimerSeconds); + this.exp = exp; + } + + public EntityType type() { + return type; + } + + public boolean canMilk() { + return milk; + } + + public boolean canSlaughter() { + return !slaughterMeat.isBlank(); + } + + public boolean canShear() { + return !shearDrops.isEmpty(); + } + + public boolean canShed() { + return !shedDrops.isEmpty(); + } + + public boolean hasEgg() { + return !egg.isBlank(); + } + + public String slaughterMeat() { + return slaughterMeat; + } + + public HusbandryDropTable slaughterDrops() { + return slaughterDrops; + } + + public HusbandryDropTable shearDrops() { + return shearDrops; + } + + public HusbandryDropTable shedDrops() { + return shedDrops; + } + + public String egg() { + return egg; + } + + public int growUpSeconds() { + return growUpSeconds; + } + + public int woolTimerSeconds() { + return woolTimerSeconds; + } + + public int milkTimerSeconds() { + return milkTimerSeconds; + } + + public HusbandryExpBracket exp() { + return exp; + } + + public boolean vanillaEggs() { + return egg.isBlank() || "vanilla".equalsIgnoreCase(egg); + } +} diff --git a/src/main/java/net/tfminecraft/breedingbuddies/husbandry/HusbandryStateDisplay.java b/src/main/java/net/tfminecraft/breedingbuddies/husbandry/HusbandryStateDisplay.java new file mode 100644 index 0000000..b28dd2e --- /dev/null +++ b/src/main/java/net/tfminecraft/breedingbuddies/husbandry/HusbandryStateDisplay.java @@ -0,0 +1,78 @@ +package net.tfminecraft.breedingbuddies.husbandry; + +import java.util.List; + +import org.bukkit.NamespacedKey; +import org.bukkit.entity.Entity; +import org.bukkit.entity.LivingEntity; +import org.bukkit.entity.TextDisplay; +import org.bukkit.persistence.PersistentDataType; + + +public final class HusbandryStateDisplay { + + /** TextDisplay passengers from Cooking's old affliction UI. */ + private static final NamespacedKey LEGACY_STATE_LABEL = + new NamespacedKey(HusbandryKeys.COOKING, "state_label"); + + private HusbandryStateDisplay() {} + + // Keep the existing legacy text representation, formatting, and exact-string comparisons. + @SuppressWarnings("deprecation") + public static void sync(LivingEntity entity, HusbandryAnimal animal) { + if (entity == null || !entity.isValid() || animal == null) { + return; + } + removeLegacyTextDisplays(entity); + String baseName = resolveBaseName(animal, entity); + String suffix = afflictionSuffix(animal); + if (suffix == null) { + entity.setCustomName(baseName); + entity.setCustomNameVisible(false); + return; + } + entity.setCustomName(baseName + " " + suffix); + entity.setCustomNameVisible(true); + } + + public static void removeAll(LivingEntity entity) { + if (entity == null || !entity.isValid()) { + return; + } + removeLegacyTextDisplays(entity); + } + + private static void removeLegacyTextDisplays(LivingEntity entity) { + for (Entity passenger : List.copyOf(entity.getPassengers())) { + if (passenger instanceof TextDisplay display && legacyLabelOf(display) != null) { + display.remove(); + } + } + } + + private static String resolveBaseName(HusbandryAnimal animal, LivingEntity entity) { + if (animal.name() != null && !animal.name().isBlank() && !"???".equals(animal.name().trim())) { + return animal.name(); + } + return HusbandryEntities.displayName(entity.getType()); + } + + private static String afflictionSuffix(HusbandryAnimal animal) { + boolean hungry = animal.hungrySince() != null; + boolean dirty = animal.dirtySince() != null; + if (hungry && dirty) { + return "(Hungry, Dirty)"; + } + if (hungry) { + return "(Hungry)"; + } + if (dirty) { + return "(Dirty)"; + } + return null; + } + + private static String legacyLabelOf(TextDisplay display) { + return display.getPersistentDataContainer().get(LEGACY_STATE_LABEL, PersistentDataType.STRING); + } +} diff --git a/src/main/java/net/tfminecraft/breedingbuddies/husbandry/HusbandryTamingListener.java b/src/main/java/net/tfminecraft/breedingbuddies/husbandry/HusbandryTamingListener.java new file mode 100644 index 0000000..2091894 --- /dev/null +++ b/src/main/java/net/tfminecraft/breedingbuddies/husbandry/HusbandryTamingListener.java @@ -0,0 +1,218 @@ +package net.tfminecraft.breedingbuddies.husbandry; + +import java.util.Optional; +import java.util.UUID; + +import org.bukkit.ChatColor; +import org.bukkit.entity.Entity; +import org.bukkit.entity.LivingEntity; +import org.bukkit.entity.Player; +import org.bukkit.entity.Tameable; +import org.bukkit.event.EventHandler; +import org.bukkit.event.Listener; +import org.bukkit.event.inventory.PrepareAnvilEvent; +import org.bukkit.event.player.PlayerInteractEntityEvent; +import org.bukkit.event.player.PlayerInteractEvent; +import org.bukkit.inventory.EquipmentSlot; +import org.bukkit.inventory.ItemStack; +import org.bukkit.inventory.view.AnvilView; + +public final class HusbandryTamingListener implements Listener { + + @EventHandler + public void onPrepareAnvil(PrepareAnvilEvent event) { + ItemStack result = event.getResult(); + if (result == null || !HusbandryItems.matches(result, HusbandryConfig.tameItem())) { + return; + } + AnvilView anvil = event.getView(); + String rename = anvil.getRenameText(); + if (rename == null || rename.isBlank()) { + return; + } + HusbandryItems.setTameName(result, rename); + event.setResult(result); + } + + @EventHandler(ignoreCancelled = true) + public void onInteractEntity(PlayerInteractEntityEvent event) { + if (event.getHand() != EquipmentSlot.HAND) { + return; + } + Player player = event.getPlayer(); + ItemStack hand = player.getInventory().getItemInMainHand(); + Entity clicked = event.getRightClicked(); + + if (HusbandryItems.matches(hand, HusbandryConfig.coOwnItem())) { + event.setCancelled(true); + if (acceptsLinkedToken(player, hand)) { + handleCoOwnOnPlayer(player, player, hand); + } else if (clicked instanceof Player target) { + handleCoOwnOnPlayer(player, target, hand); + } else if (clicked instanceof LivingEntity living) { + handleCoOwnLink(player, living, hand); + } + return; + } + + if (!HusbandryItems.matches(hand, HusbandryConfig.tameItem())) { + return; + } + event.setCancelled(true); + if (!(clicked instanceof LivingEntity living)) { + return; + } + handleTame(player, living, hand); + } + + @EventHandler + public void onInteractUse(PlayerInteractEvent event) { + if (event.getHand() != EquipmentSlot.HAND) { + return; + } + if (event.getAction() != org.bukkit.event.block.Action.RIGHT_CLICK_AIR + && event.getAction() != org.bukkit.event.block.Action.RIGHT_CLICK_BLOCK) { + return; + } + ItemStack hand = event.getItem(); + if (!HusbandryItems.matches(hand, HusbandryConfig.coOwnItem())) { + return; + } + if (!acceptsLinkedToken(event.getPlayer(), hand)) { + return; + } + event.setCancelled(true); + handleCoOwnOnPlayer(event.getPlayer(), event.getPlayer(), hand); + } + + private static boolean acceptsLinkedToken(Player player, ItemStack hand) { + UUID linked = HusbandryCoOwnToken.parse(HusbandryItems.linkedAnimal(hand)); + return HusbandryCoOwnToken.accepts( + linked, + HusbandryOwnershipService.hasAnyOwner(linked), + HusbandryOwnershipService.isOwner(player, linked)); + } + + private static void handleTame(Player player, LivingEntity entity, ItemStack hand) { + String name = HusbandryItems.tameName(hand); + if (name == null) { + player.sendMessage("§cName this item on an anvil first."); + return; + } + if (entity instanceof Tameable tameable && !tameable.isTamed()) { + player.sendMessage("§cTame this animal first."); + return; + } + if (HusbandryConfig.species(entity.getType()) == null) { + player.sendMessage("§cThis animal cannot be tamed."); + return; + } + HusbandryRepository repository = HusbandryEntities.repository(); + if (repository == null) { + return; + } + Optional stored = HusbandryEntities.lookup(entity.getUniqueId()); + HusbandryAnimal animal; + if (stored.isEmpty()) { + if (!HusbandryOwnershipService.canAccept(player)) { + player.sendMessage("§cYou already own too many animals."); + return; + } + animal = HusbandrySpawner.createWildRecord(entity); + } else { + animal = stored.get(); + } + boolean alreadyMine = HusbandryOwnershipService.isOwner(player, animal.uuid()); + HusbandryOwnershipService.Result result = + HusbandryOwnershipService.claimOwner(player, animal, entity, name); + switch (result) { + case HELD_OWNER -> player.sendMessage("§cThis animal has an unresolved estate; ask staff."); + case CHARACTER_REQUIRED -> player.sendMessage("§cChoose a character before owning animals."); + case AT_CAP -> player.sendMessage("§cYou already own too many animals."); + case NOT_OWNER -> player.sendMessage("§cThis animal already has an owner."); + case OK -> { + HusbandryEntities.applyPersistFlags(entity); + HusbandryItems.useFromMainHand(player); + if (alreadyMine) { + player.sendMessage("§aRenamed to " + name + "."); + } else { + HusbandryFx.playTame(entity); + player.sendMessage("§aTamed " + name + "."); + } + } + } + } + + // Keep the existing legacy text representation, formatting, and exact-string comparisons. + @SuppressWarnings("deprecation") + private static void handleCoOwnLink(Player player, LivingEntity entity, ItemStack hand) { + HusbandryRepository repository = HusbandryEntities.repository(); + if (repository == null) { + return; + } + Optional stored = HusbandryEntities.lookup(entity.getUniqueId()); + if (stored.isEmpty()) { + player.sendMessage("§cThis animal cannot be shared."); + return; + } + HusbandryAnimal animal = stored.get(); + if (!HusbandryOwnershipService.isOwner(player, animal.uuid())) { + player.sendMessage("§cThis is not your animal."); + return; + } + String label = animal.name() != null && !animal.name().isBlank() + ? animal.name() + : HusbandryEntities.displayName(entity.getType()); + HusbandryItems.setLinkedAnimal( + hand, + animal.uuid().toString(), + ChatColor.GRAY + "Linked to " + label); + player.sendMessage("§aLinked to " + label + "."); + HusbandryFx.playBond(entity); + } + + private static void handleCoOwnOnPlayer(Player actor, Player target, ItemStack hand) { + String linked = HusbandryItems.linkedAnimal(hand); + if (linked == null || linked.isBlank()) { + actor.sendMessage("§cLink this token to your animal first."); + return; + } + UUID animalUuid; + try { + animalUuid = UUID.fromString(linked); + } catch (IllegalArgumentException ignored) { + actor.sendMessage("§cThis token does not name any animal."); + return; + } + HusbandryRepository repository = HusbandryEntities.repository(); + if (repository == null) { + return; + } + Optional stored = HusbandryEntities.lookup(animalUuid); + if (stored.isEmpty()) { + actor.sendMessage("§cThat animal can no longer be found."); + return; + } + HusbandryAnimal animal = stored.get(); + HusbandryOwnershipService.Result result = + HusbandryOwnershipService.addCoOwner(actor, target, animal); + switch (result) { + case CHARACTER_REQUIRED -> actor.sendMessage("§cBoth players must choose a character first."); + case HELD_OWNER -> actor.sendMessage("§cThis animal has an unresolved owner; ask staff."); + case NOT_OWNER -> actor.sendMessage("§cThis is not your animal."); + case ALREADY_TARGET_OWNER -> actor.sendMessage("§eAlready an owner of this animal."); + case TARGET_AT_CAP -> actor.sendMessage("§cThat player already owns too many animals."); + case OK -> { + HusbandryItems.consumeOne(hand); + HusbandryFx.playBond(target); + 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 { + actor.sendMessage("§aAdded " + target.getName() + " as a co-owner."); + target.sendMessage("§aYou are now a co-owner of " + label + "."); + } + } + } + } +} diff --git a/src/main/java/net/tfminecraft/breedingbuddies/husbandry/HusbandryTickTask.java b/src/main/java/net/tfminecraft/breedingbuddies/husbandry/HusbandryTickTask.java new file mode 100644 index 0000000..238379e --- /dev/null +++ b/src/main/java/net/tfminecraft/breedingbuddies/husbandry/HusbandryTickTask.java @@ -0,0 +1,82 @@ +package net.tfminecraft.breedingbuddies.husbandry; + +import java.util.ArrayList; +import java.util.HashMap; +import java.util.List; +import java.util.Map; +import java.util.UUID; + +import org.bukkit.Bukkit; +import org.bukkit.entity.Entity; +import org.bukkit.entity.LivingEntity; + +import net.tfminecraft.tlibs.database.SqliteDatabaseException; +import net.tfminecraft.breedingbuddies.BreedingBuddies; + +public final class HusbandryTickTask { + + private static final long PERIOD_TICKS = 20L * 60L; + + private static int taskId = -1; + + private HusbandryTickTask() {} + + public static void start() { + if (taskId != -1 || BreedingBuddies.plugin == null) { + return; + } + taskId = Bukkit.getScheduler().runTaskTimer( + BreedingBuddies.plugin, HusbandryTickTask::tick, PERIOD_TICKS, PERIOD_TICKS).getTaskId(); + } + + public static void stop() { + if (taskId == -1) { + return; + } + Bukkit.getScheduler().cancelTask(taskId); + taskId = -1; + } + + private static void tick() { + HusbandryRepository repository = HusbandryEntities.repository(); + if (repository == null) { + return; + } + long now = System.currentTimeMillis(); + List dirty = new ArrayList<>(); + Map snapshots = new HashMap<>(); + for (HusbandryAnimal animal : HusbandryEntities.snapshotLoaded()) { + Entity entity = Bukkit.getEntity(animal.uuid()); + if (!(entity instanceof LivingEntity) || entity.isDead()) { + HusbandryEntities.evict(animal.uuid()); + continue; + } + LivingEntity living = (LivingEntity) entity; + if (HusbandrySimulator.visitLongEnough(animal, now)) { + HusbandrySimulator.tickLoaded(animal, now); + } + HusbandryLocation.remember(animal, living); + HusbandryGrowth.applyMaturity(living, animal, now); + HusbandryMounts.applyStats(living, animal); + HusbandryShed.tryShed(living, animal, now); + HusbandryEggs.tryLay(living, animal, now); + dirty.add(animal); + HusbandryStateDisplay.sync(living, animal); + if (HusbandrySnapshots.shouldCapture(animal)) { + byte[] snapshot = HusbandrySnapshots.capture(living, false); + if (snapshot != null) { + snapshots.put(animal.uuid(), snapshot); + } + } + } + if (dirty.isEmpty()) { + return; + } + try { + repository.upsertAnimals(dirty); + } catch (SqliteDatabaseException ex) { + Bukkit.getLogger().severe("[BreedingBuddies] Failed to persist husbandry tick: " + ex.getMessage()); + } + HusbandrySnapshots.save(repository, snapshots); + } +} diff --git a/src/main/java/net/tfminecraft/breedingbuddies/husbandry/HusbandryTrust.java b/src/main/java/net/tfminecraft/breedingbuddies/husbandry/HusbandryTrust.java new file mode 100644 index 0000000..a19e1fd --- /dev/null +++ b/src/main/java/net/tfminecraft/breedingbuddies/husbandry/HusbandryTrust.java @@ -0,0 +1,72 @@ +package net.tfminecraft.breedingbuddies.husbandry; + +import org.bukkit.Particle; +import org.bukkit.Sound; +import org.bukkit.entity.LivingEntity; +import org.bukkit.entity.Player; + +import net.kyori.adventure.text.Component; +import net.kyori.adventure.text.format.NamedTextColor; +import net.kyori.adventure.text.format.TextColor; + +/** + * Trust, stored as care and shown as hearts, grows only through what owners do for their animals: + * feeding a hungry animal, brushing a dirty one and a daily pet (riding counts for mounts). + */ +public final class HusbandryTrust { + + public enum Gesture { FEED, GLOVE, PET } + + static final int HEARTS = 5; + private static final TextColor FULL = TextColor.color(0xFF5577); + private static final TextColor EMPTY = TextColor.color(0x555555); + + private HusbandryTrust() {} + + /** Adds trust for an owner's gesture and returns how much was added; a pet counts once per cooldown. */ + public static int reward(HusbandryAnimal animal, Gesture gesture, long nowMillis) { + if (animal == null || gesture == null) { + return 0; + } + if (gesture == Gesture.PET) { + if (!canPet(animal, nowMillis)) { + return 0; + } + animal.setLastPetAt(nowMillis); + } + int before = animal.care(); + animal.setCare(Math.max(0, Math.min(HusbandryConfig.careMax(), before + HusbandryConfig.trustFor(gesture)))); + return animal.care() - before; + } + + public static boolean canPet(HusbandryAnimal animal, long nowMillis) { + Long last = animal.lastPetAt(); + return last == null || nowMillis - last >= HusbandryConfig.petCooldownSeconds() * 1000L; + } + + /** Filled hearts out of five, rounded; any trust at all shows at least one heart. */ + public static int filledHearts(int trust) { + int max = HusbandryConfig.careMax(); + if (trust <= 0 || max <= 0) { + return 0; + } + return Math.max(1, Math.min(HEARTS, (int) Math.round(HEARTS * (double) trust / max))); + } + + public static Component hearts(int trust) { + int filled = filledHearts(trust); + return Component.text("❤".repeat(filled), FULL).append(Component.text("❤".repeat(HEARTS - filled), EMPTY)); + } + + /** Shows the animal's name and hearts above the hotbar, with a little sparkle when trust grew. */ + static void show(Player player, LivingEntity entity, HusbandryAnimal animal, int gained) { + String name = animal.name() == null || animal.name().isBlank() ? HusbandryEntities.displayName(entity.getType()) : animal.name(); + player.sendActionBar(Component.text(name + " ", NamedTextColor.GOLD).append(hearts(animal.care()))); + if (gained <= 0) { + return; + } + entity.getWorld().spawnParticle(Particle.HEART, entity.getLocation().add(0, entity.getHeight() + 0.3, 0), + 5, 0.4, 0.2, 0.4, 0.0); + entity.getWorld().playSound(entity.getLocation(), Sound.BLOCK_AMETHYST_BLOCK_CHIME, 0.7f, 1.3f); + } +} diff --git a/src/main/java/net/tfminecraft/breedingbuddies/listeners/AnimalCareListener.java b/src/main/java/net/tfminecraft/breedingbuddies/listeners/AnimalCareListener.java deleted file mode 100644 index 93b3c41..0000000 --- a/src/main/java/net/tfminecraft/breedingbuddies/listeners/AnimalCareListener.java +++ /dev/null @@ -1,72 +0,0 @@ -package net.tfminecraft.breedingbuddies.listeners; - -import net.tfminecraft.breedingbuddies.configurables.CustomItems; -import net.tfminecraft.breedingbuddies.configurables.Messages; -import net.tfminecraft.breedingbuddies.configurables.Numbers; -import net.tfminecraft.breedingbuddies.FarmAnimal; -import net.tfminecraft.breedingbuddies.ItemUtils; -import net.tfminecraft.breedingbuddies.OwnershipManager; -import net.tfminecraft.breedingbuddies.ParticleManager; -import net.tfminecraft.breedingbuddies.AnimalUtils; -import net.tfminecraft.breedingbuddies.PluginData; -import org.bukkit.entity.Entity; -import org.bukkit.entity.Player; -import org.bukkit.event.EventHandler; -import org.bukkit.event.Listener; -import org.bukkit.event.player.PlayerInteractEntityEvent; -import org.bukkit.inventory.EquipmentSlot; -import org.bukkit.inventory.ItemStack; - -public class AnimalCareListener implements Listener { - @EventHandler - public void onInteract(PlayerInteractEntityEvent event) { - Player player = event.getPlayer(); - Entity entity = event.getRightClicked(); - ItemStack itemInHand = player.getInventory().getItemInMainHand(); - - if(event.getHand().equals(EquipmentSlot.HAND)) { - if (OwnershipManager.isOwner(player.getUniqueId(), entity.getUniqueId())) { - FarmAnimal animal = OwnershipManager.getAnimal(entity.getUniqueId(), player.getUniqueId()); - if (animal == null) return; - if (ItemUtils.getItemId(itemInHand).equalsIgnoreCase(CustomItems.universalFeed)) { - DayChangeListener.changeDayOnInteract(entity, animal); - feed(animal, entity, player, itemInHand); - event.setCancelled(true); - } else if (ItemUtils.getItemId(itemInHand).equalsIgnoreCase(CustomItems.caringItem)) { - DayChangeListener.changeDayOnInteract(entity, animal); - care(animal, entity, player, itemInHand); - event.setCancelled(true); - } - } else if ((AnimalUtils.isFarmAnimal(entity) || PluginData.knowsAnimal(entity.getUniqueId())) - && OwnershipManager.hasOwner(entity.getUniqueId()) - && !OwnershipManager.isOwner(player.getUniqueId(), entity.getUniqueId())) { - if (!player.hasPermission("breedingbuddies.use")) { - player.sendMessage(Messages.notOwner); - event.setCancelled(true); - } - } - } - } - - public void feed(FarmAnimal animal, Entity entity, Player player, ItemStack itemInHand) { - if (animal.getFed()) { - player.sendMessage(String.format(Messages.alreadyFed, animal.getName())); - } else { - itemInHand.setAmount(itemInHand.getAmount() - 1); - animal.increaseFriendship(Numbers.friendshipByFeeding); - animal.setFed(true); - ParticleManager.heartParticles(entity); - } - } - - public void care(FarmAnimal animal, Entity entity, Player player, ItemStack itemInHand) { - if (animal.getCared()) { - player.sendMessage(String.format(Messages.alreadyCared, animal.getName())); - } else { -// itemInHand.setAmount(itemInHand.getAmount() - 1); - animal.increaseFriendship(Numbers.friendshipByCaring); - animal.setCared(true); - ParticleManager.heartParticles(entity); - } - } -} diff --git a/src/main/java/net/tfminecraft/breedingbuddies/listeners/AnimalDeathListener.java b/src/main/java/net/tfminecraft/breedingbuddies/listeners/AnimalDeathListener.java deleted file mode 100644 index fea857a..0000000 --- a/src/main/java/net/tfminecraft/breedingbuddies/listeners/AnimalDeathListener.java +++ /dev/null @@ -1,20 +0,0 @@ -package net.tfminecraft.breedingbuddies.listeners; - -import net.tfminecraft.breedingbuddies.OwnershipManager; -import net.tfminecraft.breedingbuddies.UnownedAnimalsManager; -import org.bukkit.entity.Animals; -import org.bukkit.entity.Entity; -import org.bukkit.event.EventHandler; -import org.bukkit.event.Listener; -import org.bukkit.event.entity.EntityDeathEvent; - -public class AnimalDeathListener implements Listener { - @EventHandler - public void onAnimalDeath(EntityDeathEvent event) { - if (event.getEntity() instanceof Animals) { - Entity animal = event.getEntity(); - UnownedAnimalsManager.removeUnownedAnimal(animal.getUniqueId()); - OwnershipManager.removeAnimal(animal.getUniqueId()); - } - } -} diff --git a/src/main/java/net/tfminecraft/breedingbuddies/listeners/AnvilRenameListener.java b/src/main/java/net/tfminecraft/breedingbuddies/listeners/AnvilRenameListener.java deleted file mode 100644 index fa4af72..0000000 --- a/src/main/java/net/tfminecraft/breedingbuddies/listeners/AnvilRenameListener.java +++ /dev/null @@ -1,26 +0,0 @@ -package net.tfminecraft.breedingbuddies.listeners; - -import net.tfminecraft.breedingbuddies.configurables.CustomItems; -import net.tfminecraft.breedingbuddies.ItemUtils; -import org.bukkit.event.EventHandler; -import org.bukkit.event.Listener; -import org.bukkit.event.inventory.PrepareAnvilEvent; -import org.bukkit.inventory.ItemStack; -import org.bukkit.plugin.java.JavaPlugin; - -public class AnvilRenameListener implements Listener { - private final JavaPlugin plugin; - - public AnvilRenameListener(JavaPlugin plugin) { - this.plugin = plugin; - } - - @EventHandler - public void onAnvilRename(PrepareAnvilEvent event) { - ItemStack item = event.getResult(); - String renameText = event.getInventory().getRenameText(); - if (item != null && renameText != null && ItemUtils.getItemId(item).equalsIgnoreCase(CustomItems.tamingItem)) { - ItemUtils.storeDataInItem(item, renameText, plugin); - } - } -} diff --git a/src/main/java/net/tfminecraft/breedingbuddies/listeners/BreedingListener.java b/src/main/java/net/tfminecraft/breedingbuddies/listeners/BreedingListener.java deleted file mode 100644 index 8e89f72..0000000 --- a/src/main/java/net/tfminecraft/breedingbuddies/listeners/BreedingListener.java +++ /dev/null @@ -1,105 +0,0 @@ -package net.tfminecraft.breedingbuddies.listeners; - -import net.tfminecraft.breedingbuddies.*; -import net.tfminecraft.breedingbuddies.configurables.Messages; -import net.tfminecraft.breedingbuddies.configurables.Numbers; -import org.bukkit.Bukkit; -import org.bukkit.entity.AbstractHorse; -import org.bukkit.entity.Entity; -import org.bukkit.entity.LivingEntity; -import org.bukkit.entity.Player; -import org.bukkit.event.EventHandler; -import org.bukkit.event.EventPriority; -import org.bukkit.event.Listener; -import org.bukkit.event.entity.EntityBreedEvent; - -import java.util.Random; - -public class BreedingListener implements Listener { - @EventHandler(priority = EventPriority.HIGH, ignoreCancelled = true) - public void onEntityBreed(EntityBreedEvent event) { - if (event.getBreeder() instanceof Player) { - Player player = (Player) event.getBreeder(); - Entity motherEntity = event.getMother(); - Entity fatherEntity = event.getFather(); - boolean tracked = AnimalUtils.isFarmAnimal(motherEntity) || AnimalUtils.isFarmAnimal(fatherEntity) - || PluginData.knowsAnimal(motherEntity.getUniqueId()) || PluginData.knowsAnimal(fatherEntity.getUniqueId()); - var session = AnimalIdentity.session(player).orElse(null); - if (tracked && (session == null || !PluginData.ownershipReady())) { event.setCancelled(true); return; } - Entity childEntity = event.getEntity(); - - FarmAnimal mother = OwnershipManager.getAnimal(motherEntity.getUniqueId()); - FarmAnimal father = OwnershipManager.getAnimal(fatherEntity.getUniqueId()); - - boolean playerIsOwner = playerOwnsParents(player, fatherEntity, motherEntity); - - if (MountUtils.isNeutered(motherEntity) || MountUtils.isNeutered(fatherEntity)) { - event.setCancelled(true); - if (motherEntity instanceof AbstractHorse && fatherEntity instanceof AbstractHorse) { - player.sendMessage(Messages.neuteredHorse); - ((AbstractHorse) fatherEntity).setLoveModeTicks(0); - ((AbstractHorse) motherEntity).setLoveModeTicks(0); - //event.setExperience(0); - return; - } - } - - if (playerIsOwner) { - if (MountUtils.isFullStatMount(childEntity)) { - Bukkit.getScheduler().runTaskLater(BreedingBuddies.getInstance(), () -> { - if (!AnimalIdentity.current(session, player) || !playerOwnsParents(player, fatherEntity, motherEntity)) return; - MountUtils.applyInheritedStats( - (LivingEntity) childEntity, - (LivingEntity) motherEntity, - (LivingEntity) fatherEntity - ); - ((AbstractHorse) childEntity).setTamed(true); - }, 1L); - - FarmAnimal child = new FarmAnimal(childEntity.getUniqueId(), childEntity); - UnownedAnimalsManager.addUnownedAnimal(child); - } else { - specialBreed(mother, father, childEntity); - } - } else if (tracked && parentsHaveOwner(fatherEntity, motherEntity) && !playerIsOwner) { - event.setCancelled(true); - } - } - } - - public void specialBreed(FarmAnimal mother, FarmAnimal father, Entity childEntity) { - int friendshipAverage = (mother.getFriendshipPoints() + father.getFriendshipPoints()) / 2; - int geneticAverage = (mother.getGeneticPoints() + father.getGeneticPoints()) / 2; - - int geneticVariance = calculateGeneticVariance(geneticAverage); - int friendshipInfluence = calculateFriendshipInfluence(friendshipAverage); - - Random random = new Random(); - int childGeneticPoints = geneticAverage + random.nextInt(geneticVariance) + friendshipInfluence; - childGeneticPoints = Math.min(childGeneticPoints, Numbers.maxFriendshipAndGenetics); // asegurarse de no exceder el máximo - - FarmAnimal child = new FarmAnimal(childEntity.getUniqueId(), childGeneticPoints, childEntity); - UnownedAnimalsManager.addUnownedAnimal(child); - } - - private int calculateGeneticVariance(int geneticAverage) { - double effectiveDivider = 10.0 * Numbers.geneticDivisorMultiplierToRetardProgression; - return Math.max(100, (int) (Numbers.geneticVarianceMultiplier * (1000 - geneticAverage / effectiveDivider))); - } - - private int calculateFriendshipInfluence(int friendshipAverage) { - // La influencia de la amistad puede mejorar la genética del hijo - // 5% de la amistad media se agrega a la genética - return (int) (friendshipAverage * 0.05 * Numbers.friendshipInfluenceMultiplier); - } - - public boolean parentsHaveOwner(Entity father, Entity mother) { - return OwnershipManager.hasOwner(father.getUniqueId()) - && OwnershipManager.hasOwner(mother.getUniqueId()); - } - - public boolean playerOwnsParents(Player player, Entity father, Entity mother) { - return OwnershipManager.isOwner(player.getUniqueId(), father.getUniqueId()) - && OwnershipManager.isOwner(player.getUniqueId(), mother.getUniqueId()); - } -} diff --git a/src/main/java/net/tfminecraft/breedingbuddies/listeners/BundleCollector.java b/src/main/java/net/tfminecraft/breedingbuddies/listeners/BundleCollector.java deleted file mode 100644 index 9d2c438..0000000 --- a/src/main/java/net/tfminecraft/breedingbuddies/listeners/BundleCollector.java +++ /dev/null @@ -1,124 +0,0 @@ -package net.tfminecraft.breedingbuddies.listeners; - -import net.tfminecraft.breedingbuddies.*; -import net.tfminecraft.breedingbuddies.configurables.CustomItems; -import net.tfminecraft.breedingbuddies.configurables.Messages; -import net.tfminecraft.breedingbuddies.configurables.Numbers; -import org.bukkit.Material; -import org.bukkit.configuration.file.FileConfiguration; -import org.bukkit.entity.AbstractHorse; -import org.bukkit.entity.Entity; -import org.bukkit.entity.Player; -import org.bukkit.event.EventHandler; -import org.bukkit.event.Listener; -import org.bukkit.event.player.PlayerInteractEntityEvent; -import org.bukkit.inventory.EquipmentSlot; -import org.bukkit.inventory.ItemStack; -import org.bukkit.plugin.java.JavaPlugin; -import org.bukkit.configuration.*; - -import java.util.ArrayList; -import java.util.Date; -import java.util.List; -import java.util.UUID; - -public class BundleCollector implements Listener { - private JavaPlugin plugin; - private static FileConfiguration bundlesConfig; - - public static void setBundlesConfig(FileConfiguration bundlesConfig) { - BundleCollector.bundlesConfig = bundlesConfig; - } - - public BundleCollector(BreedingBuddies plugin) { - this.plugin = plugin; - } - @EventHandler - public void onInteract(PlayerInteractEntityEvent event) { - Player player = event.getPlayer(); - Entity entity = event.getRightClicked(); - ItemStack itemInHand = player.getInventory().getItemInMainHand(); - - if (OwnershipManager.isOwner(player.getUniqueId(), entity.getUniqueId()) - && event.getHand().equals(EquipmentSlot.HAND) - && itemInHand != null - && CustomItems.collectorsItem.equalsIgnoreCase(ItemUtils.getItemId(itemInHand)) - ) { - - ConfigurationSection animalSection = bundlesConfig.getConfigurationSection(entity.getType().toString().toUpperCase()); - if (animalSection == null || !animalSection.getBoolean("bundlesEnabled", false)) return; - - if (entity instanceof AbstractHorse) - event.setCancelled(true); - if (canCollect(entity)) { - FarmAnimal animal = OwnershipManager.getAnimal(entity.getUniqueId()); - ItemStack bundle = generateBundle(player, entity); - if (InventoryUtils.countEmptySlots(player) > 0) { - animal.setLastRewardCollected(new Date()); - player.getInventory().addItem(bundle); - SoundManager.playAmethystStepSound(entity); - } else { - player.sendMessage(Messages.lacksSpace); - } - } - event.setCancelled(true); - } - } - - private ItemStack generateBundle(Player player, Entity entity) { - ItemStack bundle = null; - String entityType = entity.getType().toString(); - String tier = calculateTier(entity); - String itemType = bundlesConfig.getString(entityType + ".bundles." + tier + ".bundleItem.type"); - if (itemType.equalsIgnoreCase("vanilla")) { - String itemId = bundlesConfig.getString(entityType + ".bundles." + tier + ".bundleItem.itemID"); - Material material = Material.matchMaterial(itemId); - if (material != null) { - bundle = new ItemStack(material, 1); - } - } else if (itemType.equalsIgnoreCase("mmoitem")) { - String mmoitemId = bundlesConfig.getString(entityType + ".bundles." + tier + ".bundleItem.mmoitemID"); - String mmoitemType = bundlesConfig.getString(entityType + ".bundles." + tier + ".bundleItem.mmoitemType"); - bundle = ItemUtils.generateMMOItem(mmoitemType, mmoitemId, 1); - } - if (bundle == null) { - bundle = new ItemStack(Material.DIRT, 1); - } - ItemUtils.storeDataInItem(bundle, entityType, plugin); - return bundle; - } - - public String calculateTier(Entity entity) { - String type = entity.getType().toString(); - ConfigurationSection animalSection = bundlesConfig.getConfigurationSection(type); - ConfigurationSection bundlesSection = animalSection.getConfigurationSection("bundles"); - - List tiers = new ArrayList(); - for (String tier : bundlesSection.getKeys(false)) { - tiers.add(tier); - } - int numberOfRewards = tiers.size(); - double rangePerTier = Numbers.maxFriendshipAndGenetics / (double) numberOfRewards; - FarmAnimal farmAnimal = OwnershipManager.getAnimal(entity.getUniqueId()); - - double friendshipPoints = farmAnimal.getFriendshipPoints(); - double geneticPoints = farmAnimal.getGeneticPoints(); - double averagePoints = (friendshipPoints + geneticPoints) / 2; - double disparity = Math.abs(friendshipPoints - geneticPoints); - double statsAverage = Math.max(0, averagePoints - disparity); - - int tierIndex = (int) (statsAverage / rangePerTier); - tierIndex = Math.max(0, Math.min(tierIndex, numberOfRewards - 1)); - - return tiers.get(tierIndex); - } - - private boolean canCollect(Entity entity) { - FarmAnimal animal = OwnershipManager.getAnimal(entity.getUniqueId()); - if (animal != null && animal.canCollectReward()) { - return true; - } - SoundManager.playAngryCowSound(entity); - return false; - } -} diff --git a/src/main/java/net/tfminecraft/breedingbuddies/listeners/BundleOpener.java b/src/main/java/net/tfminecraft/breedingbuddies/listeners/BundleOpener.java deleted file mode 100644 index 4fc45f8..0000000 --- a/src/main/java/net/tfminecraft/breedingbuddies/listeners/BundleOpener.java +++ /dev/null @@ -1,121 +0,0 @@ -package net.tfminecraft.breedingbuddies.listeners; - -import net.tfminecraft.breedingbuddies.BreedingBuddies; -import net.tfminecraft.breedingbuddies.ItemUtils; -import net.tfminecraft.breedingbuddies.SoundManager; -import org.bukkit.Material; -import org.bukkit.configuration.ConfigurationSection; -import org.bukkit.configuration.file.FileConfiguration; -import org.bukkit.entity.Player; -import org.bukkit.event.EventHandler; -import org.bukkit.event.Listener; -import org.bukkit.event.block.Action; -import org.bukkit.event.player.PlayerInteractEvent; -import org.bukkit.inventory.ItemStack; -import org.bukkit.plugin.java.JavaPlugin; - -import java.util.*; - -public class BundleOpener implements Listener { - private JavaPlugin plugin; - private Random random = new Random(); - private static FileConfiguration bundlesConfig; - - public static void setBundlesConfig(FileConfiguration bundlesConfig) { - BundleOpener.bundlesConfig = bundlesConfig; - } - - public BundleOpener(BreedingBuddies plugin) { - this.plugin = plugin; - } - - @EventHandler - public void onRightClick(PlayerInteractEvent event) { - if (event.getAction().equals(Action.RIGHT_CLICK_BLOCK) || event.getAction().equals(Action.RIGHT_CLICK_AIR)) { - ItemStack itemInHand = event.getItem(); - Player player = event.getPlayer(); - String entityType = ItemUtils.getDataFromItem(itemInHand, plugin); - if (entityType != null && itemInHand != null && isBundleItem(itemInHand, entityType, player)) { - openBundle(player, itemInHand, entityType); - } - } - } - - public boolean isBundleItem(ItemStack itemInHand, String entityType, Player player) { - if (bundlesConfig.getConfigurationSection(entityType + ".bundles") == null) { - return false; - } - return getTierFromItem(itemInHand, entityType) != null; - } - - public void openBundle(Player player, ItemStack bundleItem, String entityType) { - String tier = getTierFromItem(bundleItem, entityType); - - Map prizeMap = new HashMap<>(); - ConfigurationSection prizes = bundlesConfig.getConfigurationSection(entityType + ".bundles." + tier + ".prizes"); - if (prizes != null) { - for (String key : prizes.getKeys(false)) { - ConfigurationSection prizeConfig = prizes.getConfigurationSection(key); - prizeMap.put(key, prizeConfig); - } - } - - ItemStack prize = selectPrize(prizeMap); - bundleItem.setAmount(bundleItem.getAmount() - 1); - player.getInventory().addItem(prize); - SoundManager.playExperienceSound(player); - } - - private String getTierFromItem(ItemStack bundleItem, String entityType) { - for (String tier : bundlesConfig.getConfigurationSection(entityType + ".bundles.").getKeys(false)) { - String itemId = null; - if (bundlesConfig.getString(entityType + ".bundles." + tier + ".bundleItem.type").equalsIgnoreCase("vanilla")) { - itemId = bundlesConfig.getString(entityType + ".bundles." + tier + ".bundleItem.itemID"); - } else if (bundlesConfig.getString(entityType + ".bundles." + tier + ".bundleItem.type").equalsIgnoreCase("mmoitem")) { - itemId = bundlesConfig.getString(entityType + ".bundles." + tier + ".bundleItem.mmoitemID"); - } - - if (itemId != null && bundleItem != null && ItemUtils.getItemId(bundleItem).equalsIgnoreCase(itemId)) { - return tier; - } - } - return null; - } - - private ItemStack selectPrize(Map prizeMap) { - List> shuffledPrizes = new ArrayList<>(prizeMap.entrySet()); - Collections.shuffle(shuffledPrizes); - - int totalWeight = 0; - for (Map.Entry entry : shuffledPrizes) { - totalWeight += entry.getValue().getInt("weight"); - } - - int value = random.nextInt(totalWeight) + 1; - int weightSum = 0; - - for (Map.Entry entry : shuffledPrizes) { - ConfigurationSection prize = entry.getValue(); - weightSum += prize.getInt("weight"); - if (value <= weightSum) { - String type = prize.getString("type"); - String itemID = prize.getString("itemID"); - int amount = prize.getInt("amount"); - if ("vanilla".equalsIgnoreCase(type)) { - Material material = Material.matchMaterial(itemID); - if (material != null) { - return new ItemStack(material, amount); - } - } else if ("mmoitem".equalsIgnoreCase(type)) { - String mmoitemType = prize.getString("mmoitemType"); - String mmoitemID = prize.getString("mmoitemID"); - ItemStack mmoitem = ItemUtils.generateMMOItem(mmoitemType, mmoitemID, amount); - if (mmoitem != null) { - return mmoitem; - } - } - } - } - return new ItemStack(Material.DIRT, 1); - } -} \ No newline at end of file diff --git a/src/main/java/net/tfminecraft/breedingbuddies/listeners/ChunkCreation.java b/src/main/java/net/tfminecraft/breedingbuddies/listeners/ChunkCreation.java deleted file mode 100644 index bc1eb5e..0000000 --- a/src/main/java/net/tfminecraft/breedingbuddies/listeners/ChunkCreation.java +++ /dev/null @@ -1,38 +0,0 @@ -package net.tfminecraft.breedingbuddies.listeners; - -import net.tfminecraft.breedingbuddies.ChunkManager; -import net.tfminecraft.breedingbuddies.configurables.CustomItems; -import net.tfminecraft.breedingbuddies.configurables.Messages; -import net.tfminecraft.breedingbuddies.ItemUtils; -import net.tfminecraft.breedingbuddies.SoundManager; -import org.bukkit.Chunk; -import org.bukkit.entity.Player; -import org.bukkit.event.EventHandler; -import org.bukkit.event.Listener; -import org.bukkit.event.block.Action; -import org.bukkit.event.player.PlayerInteractEvent; -import org.bukkit.inventory.ItemStack; - -public class ChunkCreation implements Listener { - @EventHandler - public void onRightClick(PlayerInteractEvent event) { - if (event.getAction().equals(Action.RIGHT_CLICK_BLOCK) || event.getAction().equals(Action.RIGHT_CLICK_AIR)) { - ItemStack itemInHand = event.getItem(); - if (itemInHand != null && ItemUtils.getItemId(itemInHand).equalsIgnoreCase(CustomItems.stableChunkItem)) { - Player player = event.getPlayer(); - if (player != null) { - Chunk chunk = player.getLocation().getChunk(); - if (!ChunkManager.isStableChunk(chunk) && chunk != null) { - ChunkManager.addChunk(chunk); - itemInHand.setAmount(itemInHand.getAmount() - 1); - player.sendMessage(Messages.stableChunk); - SoundManager.playSignSound(player); - } else if (chunk != null) { - player.sendMessage(Messages.alreadyStableChunk); - } - } - event.setCancelled(true); - } - } - } -} diff --git a/src/main/java/net/tfminecraft/breedingbuddies/listeners/DayChangeListener.java b/src/main/java/net/tfminecraft/breedingbuddies/listeners/DayChangeListener.java deleted file mode 100644 index 3ccd236..0000000 --- a/src/main/java/net/tfminecraft/breedingbuddies/listeners/DayChangeListener.java +++ /dev/null @@ -1,234 +0,0 @@ -package net.tfminecraft.breedingbuddies.listeners; - -import net.tfminecraft.breedingbuddies.*; -import net.tfminecraft.breedingbuddies.configurables.Numbers; -import net.tfminecraft.breedingbuddies.events.DayChangeEvent; -import org.bukkit.Bukkit; -import org.bukkit.Chunk; -import org.bukkit.configuration.file.FileConfiguration; -import org.bukkit.entity.AbstractHorse; -import org.bukkit.entity.Entity; -import org.bukkit.event.EventHandler; -import org.bukkit.event.Listener; - -import java.io.IOException; -import java.time.Instant; -import java.time.LocalDateTime; -import java.time.ZoneOffset; -import java.time.format.DateTimeFormatter; -import java.time.temporal.ChronoUnit; -import java.util.*; -import java.util.concurrent.atomic.AtomicBoolean; - -public class DayChangeListener implements Listener { - private static boolean isChangingDay = false; - private static FileConfiguration numbersConfig; - - public static void setNumbersConfig(FileConfiguration numbersConfig) { - DayChangeListener.numbersConfig = numbersConfig; - } - - @EventHandler - public void onDayChange(DayChangeEvent event) { - scheduleGlobalChangeDay(null, 1); - manageChunkUse(); - } - - public static void globalChangeDay(UUID excludeUuid, int daysChecked) { - if (!PluginData.ownershipReady()) return; - List chunkAreas = PluginData.getChunkAreas(); - Set processed = new HashSet<>(); - - for (ChunkArea area : chunkAreas) { - processChunkArea(area, processed, excludeUuid, daysChecked); - } - - // Sacamos animales únicos de playerAnimals - Map> playerAnimals = PluginData.getPlayerAnimals(); - Set uniqueAnimals = new HashSet<>(); - for (Set animals : playerAnimals.values()) { - uniqueAnimals.addAll(animals); - } - - // Procesar animales no actualizados (no presentes en processed) - for (FarmAnimal animal : uniqueAnimals) { - int friendshipLost = Numbers.friendshipLostNotCaring * daysChecked - + Numbers.friendshipLostNotFeeding * daysChecked; - - if (!processed.contains(animal.getUuid())) { - if (!animal.isStableNeeded()) { - animal.changeDay(false, false, true, friendshipLost); - } else { - animal.changeDay(false, false, false, friendshipLost); - animal.incrementDaysOut(); - } - } - if (animal.getDaysOut() > Numbers.maxIrlDaysOut) { - animal.setState(AnimalStates.ESCAPED); - animal.setOwnersUuids(new HashSet()); - UnownedAnimalsManager.addUnownedAnimal(animal); - OwnershipManager.removeAnimal(animal.getUuid()); - } - } - } - - private static void processChunkArea(ChunkArea area, Set processed, UUID excludeUuid, int daysChecked) { - boolean containsWater = ChunkManager.containsWater(area); - boolean spaceNeeded = ChunkManager.spaceNeeded(area); - AtomicBoolean hasFarmAnimals = new AtomicBoolean(false); - - for (Chunk chunk : area.getChunks()) { - processChunk(chunk, !containsWater, spaceNeeded, processed, excludeUuid, daysChecked, hasFarmAnimals); - } - - if (!hasFarmAnimals.get()) - area.increaseDaysAbandoned(); - } - - private static void processChunk(Chunk chunk, boolean noWater, boolean spaceNeeded, Set processed, UUID excludeUuid, int daysChecked, AtomicBoolean hasFarmAnimals) { - boolean wasForced = chunk.isForceLoaded(); - chunk.setForceLoaded(true); - try { - for (Entity entity : chunk.getEntities()) { - processEntity(entity, noWater, spaceNeeded, processed, excludeUuid, daysChecked, hasFarmAnimals); - } - } catch (Exception ex) { - logChunkError(chunk, ex); - } finally { - if (!wasForced) { - chunk.setForceLoaded(false); - } - } - } - - private static void processEntity(Entity entity, boolean noWater, boolean spaceNeeded, Set processed, UUID excludeUuid, int daysChecked, AtomicBoolean hasFarmAnimals) { - try { - FarmAnimal animal = OwnershipManager.getAnimal(entity.getUniqueId()); - if (animal != null) { - if (!hasFarmAnimals.get()) - hasFarmAnimals.set(true); - // The excluded animal has already had its own day change applied. - if (!animal.getUuid().equals(excludeUuid)) { - int friendshipLost = Numbers.friendshipLostNotCaring * daysChecked + Numbers.friendshipLostNotFeeding * daysChecked; - animal.changeDay(noWater, spaceNeeded, true, friendshipLost); - } - processed.add(animal.getUuid()); - animal.setDaysOut(0); - } - } catch (Exception ex) { - logAnimalError(entity, ex); - } - } - - private static void manageChunkUse() { - ArrayList chunkAreas = PluginData.getChunkAreas(); - Iterator it = chunkAreas.iterator(); - while (it.hasNext()) { - ChunkArea chunkArea = it.next(); - if (chunkArea.getDaysAbandoned() > Numbers.maxIrlDaysChunkAbandoned) { - it.remove(); - } - } - } - - private static void logChunkError(Chunk chunk, Exception ex) { - BreedingBuddies.getInstance().getLogger().warning( - "Error processing chunk " + chunk.getX() + "," + chunk.getZ() + ": " + ex.getMessage() - ); - } - - private static void logAnimalError(Entity entity, Exception ex) { - BreedingBuddies.getInstance().getLogger().warning( - "Error processing animal " + entity.getUniqueId() + ": " + ex.getMessage() - ); - } - - // Chunk loading and entity reads must run on the server thread. - private static void scheduleGlobalChangeDay(UUID excludedEntity, int daysChecked) { - if (isChangingDay || !PluginData.ownershipReady()) return; - isChangingDay = true; - Bukkit.getScheduler().runTask(BreedingBuddies.getInstance(), () -> runGlobalChangeDay(excludedEntity, daysChecked)); - } - - private static void runGlobalChangeDay(UUID excludedEntity, int daysChecked) { - try { - globalChangeDay(excludedEntity, daysChecked); - PluginData.saveAllData(true); - updateLastDayChangeDate(); - } catch (Exception e) { - System.out.println(("Error on DayChange: " + e)); - } finally { - isChangingDay = false; - } - } - - private static void IndividualChangeDay(Entity entity, FarmAnimal animal, int daysChecked) { - int lostFriendship = Numbers.friendshipLostNotCaring * daysChecked + Numbers.friendshipLostNotFeeding * daysChecked; - - if (!animal.isStableNeeded()) { - animal.changeDay(false, false, true, lostFriendship); - } else { - Chunk chunk = entity.getLocation().getChunk(); - ChunkArea area = ChunkArea.findAreaForChunk(PluginData.getChunkAreas(), chunk); - boolean sleptInStable = true; - boolean containsWater = true; - boolean spaceNeeded = false; - - if (area == null) { - sleptInStable = false; - } else { - containsWater = ChunkManager.containsWater(area); - spaceNeeded = ChunkManager.spaceNeeded(area); - } - - animal.changeDay(!containsWater, spaceNeeded, sleptInStable, lostFriendship); - } - } - - public static void changeDayOnInteract(Entity entity, FarmAnimal animal) { - if (!PluginData.ownershipReady() || PluginData.isHeld(animal.getUuid())) return; - int daysChecked = daysBetween(getLastChangeDate(), LocalDateTime.now(ZoneOffset.UTC)); - if (daysChecked < 1) return; - IndividualChangeDay(entity, animal, daysChecked); - scheduleGlobalChangeDay(entity.getUniqueId(), daysChecked); - } - - public static void changeDayOnReport() { - if (isChangingDay || !PluginData.ownershipReady()) return; - isChangingDay = true; - try { - int daysChecked = daysBetween(getLastChangeDate(), LocalDateTime.now(ZoneOffset.UTC)); - if (daysChecked < 1) return; - globalChangeDay(null, daysChecked); - PluginData.saveAllData(true); - updateLastDayChangeDate(); - } catch (Exception e) { - System.out.println(("Error on DayChange: " + e)); - } finally { - isChangingDay = false; - } - } - - private static LocalDateTime getLastChangeDate() { - long epoch = numbersConfig.getLong("lastDayChangeDate"); - return LocalDateTime.ofEpochSecond(epoch, 0, ZoneOffset.UTC); - } - - private static void updateLastDayChangeDate() { - long nowEpochSeconds = Instant.now().getEpochSecond(); - numbersConfig.set("lastDayChangeDate", nowEpochSeconds); - try { - numbersConfig.save(BreedingBuddies.getInstance().getNumbersFile()); - } catch (IOException e) { - System.out.println("Error saving lastDayChangeDate on config: " + e.getMessage()); - } - } - - public static int daysBetween(LocalDateTime startDate, LocalDateTime endDate) { - return (int) ChronoUnit.DAYS.between(startDate, endDate); - } - - public static boolean isIsChangingDay() { - return isChangingDay; - } -} diff --git a/src/main/java/net/tfminecraft/breedingbuddies/listeners/FarmReport.java b/src/main/java/net/tfminecraft/breedingbuddies/listeners/FarmReport.java deleted file mode 100644 index 8c54f1d..0000000 --- a/src/main/java/net/tfminecraft/breedingbuddies/listeners/FarmReport.java +++ /dev/null @@ -1,58 +0,0 @@ -package net.tfminecraft.breedingbuddies.listeners; - -import java.util.List; -import java.util.Set; - -import net.tfminecraft.breedingbuddies.configurables.CustomItems; -import net.tfminecraft.breedingbuddies.configurables.Messages; -import net.tfminecraft.breedingbuddies.FarmAnimal; -import net.tfminecraft.breedingbuddies.ItemUtils; -import net.tfminecraft.breedingbuddies.OwnershipManager; -import net.tfminecraft.breedingbuddies.SoundManager; -import org.bukkit.Bukkit; -import org.bukkit.ChatColor; -import org.bukkit.Chunk; -import org.bukkit.entity.Player; -import org.bukkit.event.EventHandler; -import org.bukkit.event.Listener; -import org.bukkit.event.block.Action; -import org.bukkit.event.player.PlayerInteractEvent; -import org.bukkit.inventory.ItemStack; - -public class FarmReport implements Listener { - @EventHandler - public void onRightClick(PlayerInteractEvent event) { - if (event.getAction().equals(Action.RIGHT_CLICK_BLOCK) || event.getAction().equals(Action.RIGHT_CLICK_AIR)) { - ItemStack item = event.getItem(); - if (item != null && ItemUtils.getItemId(item).equalsIgnoreCase(CustomItems.farmReportItem)) { - DayChangeListener.changeDayOnReport(); - Player player = event.getPlayer(); - generateReport(player); - SoundManager.playBookOpenSound(player); - } - } - } - - public void generateReport(Player player) { - boolean animalsOut = false; - boolean spaceNeeded = false; - boolean waterNeeded = false; - int animalsFed = 0; - int animalsCared = 0; - Set playerAnimals = OwnershipManager.getAnimals(player.getUniqueId()); - for (FarmAnimal animal : playerAnimals) { - if (!animal.isSleptInStable()) animalsOut = true; - if (animal.isSpaceNeeded()) spaceNeeded = true; - if (animal.isWaterNeeded()) waterNeeded = true; - if (animal.getFed()) animalsFed++; - if (animal.getCared()) animalsCared++; - } - player.sendMessage(ChatColor.GOLD + "§nFarm Report:"); - player.sendMessage(String.format(Messages.animalsFed, animalsFed)); - player.sendMessage(String.format(Messages.animalsCared, animalsCared)); - player.sendMessage(String.format(Messages.animalsOwned, playerAnimals.size())); - if (animalsOut) player.sendMessage(Messages.animalsOut); - if (spaceNeeded) player.sendMessage(Messages.spaceNeeded); - if (waterNeeded) player.sendMessage(Messages.waterNeeded); - } -} \ No newline at end of file diff --git a/src/main/java/net/tfminecraft/breedingbuddies/listeners/InventoryManager.java b/src/main/java/net/tfminecraft/breedingbuddies/listeners/InventoryManager.java deleted file mode 100644 index 6af5c70..0000000 --- a/src/main/java/net/tfminecraft/breedingbuddies/listeners/InventoryManager.java +++ /dev/null @@ -1,195 +0,0 @@ -package net.tfminecraft.breedingbuddies.listeners; - -import net.tfminecraft.breedingbuddies.*; -import net.tfminecraft.breedingbuddies.configurables.CustomItems; -import net.tfminecraft.breedingbuddies.configurables.Messages; -import net.tfminecraft.breedingbuddies.configurables.Numbers; -import org.bukkit.ChatColor; -import org.bukkit.Material; -import org.bukkit.entity.Animals; -import org.bukkit.entity.Entity; -import org.bukkit.entity.LivingEntity; -import org.bukkit.entity.Player; -import org.bukkit.event.EventHandler; -import org.bukkit.event.Listener; -import org.bukkit.event.inventory.InventoryClickEvent; -import org.bukkit.event.player.PlayerInteractEntityEvent; -import org.bukkit.inventory.Inventory; -import org.bukkit.inventory.InventoryHolder; -import org.bukkit.inventory.ItemStack; -import org.bukkit.inventory.meta.ItemMeta; - -import java.util.ArrayList; -import java.util.Set; -import java.util.UUID; - -public class InventoryManager implements Listener { - @EventHandler - public void onPlayerInteractEntity(PlayerInteractEntityEvent event) { - if (!(event.getRightClicked() instanceof Animals)) return; - - Player player = event.getPlayer(); - Entity entity = event.getRightClicked(); - ItemStack itemInHand = player.getInventory().getItemInMainHand(); - String itemInHandId = ItemUtils.getItemId(itemInHand); - - boolean isEmptyHandSneak = player.isSneaking() && itemInHand.getType() == Material.AIR; - boolean isMountStatsItem = itemInHandId.equalsIgnoreCase(CustomItems.mountStatsItem); - - FarmAnimal animal = OwnershipManager.getAnimal(entity.getUniqueId()); - boolean isOwned = !(animal == null); - boolean isFromPlayer = OwnershipManager.isOwner(player.getUniqueId(), entity.getUniqueId()); - boolean isUnowned = UnownedAnimalsManager.isUnowned(entity.getUniqueId()); - boolean playerIsStaff = player.hasPermission("breedingbuddies.use"); - boolean isBBAnimal = (isOwned || isUnowned); - - if (isOwned) - DayChangeListener.changeDayOnInteract(entity, animal); - - if (isEmptyHandSneak && MountUtils.isFullStatMount(entity)) { - - } else if (isEmptyHandSneak && isBBAnimal) { - if (isUnowned) { - animal = UnownedAnimalsManager.getAnimal(entity.getUniqueId()); - } - if (isFromPlayer || isUnowned || playerIsStaff) { - openAnimalMenu(player, animal, entity); - } - } else if (isMountStatsItem && MountUtils.isFullStatMount(entity)) { - if (isFromPlayer || isUnowned || playerIsStaff || !isBBAnimal) { - event.setCancelled(true); - if (!isOwned) - animal = new FarmAnimal(entity.getUniqueId(), 0, entity); - openMountMenu(player, (LivingEntity) entity, animal); - } - } else if (isMountStatsItem && isBBAnimal) { - if (isUnowned) { - animal = UnownedAnimalsManager.getAnimal(entity.getUniqueId()); - } - if (isFromPlayer || isUnowned || playerIsStaff) { - openAnimalMenu(player, animal, entity); - } - } - } - - public void openAnimalMenu(Player player, FarmAnimal farmAnimal, Entity entity) { - if (AnimalIdentity.session(player).isEmpty() || PluginData.isHeld(farmAnimal.getUuid())) return; - String animalName = farmAnimal.getName(); - AnimalStates animalStatus = farmAnimal.getState(); - int friendshipPoints = farmAnimal.getFriendshipPoints(); - int geneticPoints = farmAnimal.getGeneticPoints(); - - UUID entityUUID = entity.getUniqueId(); - Inventory inv = new AnimalInventoryHolder(entityUUID, 27, ChatColor.GOLD + Messages.inventoryName, AnimalIdentity.session(player).orElse(null)).getInventory(); - - // Primera fila: Nombre y Estado - inv.setItem(3, createItem(Material.NAME_TAG, ChatColor.GOLD + "Name: " + animalName)); - inv.setItem(4, createItem(Material.CHEST, ChatColor.GOLD + "Next bundle: " + farmAnimal.timeForNextReward(entity))); - inv.setItem(5, createItem(Material.TOTEM_OF_UNDYING, ChatColor.GOLD + "State: " + animalStatus.toString())); - - Set owners = farmAnimal.getOwnersUuids(); - if (OwnershipManager.isOwner(player.getUniqueId(), entityUUID)) - inv.setItem(26, createItem(Material.WRITABLE_BOOK, ChatColor.DARK_RED + "Remove ownership")); // Slot 36 = índice 35 - - // Segunda fila: Puntos de amistad - int maxFriendshipPoints = Numbers.maxFriendshipAndGenetics; - int friendshipPerHeart = maxFriendshipPoints / 5; // 5 corazones en total - for (int i = 0; i < 5; i++) { - if (friendshipPoints >= (i + 1) * friendshipPerHeart) { - inv.setItem(11 + i, createItem(Material.RED_DYE, ChatColor.RED + "Friendship: " + friendshipPoints + "/" + maxFriendshipPoints)); - } else { - inv.setItem(11 + i, createItem(Material.GRAY_DYE, ChatColor.RED + "Friendship: " + friendshipPoints + "/" + maxFriendshipPoints)); - } - } - - // Tercera fila: Puntos de genética - int maxGeneticPoints = Numbers.maxFriendshipAndGenetics; - int geneticPerHeart = maxGeneticPoints / 5; - for (int i = 0; i < 5; i++) { - if (geneticPoints >= (i + 1) * geneticPerHeart) { - inv.setItem(20 + i, createItem(Material.SLIME_SPAWN_EGG, ChatColor.YELLOW + "Genetics: " + geneticPoints + "/" + maxGeneticPoints)); - } else { - inv.setItem(20 + i, createItem(Material.VEX_SPAWN_EGG, ChatColor.YELLOW + "Genetics: " + geneticPoints + "/" + maxGeneticPoints)); - } - } - player.openInventory(inv); - } - - public void openMountMenu(Player player, LivingEntity entity, FarmAnimal farmAnimal) { - if (AnimalIdentity.session(player).isEmpty() || PluginData.isHeld(farmAnimal.getUuid())) return; - String animalName = farmAnimal.getName(); - AnimalStates animalStatus = farmAnimal.getState(); - int friendshipPoints = farmAnimal.getFriendshipPoints(); - int geneticPoints = farmAnimal.getGeneticPoints(); - int maxPoints = Numbers.maxFriendshipAndGenetics; - int pointsPerHeart = maxPoints / 5; - - UUID entityUUID = entity.getUniqueId(); - Inventory inv = new AnimalInventoryHolder(entityUUID, 36, ChatColor.GOLD + Messages.inventoryName, AnimalIdentity.session(player).orElse(null)).getInventory(); - - // Fila 1: Nombre y estado (slots 3, 4, 5) - inv.setItem(3, createItem(Material.NAME_TAG, ChatColor.GOLD + "Name: " + animalName)); - inv.setItem(4, createItem(Material.CHEST, ChatColor.GOLD + "Next bundle: " + farmAnimal.timeForNextReward(entity))); - inv.setItem(5, createItem(Material.TOTEM_OF_UNDYING, ChatColor.GOLD + "State: " + animalStatus.toString())); - inv.setItem(8, createItem(Material.SHEARS, ChatColor.GOLD + "Neutered: " + MountUtils.isNeutered(entity))); - - Set owners = farmAnimal.getOwnersUuids(); - if (OwnershipManager.isOwner(player.getUniqueId(), entityUUID)) - inv.setItem(35, createItem(Material.WRITABLE_BOOK, ChatColor.DARK_RED + "Remove ownership")); // Slot 36 = índice 35 - - // Fila 2: Amistad (slots 11–15) - for (int i = 0; i < 5; i++) { - Material mat = friendshipPoints >= (i + 1) * pointsPerHeart ? Material.RED_DYE : Material.GRAY_DYE; - inv.setItem(11 + i, createItem(mat, ChatColor.RED + "Friendship: " + friendshipPoints + "/" + maxPoints)); - } - - // Fila 3: Genética (slots 20–24) - for (int i = 0; i < 5; i++) { - Material mat = geneticPoints >= (i + 1) * pointsPerHeart ? Material.SLIME_SPAWN_EGG : Material.VEX_SPAWN_EGG; - inv.setItem(20 + i, createItem(mat, ChatColor.YELLOW + "Genetics: " + geneticPoints + "/" + maxPoints)); - } - - // Fila 4: Stats de la montura (health, speed, jump en slots 29, 31, 33) - double health = MountUtils.healthPointsToHearts(MountUtils.getMaxHealth(entity)); - double speed = MountUtils.speedToBlocksPerSecond(MountUtils.getSpeed(entity)); - double jump = MountUtils.jumpStrengthToBlockHeight(MountUtils.getJumpStrength(entity)); - - inv.setItem(30, createItem(Material.GOLDEN_APPLE, ChatColor.LIGHT_PURPLE + "Health: " + String.format("%.2f", health))); - inv.setItem(31, createItem(Material.FEATHER, ChatColor.AQUA + "Speed: " + String.format("%.2f", speed))); - inv.setItem(32, createItem(Material.LEATHER_BOOTS, ChatColor.GREEN + "Jump: " + String.format("%.2f", jump))); - - player.openInventory(inv); - } - - private ItemStack createItem(Material material, String displayName) { - ItemStack item = new ItemStack(material); - ItemMeta meta = item.getItemMeta(); - meta.setDisplayName(displayName); - item.setItemMeta(meta); - return item; - } - - @EventHandler - public void onInventoryClick(InventoryClickEvent event) { - InventoryHolder holder = event.getInventory().getHolder(); - if (holder instanceof AnimalInventoryHolder animalHolder) { - event.setCancelled(true); - UUID entityUUID = animalHolder.getEntityUUID(); - ItemStack clicked = event.getCurrentItem(); - if (clicked == null || clicked.getType() != Material.WRITABLE_BOOK) return; - if (event.getWhoClicked() instanceof Player player) { - if (!AnimalIdentity.current(animalHolder.session(), player)) { player.closeInventory(); return; } - FarmAnimal animal = OwnershipManager.getAnimal(entityUUID, player.getUniqueId()); - if (animal != null) { - OwnershipManager.removeOwnership(player.getUniqueId(), animal); - player.closeInventory(); - player.sendMessage(String.format(Messages.removeOwnership, animal.getName())); - if (animal.getOwnersUuids().isEmpty()) { - animal.setState(AnimalStates.UNOWNED); - UnownedAnimalsManager.addUnownedAnimal(animal); - } - } - } - } - } -} diff --git a/src/main/java/net/tfminecraft/breedingbuddies/listeners/MountListener.java b/src/main/java/net/tfminecraft/breedingbuddies/listeners/MountListener.java deleted file mode 100644 index fd9b594..0000000 --- a/src/main/java/net/tfminecraft/breedingbuddies/listeners/MountListener.java +++ /dev/null @@ -1,96 +0,0 @@ -package net.tfminecraft.breedingbuddies.listeners; - -import net.tfminecraft.breedingbuddies.*; -import net.tfminecraft.breedingbuddies.configurables.CustomItems; -import net.tfminecraft.breedingbuddies.configurables.Messages; -import org.bukkit.NamespacedKey; -import org.bukkit.entity.AbstractHorse; -import org.bukkit.entity.Animals; -import org.bukkit.entity.Entity; -import org.bukkit.entity.Player; -import org.bukkit.event.EventHandler; -import org.bukkit.event.Listener; -import org.bukkit.event.entity.EntityDeathEvent; -import org.bukkit.event.player.PlayerInteractEntityEvent; -import org.bukkit.inventory.EquipmentSlot; -import org.bukkit.inventory.ItemStack; -import org.bukkit.persistence.PersistentDataType; -import org.bukkit.event.entity.EntityMountEvent; - -public class MountListener implements Listener { - @EventHandler - public void onMount(EntityMountEvent event) { - Entity entity = event.getMount(); - Entity rider = event.getEntity(); - - if (!(rider instanceof Player)) { - return; - } - - Player player = (Player) rider; - if (AnimalUtils.isFarmAnimal(entity) || PluginData.knowsAnimal(entity.getUniqueId())) { - //se cancela el evento si el caballo tiene dueño y no es el que interacciona - if (AnimalIdentity.session(player).isEmpty() || (OwnershipManager.hasOwner(entity.getUniqueId()) && !OwnershipManager.isOwner(player.getUniqueId(), entity.getUniqueId()))) { - SoundManager.playAngryCowSound(entity); - event.setCancelled(true); - } - } - } - - @EventHandler - public void onInteract(PlayerInteractEntityEvent event) { - Player player = event.getPlayer(); - Entity entity = event.getRightClicked(); - ItemStack itemInHand = player.getInventory().getItemInMainHand(); - if ((AnimalUtils.isFarmAnimal(entity) || PluginData.knowsAnimal(entity.getUniqueId())) && OwnershipManager.hasOwner(entity.getUniqueId()) && !OwnershipManager.isOwner(player.getUniqueId(), entity.getUniqueId()) && !(player.hasPermission("breedingbuddies.use") && AnimalIdentity.session(player).isPresent() && !PluginData.isHeld(entity.getUniqueId()))) { event.setCancelled(true); return; } - - if(event.getHand().equals(EquipmentSlot.HAND)) { - if (MountUtils.isFullStatMount(entity) && ItemUtils.getItemId(itemInHand).equalsIgnoreCase(CustomItems.neuterItem)) { - if (AnimalIdentity.session(player).isPresent() && !PluginData.isHeld(entity.getUniqueId()) && (OwnershipManager.isOwner(player.getUniqueId(), entity.getUniqueId()) || player.hasPermission("breedingbuddies.use"))) { - AbstractHorse abstractHorse = (AbstractHorse) entity; - FarmAnimal animal = OwnershipManager.getAnimal(entity.getUniqueId(), player.getUniqueId()); - - if (animal != null && !MountUtils.isNeutered(entity)) { - if (!abstractHorse.isAdult() || player.hasPermission("breedingbuddies.use")) { - SoundManager.playFoalScream(entity); - MountUtils.setNeuteredFlag(entity, true); - event.setCancelled(true); - } - } - } - } - } - } - - //BORRAR - @EventHandler - public void onInteractFix(PlayerInteractEntityEvent event) { - Entity entity = event.getRightClicked(); - - if(event.getHand().equals(EquipmentSlot.HAND)) { - FarmAnimal animal = OwnershipManager.getAnimal(entity.getUniqueId()); - if (MountUtils.isFullStatMount(entity) && animal != null && OwnershipManager.isOwner(event.getPlayer().getUniqueId(), entity.getUniqueId())) { - animal.setStableNeeded(false); - } - } - } - - //BORRAR - @EventHandler - public void onMountFix(EntityMountEvent event) { - Entity entity = event.getMount(); - Entity rider = event.getEntity(); - - if (!(rider instanceof Player)) { - return; - } - - Player player = (Player) rider; - if (entity instanceof Animals) { - FarmAnimal animal = OwnershipManager.getAnimal(entity.getUniqueId(), player.getUniqueId()); - if (animal != null) { - animal.setStableNeeded(false); - } - } - } -} diff --git a/src/main/java/net/tfminecraft/breedingbuddies/listeners/NerfMountListener.java b/src/main/java/net/tfminecraft/breedingbuddies/listeners/NerfMountListener.java deleted file mode 100644 index db19b34..0000000 --- a/src/main/java/net/tfminecraft/breedingbuddies/listeners/NerfMountListener.java +++ /dev/null @@ -1,89 +0,0 @@ -package net.tfminecraft.breedingbuddies.listeners; - -import net.tfminecraft.breedingbuddies.BreedingBuddies; -import net.tfminecraft.breedingbuddies.configurables.Numbers; -import org.bukkit.Bukkit; -import org.bukkit.NamespacedKey; -import org.bukkit.attribute.Attribute; -import org.bukkit.attribute.AttributeInstance; -import org.bukkit.entity.AbstractHorse; -import org.bukkit.entity.Entity; -import org.bukkit.event.EventHandler; -import org.bukkit.event.Listener; -import org.bukkit.event.entity.CreatureSpawnEvent; -import org.bukkit.event.world.ChunkLoadEvent; -import org.bukkit.persistence.PersistentDataType; - -public class NerfMountListener implements Listener { - @EventHandler - public void onHorseSpawn(CreatureSpawnEvent event) { - if (!Numbers.nerfMountStats) return; - - if (!(event.getEntity() instanceof AbstractHorse horse)) return; - - // Solo nerfear si se genera de forma natural o con huevo - if (event.getSpawnReason() == CreatureSpawnEvent.SpawnReason.NATURAL || event.getSpawnReason() == CreatureSpawnEvent.SpawnReason.SPAWNER_EGG) { - nerf(horse); - } - - // marca como ya procesados los spawneados por comando - else if (event.getSpawnReason() == CreatureSpawnEvent.SpawnReason.COMMAND) { - setNerfedFlag(horse, true); // solo marcador, sin nerfear - } - } - - @EventHandler - public void onChunkLoad(ChunkLoadEvent event) { - if (!Numbers.nerfMountStats) return; - - for (Entity entity : event.getChunk().getEntities()) { - if (!(entity instanceof AbstractHorse horse)) continue; - if (event.isNewChunk()) { - nerf(horse); - } else if (!isNerfed(horse)) { - nerf(horse); - } - } - } - - private void nerf(AbstractHorse horse) { - // Evita nerfeo múltiple - if (isNerfed(horse)) return; - - // Marca como nerfeado - setNerfedFlag(horse, true); - - // Asegura valores seguros y aplica divisor - try { - AttributeInstance health = horse.getAttribute(Attribute.MAX_HEALTH); - AttributeInstance speed = horse.getAttribute(Attribute.MOVEMENT_SPEED); - - if (health != null) { - double newHealth = Math.max(Numbers.minMountHealth, health.getBaseValue() / Numbers.mountNerfDivisor); - health.setBaseValue(newHealth); - } - - if (speed != null) { - double newSpeed = Math.max(Numbers.minMountSpeed, speed.getBaseValue() / Numbers.mountNerfDivisor); - speed.setBaseValue(newSpeed); - } - - double newJump = Math.max(Numbers.minMountJump, horse.getJumpStrength() / Numbers.mountNerfDivisor); - horse.setJumpStrength(newJump); - - } catch (Exception e) { - Bukkit.getLogger().warning("[BreedinBuddies] Error while nerfing horse: " + e.getMessage()); - } - } - - public void setNerfedFlag(Entity entity, boolean isNerfed) { - NamespacedKey key = new NamespacedKey(BreedingBuddies.getInstance(), "is_nerfed"); - entity.getPersistentDataContainer().set(key, PersistentDataType.BYTE, (byte) (isNerfed ? 1 : 0)); - } - - public boolean isNerfed(Entity entity) { - NamespacedKey key = new NamespacedKey(BreedingBuddies.getInstance(), "is_nerfed"); - Byte value = entity.getPersistentDataContainer().get(key, PersistentDataType.BYTE); - return value != null && value == 1; - } -} diff --git a/src/main/java/net/tfminecraft/breedingbuddies/listeners/TamingListener.java b/src/main/java/net/tfminecraft/breedingbuddies/listeners/TamingListener.java deleted file mode 100644 index cb218b4..0000000 --- a/src/main/java/net/tfminecraft/breedingbuddies/listeners/TamingListener.java +++ /dev/null @@ -1,275 +0,0 @@ -package net.tfminecraft.breedingbuddies.listeners; - -import net.tfminecraft.breedingbuddies.*; -import net.tfminecraft.breedingbuddies.configurables.CustomItems; -import net.tfminecraft.breedingbuddies.configurables.Messages; -import org.bukkit.Bukkit; -import org.bukkit.ChatColor; -import org.bukkit.NamespacedKey; -import org.bukkit.entity.Entity; -import org.bukkit.entity.LivingEntity; -import org.bukkit.entity.Player; -import org.bukkit.event.EventHandler; -import org.bukkit.event.Listener; -import org.bukkit.event.block.Action; -import org.bukkit.event.player.PlayerInteractEntityEvent; -import org.bukkit.event.player.PlayerInteractEvent; -import org.bukkit.inventory.EquipmentSlot; -import org.bukkit.inventory.ItemStack; -import org.bukkit.inventory.meta.ItemMeta; -import org.bukkit.persistence.PersistentDataType; -import org.bukkit.plugin.java.JavaPlugin; -import org.bukkit.entity.Tameable; - -import java.util.ArrayList; -import java.util.List; -import java.util.UUID; - -public class TamingListener implements Listener { - private JavaPlugin plugin; - - public TamingListener(JavaPlugin plugin) { - this.plugin = plugin; - } - - @EventHandler - public void onRightClick(PlayerInteractEvent event) { - if (event.getAction().equals(Action.RIGHT_CLICK_BLOCK) || event.getAction().equals(Action.RIGHT_CLICK_AIR)) { - ItemStack itemInHand = event.getItem(); - Player player = event.getPlayer(); - if (itemInHand != null) { - String itemInHandId = ItemUtils.getItemId(itemInHand); - if (itemInHandId.equalsIgnoreCase(CustomItems.coownershipItem)) { - handleEmptyInteraction(player, itemInHand); - } - } - } - } - @EventHandler - public void onInteract(PlayerInteractEntityEvent event) { - Player player = event.getPlayer(); - Entity entity = event.getRightClicked(); - ItemStack itemInHand = player.getInventory().getItemInMainHand(); - - /** PARA VER SI ES UN MYTHICMOB - * ActiveMob mythicMob = MythicBukkit.inst().getAPIHelper().getMythicMobInstance(entity); - * if (mythicMob == null) { - * return; - * } - * MYTHICMOB ID - * String mobId = mythicMob.getType().getInternalName(); - */ - - String itemInHandId = ItemUtils.getItemId(itemInHand); - if (itemInHandId.equalsIgnoreCase(CustomItems.tamingItem) || itemInHandId.equalsIgnoreCase(CustomItems.coownershipItem)) - { - event.setCancelled(true); - if (entity instanceof Tameable) { - Tameable tameable = (Tameable) entity; - if (!tameable.isTamed()) { - player.sendMessage(Messages.tameFirst); - return; - } - } - } - - if (event.getHand().equals(EquipmentSlot.HAND)) { - if (AnimalUtils.isFarmAnimal(entity)) { - handleFarmAnimalInteraction(player, entity, itemInHand); - } else if (entity instanceof Player && ItemUtils.getItemId(itemInHand).equalsIgnoreCase(CustomItems.coownershipItem)) { - handlePlayerInteraction(player, entity, itemInHand); - } - } - } - - private void handleEmptyInteraction(Player player, ItemStack itemInHand) { - if (AnimalIdentity.session(player).isEmpty() || !PluginData.ownershipReady()) { player.sendMessage("Select a character before changing animal ownership."); return; } - String entityUuid = ItemUtils.getDataFromItem(itemInHand, plugin); - if (entityUuid == null) { - player.sendMessage(Messages.useOnOwnedAnimal); - return; - } - UUID uuid = UUID.fromString(entityUuid); - FarmAnimal animal = OwnershipManager.getAnimal(uuid); - if (animal == null || OwnershipManager.hasHeldSlots(uuid)) { - player.sendMessage(Messages.invalidAnimalOrNotFound); - return; - } - if (OwnershipManager.isOwner(player.getUniqueId(), uuid)) { - player.sendMessage(String.format(Messages.alreadyOwner, animal.getName())); - } else { - if (!acquire(player, player, animal, itemInHand, () -> {})) return; - SoundManager.playAmethystClusterStepSound(player); - ParticleManager.cherryParticles(player); - } - } - - private void handlePlayerInteraction(Player player, Entity targetPlayer, ItemStack itemInHand) { - if (AnimalIdentity.session(player).isEmpty() || !PluginData.ownershipReady()) { player.sendMessage("Select a character before changing animal ownership."); return; } - String entityUuid = ItemUtils.getDataFromItem(itemInHand, plugin); - if (entityUuid == null) { - player.sendMessage(Messages.useOnOwnedAnimal); - return; - } - UUID uuid = UUID.fromString(entityUuid); - FarmAnimal animal = OwnershipManager.getAnimal(uuid); - if (animal == null || OwnershipManager.hasHeldSlots(uuid)) { - player.sendMessage(Messages.invalidAnimalOrNotFound); - return; - } - if (OwnershipManager.isOwner(targetPlayer.getUniqueId(), uuid)) { - player.sendMessage(String.format(Messages.alreadyOwner, animal.getName())); - } else { - if (AnimalIdentity.session((Player) targetPlayer).isEmpty()) { player.sendMessage("The recipient must select a character first."); return; } - if (!acquire(player, (Player) targetPlayer, animal, itemInHand, () -> {})) return; - SoundManager.playAmethystClusterStepSound(targetPlayer); - ParticleManager.cherryParticles(targetPlayer); - } - } - - private void handleFarmAnimalInteraction(Player player, Entity entity, ItemStack itemInHand) { - String itemInHandId = ItemUtils.getItemId(itemInHand); - if (!itemInHandId.equalsIgnoreCase(CustomItems.tamingItem) && !itemInHandId.equalsIgnoreCase(CustomItems.coownershipItem)) return; - if (AnimalIdentity.session(player).isEmpty() || PluginData.isHeld(entity.getUniqueId())) { player.sendMessage("Animal ownership is unavailable until a character and reviewed record are ready."); return; } - - if (itemInHandId.equalsIgnoreCase(CustomItems.tamingItem)) { - processTamingItem(player, entity, itemInHand); - } else if (itemInHandId.equalsIgnoreCase(CustomItems.coownershipItem)) { - processCoownershipItem(player, entity, itemInHand); - } - } - - private void processCoownershipItem(Player player, Entity entity, ItemStack itemInHand) { - String itemData = ItemUtils.getDataFromItem(itemInHand, plugin); - if (!OwnershipManager.hasOwner(entity.getUniqueId())) { - player.sendMessage(Messages.ownerFirst); - } else if (itemData != null && entity.getUniqueId().toString().equals(itemData)) { - player.sendMessage(String.format(Messages.alreadyLinked, entity.getCustomName())); - } else if (OwnershipManager.isOwner(player.getUniqueId(), entity.getUniqueId())) { - ItemUtils.storeDataInItem(itemInHand, entity.getUniqueId().toString(), plugin); - changeTokenLore(entity, itemInHand); - SoundManager.playAmethystStepSound(entity); - ParticleManager.cherryParticles(entity); - } else { - player.sendMessage(String.format(Messages.onlyOwnerCanShare, entity.getCustomName())); - } - } - - public ItemStack changeTokenLore(Entity entity, ItemStack itemBase) { - if (entity == null || itemBase == null || itemBase.getType().isAir()) return itemBase; - - String nombreEntidad = null; - if (entity instanceof LivingEntity living) { - if (living.getCustomName() != null && !living.getCustomName().isEmpty()) { - nombreEntidad = ChatColor.stripColor(living.getCustomName()); // Limpia colores si los hubiera - } - } - - String tipo = formatearTipo(entity.getType().name()); - - String loreLine; - if (nombreEntidad != null) { - loreLine = ChatColor.GRAY + "This token is linked to " + nombreEntidad + " the " + tipo + "."; - ItemMeta meta = itemBase.getItemMeta(); - if (meta != null) { - List lore = new ArrayList<>(); - lore.add(loreLine); - meta.setLore(lore); - itemBase.setItemMeta(meta); - } - } - return itemBase; - } - - - // Convierte ENTITY_TYPE en "Entity Type" o "Cow", "Zombie Villager", etc. - private String formatearTipo(String typeName) { - String[] palabras = typeName.toLowerCase().split("_"); - StringBuilder resultado = new StringBuilder(); - for (String palabra : palabras) { - resultado.append(Character.toUpperCase(palabra.charAt(0))) - .append(palabra.substring(1)) - .append(" "); - } - return resultado.toString().trim(); // "Cow", "Zombie Villager", etc. - } - - private void processTamingItem(Player player, Entity entity, ItemStack itemInHand) { - boolean hasOwner = OwnershipManager.hasOwner(entity.getUniqueId()); - boolean playerIsOwner = OwnershipManager.isOwner(player.getUniqueId(), entity.getUniqueId()); - if (hasOwner && playerIsOwner) { - FarmAnimal animal = OwnershipManager.getAnimal(entity.getUniqueId(), player.getUniqueId()); - - if (animal != null && name(entity, itemInHand, animal)) { - SoundManager.playAmethystClusterStepSound(entity); - } else { - player.sendMessage(Messages.nameFirst); - } - } else if (hasOwner) { - player.sendMessage(Messages.notOwner); - } else { - String chosenName = ItemUtils.getCustomName(itemInHand); - if (chosenName == null || ItemUtils.getDataFromItem(itemInHand, plugin) == null) { player.sendMessage(Messages.nameFirst); return; } - FarmAnimal animal = createTameAnimal(player, entity, chosenName); - if (acquire(player, player, animal, itemInHand, () -> { entity.setCustomName(chosenName); entity.setCustomNameVisible(false); })) { - player.sendMessage(String.format(Messages.nowOwner, chosenName)); - SoundManager.playAmethystClusterStepSound(entity);ParticleManager.cherryParticles(entity); - } - } - } - private boolean acquire(Player actor, Player recipient, FarmAnimal animal, ItemStack item, Runnable entityEffect) { - AnimalTokenIntent intent = null; - try { - intent = AnimalTokenIntent.prepare(actor, recipient, animal, item); - OwnershipManager.registerOwnership(recipient.getUniqueId(), animal); - intent.finish(actor, recipient, animal, item, entityEffect); - return true; - } catch (RuntimeException failure) { - if (intent != null) intent.hold(animal); - actor.sendMessage("Animal ownership needs review; preserve this token and report the recorded failure."); - plugin.getLogger().warning("Animal token " + animal.getUuid() + ": " + failure.getMessage()); - return false; - } - } - public void tame(Player player, Entity entity) { - if (AnimalIdentity.session(player).isEmpty() || PluginData.isHeld(entity.getUniqueId())) return; - OwnershipManager.registerOwnership(player.getUniqueId(), createTameAnimal(player, entity, entity.getCustomName())); - } - private FarmAnimal createTameAnimal(Player player, Entity entity, String chosenName) { - FarmAnimal animal; - FarmAnimal previous = UnownedAnimalsManager.getAnimal(entity.getUniqueId()); - if (previous != null && previous.getState() == AnimalStates.SPAWNED) { - animal = new FarmAnimal(entity.getUniqueId(), chosenName, player.getUniqueId(), entity, previous.getGeneticPoints(), previous.getFriendshipPoints()); - } else if (previous != null) { - animal = new FarmAnimal(entity.getUniqueId(), chosenName, player.getUniqueId(), entity, previous.getGeneticPoints()); - } else { - animal = new FarmAnimal(entity.getUniqueId(), chosenName, player.getUniqueId(), entity); - } - animal.setName(chosenName);animal.setState(AnimalStates.HAPPY);return animal; - } - - public boolean name(Entity entity, ItemStack itemInHand) { - String customName = ItemUtils.getCustomName(itemInHand); - if (customName == null || ItemUtils.getDataFromItem(itemInHand, plugin) == null) { - return false; - } else { - entity.setCustomName(customName); - entity.setCustomNameVisible(false); - itemInHand.setAmount(itemInHand.getAmount() - 1); - return true; - } - } - - public boolean name(Entity entity, ItemStack itemInHand, FarmAnimal animal) { - String customName = ItemUtils.getCustomName(itemInHand); - if (customName == null || ItemUtils.getDataFromItem(itemInHand, plugin) == null) { - return false; - } else { - entity.setCustomName(customName); - entity.setCustomNameVisible(false); - animal.setName(customName); - itemInHand.setAmount(itemInHand.getAmount() - 1); - return true; - } - } -} diff --git a/src/main/resources/config.yml b/src/main/resources/config.yml new file mode 100644 index 0000000..d08a707 --- /dev/null +++ b/src/main/resources/config.yml @@ -0,0 +1,424 @@ +max-animals: 15 +# Trust is shown as five hearts; care-max is full trust. +care-max: 200 + +min-loaded: 60s +decay-grace: 24h +long-unload-force: 8h +milk-timer: 20m +milk-cooldown: 20m +wool-timer: 4h +shed-timer: 4h +shed-chance: 0.15 +# Time between eggs from a loaded, mature, happy chicken. +egg-timer: 1h + +affliction: + mean: 6h + min: 4h + max: 8h + +# Trust only grows through an owner's care. A pet is an empty-handed right-click, +# or riding for mounts, and counts once per pet-cooldown. +trust: + feed: 10 + glove: 10 + pet: 5 + pet-cooldown: 20h + +# Neglect costs trust: after decay-grace with Hungry or Dirty, lose this much per interval. +care: + down: + interval: 10h + amount: 1 + +grow-up: 1h + +profession: farming + +exp: + min: 6 + max: 8 + +initial-genetic-max: 20 +max-genetics: 1000 +# Meat portions from effective genetics, before the closing bone. +# Poultry raises this floor to two legs and one filet (carve-sequences.yml). +min-roast-cuts: 1 +# Bump this string to wild-reset animals with a missing or old id (genetics + care). +stats-revision: "1" +# Respawn an owned animal from its last snapshot when a crash or forced stop loses it from the world save. +restore-lost-animals: true + +breeding: + # Bigger = larger gene bump per generation. + genetic-variance-multiplier: 0.2 + # Bigger = slower climb when parents already have high genetics. + genetic-slowdown-divisor: 0.6 + # Extra gene points at full parent care: this * max-genetics (0.02 -> +20 at 1000). + care-influence: 0.02 + # Breeding-food stars scale that bonus (1★ = 20% … 5★ = 100%, same roll as before). + # Universal feed and items with no cooking quality count as a full bonus. + +amount-from-genetics: + - min: 0 + roast-cuts: 1 + wool: 1 + secondary-extra: 0 + - min: 125 + roast-cuts: 2 + wool: 1 + secondary-extra: 0 + - min: 250 + roast-cuts: 3 + wool: 1 + secondary-extra: 0 + - min: 375 + roast-cuts: 4 + wool: 2 + secondary-extra: 0 + - min: 500 + roast-cuts: 5 + wool: 2 + secondary-extra: 1 + - min: 625 + roast-cuts: 6 + wool: 3 + secondary-extra: 1 + - min: 750 + roast-cuts: 7 + wool: 3 + secondary-extra: 2 + - min: 875 + roast-cuts: 8 + wool: 4 + secondary-extra: 2 + +items: + tame: m.pets.taming_item + co-own: m.pets.coownership_item + feed: m.pets.universal_feed + glove: m.pets.caring_item + neuter: m.pets.neutering_item + inspect: "" + mount-stats: "" + +remove-unowned: + - COW + - PIG + - SHEEP + - CHICKEN + - GOAT + - HORSE + - DONKEY + - MULE + - CAMEL + - LLAMA + - CAT + - OCELOT + - WOLF + +species: + COW: + milk: true + slaughter: + meat: "meat(type=roast;origin=Beef)" + drops: + mode: counted + common: + - { path: m.materials.leather, amount: 4, weight: 100 } + rare: + - { path: m.materials.leather, amount: 8, weight: 20 } + - { path: m.materials.rare_leather, amount: 4, weight: 60 } + - { path: m.materials.epic_leather, amount: 2, weight: 20 } + epic: + - { path: m.materials.rare_leather, amount: 8, weight: 20 } + - { path: m.materials.epic_leather, amount: 4, weight: 60 } + - { path: m.materials.legendary_leather, amount: 2, weight: 20 } + legendary: + - { path: m.materials.epic_leather, amount: 8, weight: 40 } + - { path: m.materials.legendary_leather, amount: 4, weight: 60 } + # Manure for the fields. Swap the path for the server's fertiliser item if it has one. + shed: + drops: + mode: counted + common: + - { path: v.bone_meal, amount: 2, weight: 100 } + PIG: + slaughter: + meat: "meat(type=roast;origin=Pork)" + drops: + rare: + - { path: "", weight: 97 } + - { path: m.currency.enchanted_dust, amount: 2, weight: 3 } + epic: + - { path: "", weight: 90 } + - { path: m.currency.enchanted_dust, amount: 2, weight: 10 } + legendary: + - { path: "", weight: 80 } + - { path: m.currency.enchanted_dust, amount: 2, weight: 20 } + # Rooting about, a happy pig sometimes turns something up. + shed: + drops: + rare: + - { path: "", weight: 97 } + - { path: m.currency.enchanted_dust, amount: 1, weight: 3 } + epic: + - { path: "", weight: 90 } + - { path: m.currency.enchanted_dust, amount: 1, weight: 10 } + legendary: + - { path: "", weight: 80 } + - { path: m.currency.enchanted_dust, amount: 1, weight: 20 } + + SHEEP: + slaughter: + meat: "meat(type=roast;origin=Mutton)" + drops: + mode: counted + common: + - { path: v.white_wool, amount: 4, weight: 100 } + rare: + - { path: v.white_wool, amount: 8, weight: 20 } + - { path: m.materials.rare_wool, amount: 4, weight: 60 } + - { path: m.materials.epic_wool, amount: 2, weight: 20 } + epic: + - { path: m.materials.rare_wool, amount: 8, weight: 20 } + - { path: m.materials.epic_wool, amount: 4, weight: 60 } + - { path: m.materials.legendary_wool, amount: 2, weight: 20 } + legendary: + - { path: m.materials.epic_wool, amount: 8, weight: 40 } + - { path: m.materials.legendary_wool, amount: 4, weight: 60 } + shear: + drops: + mode: counted + common: + - { path: v.white_wool, amount: 1, weight: 100 } + rare: + - { path: v.white_wool, amount: 2, weight: 20 } + - { path: m.materials.rare_wool, amount: 1, weight: 60 } + - { path: m.materials.epic_wool, amount: 1, weight: 20 } + epic: + - { path: m.materials.rare_wool, amount: 2, weight: 20 } + - { path: m.materials.epic_wool, amount: 1, weight: 60 } + - { path: m.materials.legendary_wool, amount: 1, weight: 20 } + legendary: + - { path: m.materials.epic_wool, amount: 2, weight: 40 } + - { path: m.materials.legendary_wool, amount: 1, weight: 60 } + CHICKEN: + grow-up: 45m + egg: "food(type=egg;origin=Egg;tags=freshness.0:cooked.0)" + slaughter: + meat: "meat(type=roast;origin=Chicken)" + drops: + mode: counted + common: + - { path: v.feather, amount: 4, weight: 100 } + rare: + - { path: v.feather, amount: 8, weight: 20 } + - { path: m.materials.rare_feather, amount: 4, weight: 60 } + - { path: m.materials.epic_feather, amount: 2, weight: 20 } + epic: + - { path: m.materials.rare_feather, amount: 8, weight: 20 } + - { path: m.materials.epic_feather, amount: 4, weight: 60 } + - { path: m.materials.legendary_feather, amount: 2, weight: 20 } + legendary: + - { path: m.materials.epic_feather, amount: 8, weight: 40 } + - { path: m.materials.legendary_feather, amount: 4, weight: 60 } + # Feathers from moulting. + shed: + drops: + mode: counted + common: + - { path: v.feather, amount: 1, weight: 100 } + rare: + - { path: v.feather, amount: 2, weight: 20 } + - { path: m.materials.rare_feather, amount: 1, weight: 60 } + - { path: m.materials.epic_feather, amount: 1, weight: 20 } + epic: + - { path: m.materials.rare_feather, amount: 2, weight: 20 } + - { path: m.materials.epic_feather, amount: 1, weight: 60 } + - { path: m.materials.legendary_feather, amount: 1, weight: 20 } + legendary: + - { path: m.materials.epic_feather, amount: 2, weight: 40 } + - { path: m.materials.legendary_feather, amount: 1, weight: 60 } + GOAT: + milk: true + slaughter: + meat: "meat(type=roast;origin=Goat)" + drops: + mode: counted + common: + - { path: v.white_wool, amount: 4, weight: 100 } + rare: + - { path: v.white_wool, amount: 8, weight: 20 } + - { path: m.materials.rare_wool, amount: 4, weight: 60 } + - { path: m.materials.epic_wool, amount: 2, weight: 20 } + epic: + - { path: m.materials.rare_wool, amount: 8, weight: 20 } + - { path: m.materials.epic_wool, amount: 4, weight: 60 } + - { path: m.materials.legendary_wool, amount: 2, weight: 20 } + legendary: + - { path: m.materials.epic_wool, amount: 8, weight: 40 } + - { path: m.materials.legendary_wool, amount: 4, weight: 60 } + shear: + drops: + mode: counted + common: + - { path: v.white_wool, amount: 1, weight: 100 } + rare: + - { path: v.white_wool, amount: 2, weight: 20 } + - { path: m.materials.rare_wool, amount: 1, weight: 60 } + - { path: m.materials.epic_wool, amount: 1, weight: 20 } + epic: + - { path: m.materials.rare_wool, amount: 2, weight: 20 } + - { path: m.materials.epic_wool, amount: 1, weight: 60 } + - { path: m.materials.legendary_wool, amount: 1, weight: 20 } + legendary: + - { path: m.materials.epic_wool, amount: 2, weight: 40 } + - { path: m.materials.legendary_wool, amount: 1, weight: 60 } + HORSE: + slaughter: + meat: "meat(type=roast;origin=Horse)" + drops: + mode: counted + common: + - { path: m.materials.leather, amount: 4, weight: 100 } + rare: + - { path: m.materials.leather, amount: 8, weight: 20 } + - { path: m.materials.rare_leather, amount: 4, weight: 60 } + - { path: m.materials.epic_leather, amount: 2, weight: 20 } + epic: + - { path: m.materials.rare_leather, amount: 8, weight: 20 } + - { path: m.materials.epic_leather, amount: 4, weight: 60 } + - { path: m.materials.legendary_leather, amount: 2, weight: 20 } + legendary: + - { path: m.materials.epic_leather, amount: 8, weight: 40 } + - { path: m.materials.legendary_leather, amount: 4, weight: 60 } + DONKEY: + slaughter: + meat: "meat(type=roast;origin=Donkey)" + drops: + mode: counted + common: + - { path: m.materials.leather, amount: 4, weight: 100 } + rare: + - { path: m.materials.leather, amount: 8, weight: 20 } + - { path: m.materials.rare_leather, amount: 4, weight: 60 } + - { path: m.materials.epic_leather, amount: 2, weight: 20 } + epic: + - { path: m.materials.rare_leather, amount: 8, weight: 20 } + - { path: m.materials.epic_leather, amount: 4, weight: 60 } + - { path: m.materials.legendary_leather, amount: 2, weight: 20 } + legendary: + - { path: m.materials.epic_leather, amount: 8, weight: 40 } + - { path: m.materials.legendary_leather, amount: 4, weight: 60 } + MULE: + slaughter: + meat: "meat(type=roast;origin=Mule)" + drops: + mode: counted + common: + - { path: m.materials.leather, amount: 4, weight: 100 } + rare: + - { path: m.materials.leather, amount: 8, weight: 20 } + - { path: m.materials.rare_leather, amount: 4, weight: 60 } + - { path: m.materials.epic_leather, amount: 2, weight: 20 } + epic: + - { path: m.materials.rare_leather, amount: 8, weight: 20 } + - { path: m.materials.epic_leather, amount: 4, weight: 60 } + - { path: m.materials.legendary_leather, amount: 2, weight: 20 } + legendary: + - { path: m.materials.epic_leather, amount: 8, weight: 40 } + - { path: m.materials.legendary_leather, amount: 4, weight: 60 } + CAMEL: + slaughter: + meat: "meat(type=roast;origin=Camel)" + drops: + mode: counted + common: + - { path: m.materials.leather, amount: 4, weight: 100 } + rare: + - { path: m.materials.leather, amount: 8, weight: 20 } + - { path: m.materials.rare_leather, amount: 4, weight: 60 } + - { path: m.materials.epic_leather, amount: 2, weight: 20 } + epic: + - { path: m.materials.rare_leather, amount: 8, weight: 20 } + - { path: m.materials.epic_leather, amount: 4, weight: 60 } + - { path: m.materials.legendary_leather, amount: 2, weight: 20 } + legendary: + - { path: m.materials.epic_leather, amount: 8, weight: 40 } + - { path: m.materials.legendary_leather, amount: 4, weight: 60 } + LLAMA: + slaughter: + meat: "meat(type=roast;origin=Llama)" + drops: + mode: counted + common: + - { path: m.materials.leather, amount: 4, weight: 100 } + rare: + - { path: m.materials.leather, amount: 8, weight: 20 } + - { path: m.materials.rare_leather, amount: 4, weight: 60 } + - { path: m.materials.epic_leather, amount: 2, weight: 20 } + epic: + - { path: m.materials.rare_leather, amount: 8, weight: 20 } + - { path: m.materials.epic_leather, amount: 4, weight: 60 } + - { path: m.materials.legendary_leather, amount: 2, weight: 20 } + legendary: + - { path: m.materials.epic_leather, amount: 8, weight: 40 } + - { path: m.materials.legendary_leather, amount: 4, weight: 60 } + +damage: + other-players: true + owner: true + mobs: true + environment: true + +quality-from-genetics: + - min: 0 + stars: 1 + - min: 200 + stars: 2 + - min: 400 + stars: 3 + - min: 600 + stars: 4 + - min: 800 + stars: 5 + +mounts: + # Every stat (health, speed, jump) is max * (min-pct + genetics-pct * gene + care-pct * trust), + # never below the type's minimum. Genes come from breeding; full genes and trust reach the max. + # As on TF Main: trust is worth as much as genes. + shares: + min-pct: 0.40 + genetics-pct: 0.30 + care-pct: 0.30 + # Horse maximums as tuned on TF Main. + HORSE: + min-health: 10 + max-health: 27 + min-speed: 0.0675 + max-speed: 0.27 + min-jump: 0.24 + max-jump: 0.8 + DONKEY: + min-health: 10 + max-health: 20 + min-speed: 0.0675 + max-speed: 0.2025 + min-jump: 0.24 + max-jump: 0.6 + MULE: + min-health: 10 + max-health: 20 + min-speed: 0.0675 + max-speed: 0.2025 + min-jump: 0.24 + max-jump: 0.6 + CAMEL: + min-health: 20 + max-health: 20 + min-speed: 0.054 + max-speed: 0.054 + min-jump: 0.4 + max-jump: 0.4 diff --git a/src/main/resources/plugin.yml b/src/main/resources/plugin.yml index d5c841e..c9c9f45 100644 --- a/src/main/resources/plugin.yml +++ b/src/main/resources/plugin.yml @@ -3,10 +3,20 @@ api-version: '1.21.10' name: BreedingBuddies version: ${project.version} author: Nowko -depend: [MMOItems, MythicLib, TLibs] +depend: [TLibs, Cooking] +softdepend: [RPCharacters, MMOCore] commands: breedingbuddies: - description: Main command for the BreedingBuddies plugin. - usage: / - permission: breedingbuddies.use - permission-message: You do not have permission to use this command. + description: BreedingBuddies staff commands + usage: /breedingbuddies reload | spawn [genetics] [care] | save + aliases: [bb] + permission: breedingbuddies.admin + animals: + description: List the animals you own and where they were last seen + usage: /animals + aliases: [livestock] + +permissions: + breedingbuddies.admin: + description: Staff access to BreedingBuddies commands and to other characters' animals + default: op diff --git a/src/test/java/net/tfminecraft/breedingbuddies/AnimalCharacterAuthorityTest.java b/src/test/java/net/tfminecraft/breedingbuddies/AnimalCharacterAuthorityTest.java deleted file mode 100644 index 8233f57..0000000 --- a/src/test/java/net/tfminecraft/breedingbuddies/AnimalCharacterAuthorityTest.java +++ /dev/null @@ -1,86 +0,0 @@ -package net.tfminecraft.breedingbuddies; - -import net.tfminecraft.breedingbuddies.listeners.*; -import net.tfminecraft.breedingbuddies.configurables.CustomItems; -import net.tfminecraft.tlibs.character.*; -import org.bukkit.Material; -import org.bukkit.entity.*; -import org.bukkit.event.entity.*; -import org.bukkit.event.inventory.*; -import org.bukkit.event.player.*; -import org.bukkit.inventory.*; -import org.json.simple.*; -import org.junit.jupiter.api.Test; -import java.util.*; -import static org.junit.jupiter.api.Assertions.*; -import static org.mockito.Mockito.*; - -class AnimalCharacterAuthorityTest extends PluginTestBase { - CharacterSession select(Player player,UUID character,long generation) { var session=new CharacterSession(new CharacterRef(player.getUniqueId(),character),generation);sessions.put(player.getUniqueId(),session);return session; } - @Test void sameAccountSwitchCannotCareMountBreedShareRemoveOrViewAnotherCharactersAnimals() throws Exception { - var player=player();var a=select(player,UUID.randomUUID(),2);Horse horse=spawn(Horse.class);horse.setTamed(true);FarmAnimal animal=own(player,horse); - var menus=new InventoryManager();menus.openMountMenu(player,horse,animal);var holder=(AnimalInventoryHolder)player.getOpenInventory().getTopInventory().getHolder(); - assertEquals(a,holder.session());horse.addPassenger(player);sessions.remove(player.getUniqueId()); - server.getPluginManager().callEvent(new CharacterSessionEndEvent(player,a,CharacterSessionEndEvent.Reason.SWITCH));assertNull(player.getVehicle()); - var b=select(player,UUID.randomUUID(),3);assertFalse(OwnershipManager.isOwner(player.getUniqueId(),animal.getUuid()));assertTrue(OwnershipManager.getAnimals(player.getUniqueId()).isEmpty()); - var mount=new EntityMountEvent(player,horse);new MountListener().onMount(mount);assertTrue(mount.isCancelled()); - player.openInventory(holder.getInventory());var removal=new InventoryClickEvent(player.getOpenInventory(),InventoryType.SlotType.CONTAINER,35,ClickType.LEFT,InventoryAction.PICKUP_ALL); - menus.onInventoryClick(removal);assertTrue(removal.isCancelled());assertEquals(Set.of(a.ref().characterId()),animal.getOwnerRefs().keySet()); - player.getInventory().setItemInMainHand(mmoItem(CustomItems.universalFeed));var care=new PlayerInteractEntityEvent(player,horse,EquipmentSlot.HAND);new AnimalCareListener().onInteract(care); - assertTrue(care.isCancelled());assertFalse(animal.getFed()); - var tame=new TamingListener(plugin);var token=mmoItem(CustomItems.coownershipItem);ItemUtils.storeDataInItem(token,animal.getUuid().toString(),plugin); - // Existing bearer-token acquisitions address the NEW character explicitly; the token is not owner authority. - tame.onRightClick(new PlayerInteractEvent(player,org.bukkit.event.block.Action.RIGHT_CLICK_AIR,token,null,org.bukkit.block.BlockFace.SELF,EquipmentSlot.HAND)); - assertEquals(Set.of(a.ref().characterId(),b.ref().characterId()),animal.getOwnerRefs().keySet());assertEquals(0,token.getAmount()); - PluginData.loadAllData();assertTrue(OwnershipManager.isOwner(player.getUniqueId(),animal.getUuid())); - } - @Test void staleWrongAccountMissingSessionsAndHeldSlotsCannotGrantTokenOrMenuAuthority() throws Exception { - var player=player();var neighbour=player();var current=AnimalIdentity.session(player).orElseThrow();Cow cow=spawn(Cow.class);FarmAnimal animal=own(player,cow); - when(characters.isCurrent(current)).thenReturn(false);assertTrue(AnimalIdentity.session(player).isEmpty());assertFalse(AnimalIdentity.current(current,player)); - assertFalse(OwnershipManager.isOwner(player.getUniqueId(),cow.getUniqueId())); - var wrong=AnimalIdentity.session(neighbour).orElseThrow();when(characters.currentSession(player)).thenReturn(Optional.of(wrong));assertTrue(AnimalIdentity.session(player).isEmpty()); - assertThrows(IllegalStateException.class,()->OwnershipManager.registerOwnership(player.getUniqueId(),animal)); - when(characters.currentSession(player)).thenReturn(Optional.empty());var token=mmoItem(CustomItems.coownershipItem);ItemUtils.storeDataInItem(token,cow.getUniqueId().toString(),plugin); - new TamingListener(plugin).onRightClick(new PlayerInteractEvent(player,org.bukkit.event.block.Action.RIGHT_CLICK_AIR,token,null,org.bukkit.block.BlockFace.SELF,EquipmentSlot.HAND));assertEquals(1,token.getAmount()); - when(characters.currentSession(player)).thenReturn(Optional.of(current));when(characters.isCurrent(current)).thenReturn(true); - JSONObject row=PluginData.ownership().rows.get(cow.getUniqueId());JSONArray held=(JSONArray)row.get("heldOwners");JSONObject slot=new JSONObject();slot.put("account",UUID.randomUUID().toString());slot.put("reason","missing heir");held.add(slot); - assertTrue(OwnershipManager.isOwner(player.getUniqueId(),cow.getUniqueId()));assertTrue(OwnershipManager.hasHeldSlots(cow.getUniqueId())); - assertThrows(IllegalStateException.class,()->OwnershipManager.registerOwnership(neighbour.getUniqueId(),animal)); - OwnershipManager.removeOwnership(player.getUniqueId(),animal);assertTrue(OwnershipManager.hasOwner(cow.getUniqueId()));assertFalse(OwnershipManager.isOwner(player.getUniqueId(),cow.getUniqueId())); - assertTrue(AnimalIdentity.session((Player)null).isEmpty());assertFalse(AnimalIdentity.current(null,player));assertFalse(AnimalIdentity.current(current,null)); - ClassLoader absent=new ClassLoader(AnimalIdentity.class.getClassLoader()) { @Override protected Class loadClass(String name,boolean resolve)throws ClassNotFoundException { if(name.startsWith("net.tfminecraft.tlibs.character.estate."))throw new ClassNotFoundException(name);return super.loadClass(name,resolve); } }; - assertFalse(AnimalIdentity.supports(absent,true));assertTrue(AnimalIdentity.supports(absent,false)); - ClassLoader broken=new ClassLoader(AnimalIdentity.class.getClassLoader()) { @Override protected Class loadClass(String name,boolean resolve)throws ClassNotFoundException { if(name.equals("net.tfminecraft.tlibs.character.Characters"))throw new LinkageError("old");return super.loadClass(name,resolve); } }; - assertFalse(AnimalIdentity.supports(broken,false)); - } - @Test void retainedCompatibilitySavesPreserveCharacterAuthorityAndUnloadableRawRows() throws Exception { - var player=player();var current=select(player,UUID.randomUUID(),2);Cow cow=spawn(Cow.class);FarmAnimal animal=own(player,cow); - PluginData.savePlayerData(player.getUniqueId(),Set.of(animal));PluginData.saveAllPlayerData();PluginData.saveUnownedAnimals(); - PluginData.loadPlayerData(player.getUniqueId(),new HashMap<>());PluginData.loadAllPlayerData();PluginData.loadUnownedAnimals(); - assertTrue(OwnershipManager.isOwner(player.getUniqueId(),cow.getUniqueId())); - PluginData.ownership().rows.get(cow.getUniqueId()).put("state","FUTURE_STATE"); - java.nio.file.Files.writeString(playerFile(current.ref().characterId()),PluginData.ownership().documents.get("CharacterData/"+current.ref().characterId()+".json").toJSONString()); - PluginData.loadAllData(); - assertTrue(PluginData.isHeld(cow.getUniqueId()));assertNull(OwnershipManager.getAnimal(cow.getUniqueId())); - PluginData.saveAllData(true);PluginData.loadAllData();assertEquals("FUTURE_STATE",PluginData.ownership().rows.get(cow.getUniqueId()).get("state")); - assertTrue(OwnershipManager.hasOwner(cow.getUniqueId())); - } - @Test void oldHeirApiHoldsAtBootAndSessionStartEjectsUnauthorizedRidersOnly() throws Exception { - var player=player();Horse horse=spawn(Horse.class);FarmAnimal animal=own(player,horse);var original=AnimalIdentity.session(player).orElseThrow(); - horse.addPassenger(player);server.getPluginManager().callEvent(new CharacterSessionStartEvent(player,original));assertEquals(horse,player.getVehicle()); - var other=select(player,UUID.randomUUID(),3);server.getPluginManager().callEvent(new CharacterSessionStartEvent(player,other));assertNull(player.getVehicle()); - server.getPluginManager().callEvent(new CharacterSessionStartEvent(player,other)); - try(var probe=mockStatic(AnimalIdentity.class,CALLS_REAL_METHODS)) { - probe.when(()->AnimalIdentity.supports(any(ClassLoader.class),anyBoolean())).thenReturn(false); - var store=new AnimalOwnershipStore(DATA_DIR.resolve("old-provider"));AnimalIdentity.open(store);assertFalse(store.ready);assertNotNull(store.blocked()); - } - } - - @Test void deferredMountBreedingCannotChangeNewCharactersAnimalAfterSwitch() { - var player=player();var a=select(player,UUID.randomUUID(),2);Horse mother=spawn(Horse.class),father=spawn(Horse.class),child=spawn(Horse.class); - own(player,mother);own(player,father);child.getAttribute(org.bukkit.attribute.Attribute.MAX_HEALTH).setBaseValue(7);child.setTamed(false); - new BreedingListener().onEntityBreed(new EntityBreedEvent(child,mother,father,player,new ItemStack(Material.WHEAT),0)); - select(player,UUID.randomUUID(),3);server.getScheduler().performOneTick();assertFalse(child.isTamed());assertEquals(7,child.getAttribute(org.bukkit.attribute.Attribute.MAX_HEALTH).getBaseValue()); - when(characters.currentSession(player)).thenReturn(Optional.empty());var denied=new EntityBreedEvent(child,mother,father,player,new ItemStack(Material.WHEAT),0);new BreedingListener().onEntityBreed(denied);assertTrue(denied.isCancelled()); - } -} diff --git a/src/test/java/net/tfminecraft/breedingbuddies/AnimalEstatesTest.java b/src/test/java/net/tfminecraft/breedingbuddies/AnimalEstatesTest.java deleted file mode 100644 index e5243d8..0000000 --- a/src/test/java/net/tfminecraft/breedingbuddies/AnimalEstatesTest.java +++ /dev/null @@ -1,179 +0,0 @@ -package net.tfminecraft.breedingbuddies; - -import net.tfminecraft.tlibs.character.*; -import net.tfminecraft.tlibs.character.estate.*; -import org.bukkit.plugin.ServicePriority; -import org.json.simple.*; -import org.junit.jupiter.api.Test; -import org.junit.jupiter.api.io.TempDir; -import java.io.IOException; -import java.nio.file.*; -import java.time.Instant; -import java.util.*; -import static org.junit.jupiter.api.Assertions.*; -import static org.mockito.Mockito.*; - -class AnimalEstatesTest extends PluginTestBase { - @TempDir Path directory; - final UUID account=UUID.randomUUID(), other=UUID.randomUUID(), death=UUID.randomUUID(); - final CharacterRef from=new CharacterRef(account,UUID.randomUUID()), to=new CharacterRef(other,UUID.randomUUID()); - final CharacterRef coowner=new CharacterRef(UUID.randomUUID(),UUID.randomUUID()); - EstateService service; - EstateDecision decision(long revision,Recipient recipient) { return new EstateDecision(death,from,Optional.of(Instant.EPOCH),Instant.EPOCH,Optional.of(to),Optional.empty(),Optional.empty(),Recipient.FROZEN,recipient,Recipient.FROZEN,Recipient.SENDER,revision); } - void activate(EstateDecision decision) { - if(service==null) { service=mock(EstateService.class);server.getServicesManager().register(EstateService.class,service,plugin,ServicePriority.Normal); } - when(service.decision(death)).thenReturn(Optional.of(decision));when(service.stateOf(from.characterId())).thenReturn(Optional.of(EstateState.DECIDED)); - when(characters.deathId(from.characterId())).thenReturn(Optional.of(death)); - when(characters.info(from.characterId())).thenReturn(Optional.of(new CharacterInfo(from,"A",CharacterStatus.DEAD,null,Set.of(),0,null))); - } - AnimalOwnershipStore setup(UUID... animals) throws Exception { - JSONObject[] rows=Arrays.stream(animals).map(id->AnimalOwnershipStoreTest.row(id,account,other,coowner.ownerId())).toArray(JSONObject[]::new); - AnimalFiles.write(directory.resolve("PlayerData/"+account+".json"),AnimalOwnershipStoreTest.doc(rows).toJSONString()); - var store=new AnimalOwnershipStore(directory);store.guard=AnimalEstates::guard;store.open(true,k->Optional.of(k.equals(account)?from:k.equals(other)?to:coowner)); - setStatic(PluginData.class,"ownership",store);PluginData.rebuildAnimals();return store; - } - @Test void coownerMergeKeepsOneAnimalPhysiologyUnknownFieldsAndCumulativeFrozenWaitingReceipts() throws Exception { - UUID id=UUID.randomUUID();var store=setup(id);var decision=decision(1,Recipient.character(to));activate(decision);var estate=new AnimalEstates(); - JSONObject before=AnimalFiles.copy(store.rows.get(id));Outcome.Done done=(Outcome.Done)estate.settle(decision); - assertEquals(List.of(id.toString()),done.movedIds());assertEquals(Set.of(to,coowner),AnimalOwnershipStore.owners(store.rows.get(id))); - JSONObject after=AnimalFiles.copy(store.rows.get(id));after.put("characterOwners",before.get("characterOwners"));assertEquals(before,after); - assertEquals(1,store.rows.size());assertEquals(1,((JSONArray)AnimalFiles.object(Files.readString(directory.resolve("CharacterData/"+to.characterId()+".json"))).get("animals")).size()); - assertEquals(0,((JSONArray)AnimalFiles.object(Files.readString(directory.resolve("CharacterData/"+from.characterId()+".json"))).get("animals")).size()); - assertEquals(done,estate.settle(decision));assertEquals(1,estate.describe(to.characterId()).counts().get("animals")); - for(var kind:List.of(Recipient.WAITING,Recipient.FROZEN)) {var next=decision(2,kind);activate(next);assertEquals(done,estate.settle(next));} - var same=decision(3,Recipient.character(from));activate(same);assertEquals(done,estate.settle(same)); - assertEquals("breedingbuddies-animals",estate.id()); - } - @Test void eachEstateFanoutCutRecoversSameDecisionOrHoldsChangedDecisionWithoutOverwriting() throws Exception { - for(int cut=1;cut<=3;cut++) { - UUID id=UUID.randomUUID();Path folder=directory.resolve("cut"+cut);Files.createDirectories(folder); - var store=new AnimalOwnershipStore(folder);JSONObject source=AnimalOwnershipStoreTest.row(id,account,coowner.ownerId()); - AnimalFiles.write(folder.resolve("PlayerData/"+account+".json"),AnimalOwnershipStoreTest.doc(source).toJSONString()); - store.guard=AnimalEstates::guard;store.open(true,k->Optional.of(k.equals(account)?from:coowner));setStatic(PluginData.class,"ownership",store);PluginData.rebuildAnimals(); - var decision=decision(1,Recipient.character(to));activate(decision);final int selected=cut;int[] count={0};boolean[] estateWriting={false}; - try(var writes=mockStatic(AnimalFiles.class,CALLS_REAL_METHODS)) { - writes.when(()->AnimalFiles.write(any(Path.class),anyString())).thenAnswer(call->{call.callRealMethod();Path file=call.getArgument(0);if(file.toString().contains("ownership/journals")&&((String)call.getArgument(1)).contains("\"kind\":\"estate\""))estateWriting[0]=true;if(estateWriting[0]&&!file.toString().contains("ownership/"))if(++count[0]==selected)throw new IOException("crash after estate file");return null;}); - assertEquals(Outcome.Kind.RETRY,new AnimalEstates().settle(decision).kind()); - } - var pending=AnimalFiles.object(Files.readString(store.files("ownership/journals").getLast()));assertEquals(false,pending.get("done")); - assertEquals(4,((JSONArray)pending.get("outputs")).size()); - if(cut==2) { - var revised=decision(2,Recipient.FROZEN);activate(revised); - var reboot=new AnimalOwnershipStore(folder);reboot.guard=AnimalEstates::guard; - assertThrows(IOException.class,()->reboot.open(true,k->Optional.of(to)));setStatic(PluginData.class,"ownership",reboot); - assertEquals(Outcome.Kind.BLOCKED,new AnimalEstates().settle(revised).kind());assertFalse(reboot.ready); - activate(decision); // explicitly restore the original synthetic decision for the recovery proof - } - var reboot=new AnimalOwnershipStore(folder);reboot.guard=AnimalEstates::guard;reboot.open(false,k->{fail("must not resolve mapping again");return Optional.empty();}); - setStatic(PluginData.class,"ownership",reboot);PluginData.rebuildAnimals(); - assertEquals(Set.of(to,coowner),AnimalOwnershipStore.owners(reboot.rows.get(id)));assertEquals(1,reboot.rows.size()); - assertEquals(List.of(id.toString()),((Outcome.Done)new AnimalEstates().settle(decision)).movedIds()); - } - } - @Test void realGuardRejectsRevivalRedeathChangedOwnerAndRevisionBeforeMovement() throws Exception { - UUID id=UUID.randomUUID();var store=setup(id);var decision=decision(1,Recipient.character(to));activate(decision);var estate=new AnimalEstates(); - when(characters.info(from.characterId())).thenReturn(Optional.of(new CharacterInfo(from,"A",CharacterStatus.ALIVE,null,Set.of(),0,null)));assertEquals(Outcome.Kind.RETRY,estate.settle(decision).kind()); - activate(decision);when(characters.deathId(from.characterId())).thenReturn(Optional.of(UUID.randomUUID()));assertEquals(Outcome.Kind.RETRY,estate.settle(decision).kind()); - activate(decision);when(characters.info(from.characterId())).thenReturn(Optional.of(new CharacterInfo(to,"Wrong",CharacterStatus.DEAD,null,Set.of(),0,null)));assertEquals(Outcome.Kind.RETRY,estate.settle(decision).kind()); - activate(decision(2,Recipient.FROZEN));assertEquals(Outcome.Kind.RETRY,estate.settle(decision).kind());assertTrue(AnimalOwnershipStore.owners(store.rows.get(id)).contains(from)); - assertFalse(AnimalEstates.guard(new JSONObject())); - } - @Test void hourlyReconcileReportsEachRevisionAndContinuesProviderFailure() throws Exception { - UUID id=UUID.randomUUID();setup(id);var first=decision(1,Recipient.FROZEN);activate(first); - when(service.decided()).thenReturn(List.of(first,first)); - doThrow(new IllegalStateException("report temporarily unavailable")).doNothing().when(service).report(eq(death),eq("breedingbuddies-animals"),eq(1L),any()); - server.getScheduler().performTicks(3);verify(service,times(2)).report(eq(death),eq("breedingbuddies-animals"),eq(1L),any()); - } - @Test void settlementFinishesMatchingPendingPlanBeforeFurtherMovementAndPreservesUnsavedCare() throws Exception { - UUID id=UUID.randomUUID();var store=setup(id);var decision=decision(1,Recipient.character(to));activate(decision); - FarmAnimal live=OwnershipManager.getAnimal(id);live.setFed(true);live.setFriendshipPoints(1300); - boolean[] estateWriting={false}; - try(var writes=mockStatic(AnimalFiles.class,CALLS_REAL_METHODS)) { - writes.when(()->AnimalFiles.write(any(Path.class),anyString())).thenAnswer(call->{Path file=call.getArgument(0);if(file.toString().contains("ownership/journals")&&((String)call.getArgument(1)).contains("\"kind\":\"estate\""))estateWriting[0]=true;if(estateWriting[0]&&file.toString().contains("CharacterData"))throw new IOException("disk failed");return call.callRealMethod();}); - assertEquals(Outcome.Kind.RETRY,new AnimalEstates().settle(decision).kind()); - } - assertEquals(Outcome.Kind.DONE,new AnimalEstates().settle(decision).kind());assertTrue(store.ready); - assertEquals(true,store.rows.get(id).get("fed"));assertEquals(1300L,store.rows.get(id).get("friendshipPoints")); - var plan=AnimalFiles.object(Files.readString(store.files("ownership/journals").getLast()));assertEquals(true,plan.get("done")); - } - - @Test void inheritanceKeepsUnrelatedBufferedPhysiologyAndRejectsAChangedDecisionAfterFlush() throws Exception { - UUID id=UUID.randomUUID(),unrelated=UUID.randomUUID();var store=setup(id);var decision=decision(1,Recipient.character(to));activate(decision); - JSONObject extra=AnimalOwnershipStoreTest.row(unrelated);JSONArray slots=new JSONArray();slots.add(AnimalOwnershipStore.ref(to));extra.put("characterOwners",slots);extra.put("heldOwners",new JSONArray());store.rows.put(unrelated,extra);store.commit(store.fanout(),new JSONObject());store.load();PluginData.rebuildAnimals(); - FarmAnimal recipientAnimal=OwnershipManager.getAnimal(unrelated);recipientAnimal.setFed(true);recipientAnimal.setFriendshipPoints(1750); - assertEquals(Outcome.Kind.DONE,new AnimalEstates().settle(decision).kind());assertEquals(true,store.rows.get(unrelated).get("fed"));assertEquals(1750L,store.rows.get(unrelated).get("friendshipPoints")); - assertEquals(2,store.rows.size());assertEquals(Set.of(to,coowner),AnimalOwnershipStore.owners(store.rows.get(id))); - UUID late=UUID.randomUUID();JSONObject row=AnimalOwnershipStoreTest.row(late);JSONArray source=new JSONArray();source.add(AnimalOwnershipStore.ref(from));row.put("characterOwners",source);row.put("heldOwners",new JSONArray());store.rows.put(late,row);store.commit(store.fanout(),new JSONObject());store.load();PluginData.rebuildAnimals(); - OwnershipManager.getAnimal(late).setFed(true); - var next=decision(2,Recipient.character(to));activate(next);boolean[] flushed={false}; - try(var writes=mockStatic(AnimalFiles.class,CALLS_REAL_METHODS)) { - writes.when(()->AnimalFiles.write(any(Path.class),anyString())).thenAnswer(call->{call.callRealMethod();String text=call.getArgument(1);if(text.contains("\"kind\":\"gameplay\"")&&text.contains("\"done\":true")){activate(decision(3,Recipient.FROZEN));flushed[0]=true;}return null;}); - assertEquals(Outcome.Kind.RETRY,new AnimalEstates().settle(next).kind()); - } - assertTrue(flushed[0]);assertTrue(AnimalOwnershipStore.owners(store.rows.get(late)).contains(from)); - } - - @Test void historicalHeldTokenEvidenceSurvivesReviewedCompletionCareAndRestart() throws Exception { - UUID inherited=UUID.randomUUID(),heldAnimal=UUID.randomUUID();var store=setup(inherited); - JSONObject extra=AnimalOwnershipStoreTest.row(heldAnimal);JSONArray owners=new JSONArray();owners.add(AnimalOwnershipStore.ref(to));extra.put("characterOwners",owners);extra.put("heldOwners",new JSONArray()); - store.rows.put(heldAnimal,extra);store.commit(store.fanout(),new JSONObject());store.load();PluginData.rebuildAnimals(); - var player=player();FarmAnimal live=OwnershipManager.getAnimal(heldAnimal);var token=AnimalTokenIntent.prepare(player,player,live,mmoItem("TAMING_ITEM"));token.hold(live); - var first=decision(1,Recipient.character(to));activate(first);var estate=new AnimalEstates();Outcome.Done receipt=(Outcome.Done)estate.settle(first);assertEquals(List.of(inherited.toString()),receipt.movedIds()); - Path estatePath=store.files("ownership/journals").getLast();JSONObject frozen=AnimalFiles.object(Files.readString(estatePath));assertEquals(1,((JSONObject)frozen.get("tokenHolds")).size()); - Path effectPath=store.files("ownership/token-effects").getFirst();JSONObject effect=AnimalFiles.object(Files.readString(effectPath));effect.put("status","done");effect.put("reviewedResolution","synthetic staff reconciliation");AnimalFiles.write(effectPath,effect.toJSONString()); - store.load();PluginData.rebuildAnimals();live=OwnershipManager.getAnimal(heldAnimal);assertNotNull(live);live.setFed(true);live.setFriendshipPoints(1875);PluginData.saveAnimals(); - assertEquals(receipt,estate.settle(first));var waiting=decision(2,Recipient.WAITING);activate(waiting);assertEquals(receipt,estate.settle(waiting)); - var reboot=new AnimalOwnershipStore(directory);reboot.guard=AnimalEstates::guard;reboot.open(false,k->{fail("must not resolve reviewed mapping again");return Optional.empty();});setStatic(PluginData.class,"ownership",reboot);PluginData.rebuildAnimals(); - assertEquals(receipt,estate.settle(waiting));assertEquals(true,reboot.rows.get(heldAnimal).get("fed"));assertEquals(1875L,reboot.rows.get(heldAnimal).get("friendshipPoints"));assertEquals(Set.of(to,coowner),AnimalOwnershipStore.owners(reboot.rows.get(inherited))); - JSONObject altered=AnimalFiles.object(Files.readString(estatePath));JSONObject snapshot=(JSONObject)((JSONObject)altered.get("tokenHolds")).values().iterator().next();snapshot.put("actor",AnimalOwnershipStore.ref(from));AnimalFiles.write(estatePath,altered.toJSONString()); - var unchanged=new LinkedHashMap(reboot.expected);assertEquals(Outcome.Kind.BLOCKED,estate.settle(waiting).kind());assertEquals(unchanged,reboot.expected);assertNotNull(reboot.blocked()); - } - @Test void restoredAlreadyInheritedSourceIsBlockedForSameAndNewRevision() throws Exception { - UUID id=UUID.randomUUID();var store=setup(id);JSONObject original=AnimalFiles.copy(store.rows.get(id));var first=decision(1,Recipient.character(to));activate(first); - assertEquals(Outcome.Kind.DONE,new AnimalEstates().settle(first).kind()); - store.rows.put(id,original);store.commit(store.fanout(),new JSONObject());store.load();PluginData.rebuildAnimals(); - var bytes=new LinkedHashMap(store.expected); - for(var next:List.of(first,decision(2,Recipient.character(to)),decision(3,Recipient.FROZEN))) { - activate(next);assertEquals(Outcome.Kind.BLOCKED,new AnimalEstates().settle(next).kind());assertEquals(bytes,store.expected);assertTrue(AnimalOwnershipStore.owners(store.rows.get(id)).contains(from)); - } - } - @Test void runtimeFailureBeforeEstatePublicationRestoresOriginalRowsAndDoesNotClaimMovement() throws Exception { - UUID id=UUID.randomUUID();var store=setup(id);var d=decision(1,Recipient.character(to));activate(d); - PluginData.saveAnimals(); - var before=new LinkedHashMap<>(store.rows);var documents=new LinkedHashMap<>(store.expected); - try(var copies=mockStatic(AnimalFiles.class,CALLS_REAL_METHODS)) { - copies.when(()->AnimalFiles.copy(any(JSONObject.class))).thenAnswer(call->{ - JSONObject value=call.getArgument(0);if("estate".equals(value.get("kind")))throw new IllegalStateException("estate metadata copy failed"); - return call.callRealMethod(); - }); - assertEquals(Outcome.Kind.BLOCKED,new AnimalEstates().settle(d).kind()); - } - assertEquals(before,store.rows);assertEquals(documents,store.expected);assertFalse(store.ready); - assertTrue(AnimalOwnershipStore.owners(store.rows.get(id)).contains(from)); - for(var path:store.files("ownership/journals"))assertFalse(AnimalFiles.object(Files.readString(path)).containsKey("estate")); - } - @Test void heldRowsAndRecipientConflictsNeverPartiallyModifyOtherSlots() throws Exception { - UUID first=UUID.randomUUID(),second=UUID.randomUUID();var store=setup(first,second);var d=decision(1,Recipient.character(to));activate(d);var estate=new AnimalEstates(); - store.held.put(second,"Conflict");assertEquals(Outcome.Kind.BLOCKED,estate.settle(d).kind());assertTrue(AnimalOwnershipStore.owners(store.rows.get(first)).contains(from)); - assertTrue(estate.describe(from.characterId()).labels().stream().anyMatch(s->s.contains("held"))); - store.held.clear();JSONArray owners=(JSONArray)store.rows.get(second).get("characterOwners");owners.add(AnimalOwnershipStore.ref(new CharacterRef(UUID.randomUUID(),to.characterId()))); - assertEquals(Outcome.Kind.BLOCKED,estate.settle(d).kind());assertTrue(AnimalOwnershipStore.owners(store.rows.get(first)).contains(from)); - setStatic(PluginData.class,"ownership",null);assertEquals(Holdings.NONE,estate.describe(from.characterId()));assertEquals(Outcome.Kind.RETRY,estate.settle(d).kind()); - } - @Test void careFlushIsPrunedEstateReceiptIsKeptAndInertDoneGameplayIsIgnored() throws Exception { - UUID id=UUID.randomUUID();var store=setup(id);OwnershipManager.getAnimal(id).setFed(true);var d=decision(1,Recipient.character(to));activate(d); - Path[] care={null};String[] done={null};Outcome.Done outcome; - try(var writes=mockStatic(AnimalFiles.class,CALLS_REAL_METHODS)) { - writes.when(()->AnimalFiles.write(any(Path.class),anyString())).thenAnswer(call->{ - String text=call.getArgument(1);if(text.contains("\"kind\":\"gameplay\"")&&text.contains("\"done\":true")){care[0]=call.getArgument(0);done[0]=text;} - return call.callRealMethod(); - }); - outcome=(Outcome.Done)new AnimalEstates().settle(d); - } - assertNotNull(care[0]);assertFalse(Files.exists(care[0]));assertEquals(true,store.rows.get(id).get("fed")); - Path receipt=store.files("ownership/journals").getLast();String kept=Files.readString(receipt);assertEquals("estate",AnimalFiles.object(kept).get("kind")); - Files.writeString(care[0],done[0]);var reboot=new AnimalOwnershipStore(directory);reboot.guard=AnimalEstates::guard;reboot.open(false,k->Optional.empty()); - setStatic(PluginData.class,"ownership",reboot);PluginData.rebuildAnimals();assertTrue(reboot.ready); - assertEquals(outcome,new AnimalEstates().settle(d));assertEquals(kept,Files.readString(receipt));assertEquals(done[0],Files.readString(care[0])); - } -} diff --git a/src/test/java/net/tfminecraft/breedingbuddies/AnimalJournalLifecycleTest.java b/src/test/java/net/tfminecraft/breedingbuddies/AnimalJournalLifecycleTest.java deleted file mode 100644 index 9adffc4..0000000 --- a/src/test/java/net/tfminecraft/breedingbuddies/AnimalJournalLifecycleTest.java +++ /dev/null @@ -1,120 +0,0 @@ -package net.tfminecraft.breedingbuddies; - -import net.tfminecraft.tlibs.character.CharacterRef; -import org.json.simple.*; -import org.junit.jupiter.api.Test; -import org.junit.jupiter.api.io.TempDir; -import java.io.IOException; -import java.nio.file.*; -import java.util.*; -import static org.junit.jupiter.api.Assertions.*; -import static org.mockito.Mockito.*; - -class AnimalJournalLifecycleTest { - @TempDir Path root; - final UUID account = UUID.randomUUID(), id = UUID.randomUUID(); - final CharacterRef owner = new CharacterRef(account, UUID.randomUUID()); - AnimalOwnershipStore open() throws IOException { - var store = new AnimalOwnershipStore(root); - store.open(true, key -> Optional.of(owner)); - return store; - } - AnimalOwnershipStore setup(boolean animal) throws IOException { - AnimalFiles.write(root.resolve("PlayerData/" + account + ".json"), - AnimalOwnershipStoreTest.doc(animal ? new JSONObject[]{AnimalOwnershipStoreTest.row(id, account)} : new JSONObject[0]).toJSONString()); - return open(); - } - Map snapshot() throws IOException { - Map result = new TreeMap<>(); - try (var files = Files.walk(root)) { - for (Path file : files.filter(Files::isRegularFile).toList()) result.put(root.relativize(file).toString(), Base64.getEncoder().encodeToString(Files.readAllBytes(file))); - } - return result; - } - @Test void idleAutosavesWriteNothingAndDoNotReadRetainedJournalHistory() throws Exception { - var store = setup(true); var animal = AnimalCodec.load(store.rows.get(id)); - store.save(List.of(animal)); var before = snapshot(); var expected = new LinkedHashMap<>(store.expected); String last = store.lastJournalId; - try (var reads = mockStatic(AnimalFiles.class, CALLS_REAL_METHODS)) { - String history = Files.readString(store.files("ownership/journals").getFirst()); - reads.when(() -> AnimalFiles.object(history)).thenThrow(new IOException("save must not parse journal history")); - for (int i = 0; i < 2; i++) store.save(List.of(animal)); - } - assertEquals(before, snapshot()); assertEquals(expected, store.expected); assertEquals(last, store.lastJournalId); - assertEquals(store.fanout(), store.documents); assertTrue(store.ready); - assertTrue(snapshot().keySet().stream().noneMatch(name -> name.endsWith(".next"))); - } - @Test void omittedEmptyOwnerOutputCannotBeMistakenForANoop() throws Exception { - var store = setup(false); var before = snapshot(); var expected = new LinkedHashMap<>(store.expected); - var outputs = store.fanout(); outputs.remove("CharacterData/" + owner.characterId() + ".json"); - assertThrows(IOException.class, () -> store.commit(outputs, new JSONObject())); - assertEquals(before, snapshot()); assertEquals(expected, store.expected); - } - @Test void changedSavesPruneOnlyTheirOwnDoneGameplayJournalAndRetainTokenHistory() throws Exception { - var store = setup(true); var animal = AnimalCodec.load(store.rows.get(id)); store.save(List.of(animal)); - store.tokenEffectId = UUID.randomUUID().toString(); store.save(List.of(animal)); store.tokenEffectId = null; - var retained = snapshot(); var history = store.files("ownership/journals"); - assertEquals(2, history.size()); - animal.setFed(true); store.save(List.of(animal)); - assertEquals(history, store.files("ownership/journals")); assertFalse(Files.exists(store.path("ownership/journals/" + store.lastJournalId + ".json"))); - for (String name : retained.keySet()) if (name.startsWith("ownership/")) assertEquals(retained.get(name), snapshot().get(name), name); - assertEquals(true, store.rows.get(id).get("fed")); assertEquals(true, open().rows.get(id).get("fed")); - } - @Test void interruptedGameplayPublicationReplaysAndPrunesOnlyAfterDurableCompletion() throws Exception { - var store = setup(true); var animal = AnimalCodec.load(store.rows.get(id)); animal.setCared(true); - try (var writes = mockStatic(AnimalFiles.class, CALLS_REAL_METHODS)) { - writes.when(() -> AnimalFiles.write(any(Path.class), anyString())).thenAnswer(call -> { - if (((Path) call.getArgument(0)).toString().contains("CharacterData")) throw new IOException("publication interrupted"); - return call.callRealMethod(); - }); - assertThrows(IOException.class, () -> store.save(List.of(animal))); assertFalse(store.ready); - } - var pending = store.files("ownership/journals").getLast(); var plan = AnimalFiles.object(Files.readString(pending)); assertEquals(false, plan.get("done")); - var before = snapshot(); assertThrows(IOException.class, () -> store.save(List.of(animal))); assertEquals(before, snapshot()); - var reboot = open(); assertTrue(reboot.ready); assertFalse(Files.exists(pending)); assertEquals(true, reboot.rows.get(id).get("cared")); - for (Object value : (JSONArray) plan.get("outputs")) { var output = (JSONObject) value; assertEquals(output.get("after"), Files.readString(root.resolve((String) output.get("path")))); } - } - @Test void leftoverDoneGameplayIsInertOnOpenAndIsNeverSweptByLaterSaves() throws Exception { - var store = setup(true); var animal = AnimalCodec.load(store.rows.get(id)); - String[] done = {null}; Path[] path = {null}; - try (var writes = mockStatic(AnimalFiles.class, CALLS_REAL_METHODS)) { - writes.when(() -> AnimalFiles.write(any(Path.class), anyString())).thenAnswer(call -> { - String text = call.getArgument(1); - if (text.contains("\"kind\":\"gameplay\"") && text.contains("\"done\":true")) { done[0] = text; path[0] = call.getArgument(0); } - return call.callRealMethod(); - }); - animal.setFed(true); store.save(List.of(animal)); - } - Files.writeString(path[0], done[0]); var before = snapshot(); var reboot = open(); assertTrue(reboot.ready); assertEquals(before, snapshot()); - animal.setCared(true); reboot.save(List.of(animal)); assertEquals(done[0], Files.readString(path[0])); - assertEquals(2, reboot.files("ownership/journals").size()); - } - @Test void noopRefusesReceiptPathConflictsWithoutTouchingAnyFile() throws Exception { - var store = setup(true); var animal = AnimalCodec.load(store.rows.get(id)); store.save(List.of(animal)); - JSONObject effect = new JSONObject(); effect.put("id", UUID.randomUUID().toString()); effect.put("animal", id.toString()); effect.put("status", "prepared"); - AnimalFiles.write(root.resolve("ownership/token-effects/" + UUID.randomUUID() + ".json"), effect.toJSONString()); store.held.put(id, "Unfinished token effect"); - var before = snapshot(); assertThrows(IOException.class, () -> store.save(List.of(animal))); assertEquals(before, snapshot()); - } - @Test void pruningUnlinkAndForceFailuresCannotInvalidateCompletedPublication() throws Exception { - var store = spy(setup(true)); var animal = AnimalCodec.load(store.rows.get(id)); - doThrow(new IOException("unlink unavailable")).when(store).prune(any(Path.class)); - animal.setFed(true); assertDoesNotThrow(() -> store.save(List.of(animal))); assertTrue(store.ready); - Path inert = store.path("ownership/journals/" + store.lastJournalId + ".json"); - assertEquals(true, AnimalFiles.object(Files.readString(inert)).get("done")); String original = Files.readString(inert); - doAnswer(call -> { Files.deleteIfExists(call.getArgument(0)); throw new IOException("directory force unavailable"); }).when(store).prune(any(Path.class)); - animal.setCared(true); assertDoesNotThrow(() -> store.save(List.of(animal))); assertTrue(store.ready); - assertFalse(Files.exists(store.path("ownership/journals/" + store.lastJournalId + ".json"))); assertEquals(original, Files.readString(inert)); - var reboot = open(); assertTrue(reboot.ready); assertEquals(true, reboot.rows.get(id).get("fed")); assertEquals(true, reboot.rows.get(id).get("cared")); - } - @Test void firstAcquisitionCreatesNewOwnerAndLaterFanoutRetainsEmptiedOwners() throws Exception { - var store = setup(false); var next = new CharacterRef(account, UUID.randomUUID()); - JSONObject row = AnimalOwnershipStoreTest.row(id); JSONArray slots = new JSONArray(); slots.add(AnimalOwnershipStore.ref(next)); - row.put("characterOwners", slots); row.put("heldOwners", new JSONArray()); store.rows.put(id, row); - store.commit(store.fanout(), new JSONObject()); store.load(); - assertEquals(Set.of(next), AnimalOwnershipStore.owners(store.rows.get(id))); assertTrue(store.ready); - slots = new JSONArray(); slots.add(AnimalOwnershipStore.ref(owner)); store.rows.get(id).put("characterOwners", slots); - store.commit(store.fanout(), new JSONObject()); store.load(); - String previous = "CharacterData/" + next.characterId() + ".json"; - assertTrue(store.expected.containsKey(previous)); assertEquals(0, ((JSONArray) AnimalFiles.object(Files.readString(root.resolve(previous))).get("animals")).size()); - assertEquals(Set.of(owner), AnimalOwnershipStore.owners(open().rows.get(id))); assertEquals(1, store.files("ownership/journals").size()); - } -} diff --git a/src/test/java/net/tfminecraft/breedingbuddies/AnimalOwnershipStoreTest.java b/src/test/java/net/tfminecraft/breedingbuddies/AnimalOwnershipStoreTest.java deleted file mode 100644 index 96577ce..0000000 --- a/src/test/java/net/tfminecraft/breedingbuddies/AnimalOwnershipStoreTest.java +++ /dev/null @@ -1,166 +0,0 @@ -package net.tfminecraft.breedingbuddies; - -import net.tfminecraft.tlibs.character.CharacterRef; -import org.json.simple.*; -import org.junit.jupiter.api.Test; -import org.junit.jupiter.api.io.TempDir; -import java.io.IOException; -import java.nio.file.*; -import java.util.*; -import static org.junit.jupiter.api.Assertions.*; -import static org.mockito.Mockito.*; - -class AnimalOwnershipStoreTest { - @TempDir Path root; - final UUID account = UUID.randomUUID(), other = UUID.randomUUID(), id = UUID.randomUUID(); - final CharacterRef a = new CharacterRef(account, UUID.randomUUID()), b = new CharacterRef(other, UUID.randomUUID()); - static JSONObject row(UUID id, UUID... owners) { - FarmAnimal animal = new FarmAnimal(id,"Daisy",new HashSet<>()); - animal.setState(AnimalStates.HAPPY); animal.setGeneticPoints(341); animal.setFriendshipPoints(999); - animal.setLastUpdated(new Date(1700000000000L)); animal.setLastRewardCollected(new Date(1700000000100L)); - JSONObject row = AnimalCodec.patch(null,animal); row.remove("characterOwners"); row.remove("heldOwners"); - JSONArray slots = new JSONArray(); for(UUID owner:owners) slots.add(owner.toString()); row.put("ownersUuids",slots); - JSONObject future = new JSONObject(); future.put("nested",null); row.put("future",future); return row; - } - static JSONObject doc(JSONObject... rows) { JSONObject doc=new JSONObject(); JSONArray animals=new JSONArray(); animals.addAll(List.of(rows)); doc.put("animals",animals); doc.put("future-root",null); return doc; } - void legacy(UUID account,JSONObject... rows) throws IOException { AnimalFiles.write(root.resolve("PlayerData/"+account+".json"),doc(rows).toJSONString()); } - AnimalOwnershipStore open() throws IOException { var store=new AnimalOwnershipStore(root); store.open(true,k->k.equals(account)?Optional.of(a):k.equals(other)?Optional.of(b):Optional.empty()); return store; } - @Test void fullInventoryAgreesBeforePublicationAndRawFieldsSurviveRoutineSaveRestart() throws Exception { - JSONObject source=row(id,account,other); legacy(account,source); legacy(other,source); - var store=open(); assertTrue(store.ready); assertEquals(Set.of(a,b),AnimalOwnershipStore.owners(store.rows.get(id))); - assertTrue(AnimalFiles.object(Files.readString(root.resolve("CharacterData/"+a.characterId()+".json"))).containsKey("future-root")); - FarmAnimal animal=AnimalCodec.load(store.rows.get(id)); animal.setFed(true); animal.increaseFriendship(1); store.save(List.of(animal)); - var reboot=open(); assertEquals(1000L,reboot.rows.get(id).get("friendshipPoints")); assertEquals(true,reboot.rows.get(id).get("fed")); - assertTrue(((JSONObject)reboot.rows.get(id).get("future")).containsKey("nested")); assertNull(((JSONObject)reboot.rows.get(id).get("future")).get("nested")); - assertEquals(source,((JSONArray)AnimalFiles.object(Files.readString(root.resolve("PlayerData/"+account+".json"))).get("animals")).getFirst()); - assertEquals(1,reboot.rows.size()); - reboot.forget(id); reboot.save(List.of()); assertTrue(open().rows.isEmpty()); - assertEquals(0,((JSONArray)AnimalFiles.object(Files.readString(root.resolve("CharacterData/"+a.characterId()+".json"))).get("animals")).size()); - } - @Test void filenameAuthorityAndUnownedCopyDisagreementsAreHeldRatherThanUnioned() throws Exception { - JSONObject first=row(id,account); legacy(account,first); legacy(other,first); - var store=open(); assertEquals("Conflicting legacy copies",store.held.get(id)); assertTrue(store.rows.isEmpty()); assertTrue(store.hasOwner(id)); - assertEquals(2,((JSONObject)AnimalFiles.object(Files.readString(root.resolve("ownership/source.json")))).size()); - Path second=root.resolve("second"); AnimalFiles.write(second.resolve("PlayerData/"+account+".json"),doc(first).toJSONString()); - JSONObject changed=AnimalFiles.copy(first); ((JSONObject)changed.get("future")).put("nested","different"); - AnimalFiles.write(second.resolve("unownedanimals.json"),doc(changed).toJSONString()); - var mismatch=new AnimalOwnershipStore(second); mismatch.open(true,k->Optional.of(a)); assertTrue(mismatch.held.containsKey(id)); - Path third=root.resolve("third"); AnimalFiles.write(third.resolve("PlayerData/"+account+".json"),doc(first).toJSONString()); - AnimalFiles.write(third.resolve("unownedanimals.json"),doc(first).toJSONString()); - var agreed=new AnimalOwnershipStore(third); agreed.open(true,k->Optional.of(a)); assertEquals(Set.of(a),AnimalOwnershipStore.owners(agreed.rows.get(id))); - } - @Test void reviewedMappingIsFixedWithMissingAndWrongAccountHeirsHeld() throws Exception { - legacy(account,row(id,account,other)); var store=new AnimalOwnershipStore(root); - store.open(false,k->{fail("must not resolve before readiness");return Optional.empty();}); assertFalse(store.ready); assertNotNull(store.blocked()); - store.open(true,k->k.equals(account)?Optional.of(a):Optional.of(new CharacterRef(account,b.characterId()))); - assertEquals(Set.of(a),AnimalOwnershipStore.owners(store.rows.get(id))); assertEquals(1,((JSONArray)store.rows.get(id).get("heldOwners")).size()); - store.open(true,k->{fail("fixed published recipient must not be resolved again");return Optional.empty();}); - assertEquals(Set.of(a),AnimalOwnershipStore.owners(store.rows.get(id))); - UUID stray=UUID.randomUUID(); Path blank=root.resolve("blank"); AnimalFiles.write(blank.resolve("unownedanimals.json"),doc(row(stray)).toJSONString()); - var unowned=new AnimalOwnershipStore(blank); unowned.open(true,k->Optional.empty()); assertFalse(unowned.hasOwner(stray)); - } - @Test void reviewedHeirsStayFixedWhenTheJournalCannotYetBeCreated() throws Exception { - legacy(account,row(id,account,other));var store=new AnimalOwnershipStore(root); - try(var writes=mockStatic(AnimalFiles.class,CALLS_REAL_METHODS)) { - writes.when(()->AnimalFiles.write(any(Path.class),anyString())).thenAnswer(call->{if(((Path)call.getArgument(0)).toString().contains("ownership/journals"))throw new IOException("journal disk unavailable");return call.callRealMethod();}); - assertThrows(IOException.class,()->store.open(true,k->k.equals(account)?Optional.of(a):Optional.empty())); - } - assertTrue(store.files("ownership/journals").isEmpty());assertTrue(Files.exists(root.resolve("ownership/legacy-heirs.json"))); - var reboot=new AnimalOwnershipStore(root);reboot.open(true,k->{fail("fixed reviewed mapping must not be resolved again");return Optional.of(b);}); - assertEquals(Set.of(a),AnimalOwnershipStore.owners(reboot.rows.get(id)));assertEquals(1,((JSONArray)reboot.rows.get(id).get("heldOwners")).size()); - } - @Test void everyMigrationPublicationCutReplaysTheSameOutputsAndSourcePlan() throws Exception { - for(int cut=1;cut<=4;cut++) { - Path caseRoot=root.resolve("cut"+cut); JSONObject source=row(id,account,other); - AnimalFiles.write(caseRoot.resolve("PlayerData/"+account+".json"),doc(source).toJSONString()); - AnimalFiles.write(caseRoot.resolve("PlayerData/"+other+".json"),doc(source).toJSONString()); - var store=new AnimalOwnershipStore(caseRoot); final int selected=cut; int[] output={0}; - try(var writes=mockStatic(AnimalFiles.class,CALLS_REAL_METHODS)) { - writes.when(()->AnimalFiles.write(any(Path.class),anyString())).thenAnswer(call->{ - call.callRealMethod(); Path path=call.getArgument(0); - if (!path.toString().contains("ownership/")||path.endsWith("migration.json")) if(++output[0]==selected) throw new IOException("simulated crash after output"); return null; - }); - assertThrows(IOException.class,()->store.open(true,k->k.equals(account)?Optional.of(a):Optional.of(b))); - } - var reboot=new AnimalOwnershipStore(caseRoot); reboot.open(false,k->{fail("must use fixed journal");return Optional.empty();}); - assertTrue(reboot.ready); assertEquals(Set.of(a,b),AnimalOwnershipStore.owners(reboot.rows.get(id))); assertEquals(1,reboot.rows.size()); - JSONObject journal=AnimalFiles.object(Files.readString(reboot.files("ownership/journals").getFirst())); assertEquals(true,journal.get("done")); - assertEquals(4,((JSONArray)journal.get("outputs")).size()); - } - } - @Test void interruptedPlanAndExternalEditsAreHeldWithoutOverwritingAnyOutput() throws Exception { - legacy(account,row(id,account)); var store=open(); FarmAnimal animal=AnimalCodec.load(store.rows.get(id)); animal.setCared(true); - try(var writes=mockStatic(AnimalFiles.class,CALLS_REAL_METHODS)) { - writes.when(()->AnimalFiles.write(any(Path.class),anyString())).thenAnswer(call->{ Path path=call.getArgument(0); if(path.toString().contains("CharacterData")) throw new IOException("disk failed"); return call.callRealMethod(); }); - assertThrows(IOException.class,()->store.save(List.of(animal))); assertFalse(store.ready); - } - Path file=root.resolve("CharacterData/"+a.characterId()+".json"); Files.writeString(file,"{\"changed\":true}"); - var reboot=new AnimalOwnershipStore(root); assertThrows(IOException.class,()->reboot.open(true,k->Optional.of(a))); assertFalse(reboot.ready); assertNotNull(reboot.blocked()); - assertEquals("{\"changed\":true}",Files.readString(file)); - assertThrows(IOException.class,()->reboot.commit(Map.of(),new JSONObject())); - Files.writeString(root.resolve("PlayerData/"+account+".json"),"{}"); assertThrows(IOException.class,reboot::verifySources); - } - @Test void repeatedSavesPreserveRawEnvelopesAndEveryConflictingQuarantinedCopy() throws Exception { - UUID healthy=UUID.randomUUID();legacy(account,row(id,account,other),row(healthy,account,other));legacy(other,row(id,account,other),row(healthy,account,other)); - var store=open();Path first=root.resolve("CharacterData/"+a.characterId()+".json"),second=root.resolve("CharacterData/"+b.characterId()+".json"); - JSONObject document=AnimalFiles.object(Files.readString(first));((JSONObject)((JSONArray)document.get("animals")).stream().filter(value->((JSONObject)value).get("uuid").equals(id.toString())).findFirst().orElseThrow()).put("state","FUTURE_STATE"); - document.put("future-envelope",null);AnimalFiles.write(first,document.toJSONString()); - store=open();assertTrue(store.held.containsKey(id));FarmAnimal live=AnimalCodec.load(store.rows.get(healthy)); - JSONObject retainedFirst=AnimalFiles.object(Files.readString(first)),retainedSecond=AnimalFiles.object(Files.readString(second)); - for(int pass=0;pass<3;pass++){live.increaseFriendship(1);store.save(List.of(live));} - var reboot=open();assertTrue(reboot.held.containsKey(id));assertEquals(2,reboot.rows.size()); - JSONObject savedFirst=AnimalFiles.object(Files.readString(first)),savedSecond=AnimalFiles.object(Files.readString(second)); - assertTrue(savedFirst.containsKey("future-envelope"));assertNull(savedFirst.get("future-envelope"));assertTrue(savedFirst.containsKey("future-root")); - for(var pair:List.of(List.of(retainedFirst,savedFirst),List.of(retainedSecond,savedSecond))) { - Object old=((JSONArray)pair.getFirst().get("animals")).stream().filter(value->((JSONObject)value).get("uuid").equals(id.toString())).findFirst().orElseThrow(); - Object current=((JSONArray)pair.getLast().get("animals")).stream().filter(value->((JSONObject)value).get("uuid").equals(id.toString())).findFirst().orElseThrow();assertEquals(old,current); - } - } - @Test void anEditAfterEarlierPublicationBlocksTheNextDestinationAndNeverOverwritesIt() throws Exception { - legacy(account,row(id,account,other));legacy(other,row(id,account,other));var store=open();FarmAnimal live=AnimalCodec.load(store.rows.get(id));live.setFed(true); - var outputs=store.fanout();List files=new ArrayList<>(outputs.keySet());Path first=root.resolve(files.getFirst()),next=root.resolve(files.get(1)); - try(var writes=mockStatic(AnimalFiles.class,CALLS_REAL_METHODS)) { - writes.when(()->AnimalFiles.write(any(Path.class),anyString())).thenAnswer(call->{call.callRealMethod();if(first.equals(call.getArgument(0)))Files.writeString(next,"{\"manual\":null}");return null;}); - assertThrows(IOException.class,()->store.save(List.of(live))); - } - assertEquals("{\"manual\":null}",Files.readString(next));assertNotNull(store.blocked());assertFalse(store.ready); - var reboot=new AnimalOwnershipStore(root);assertThrows(IOException.class,()->reboot.open(true,k->Optional.of(a)));assertEquals("{\"manual\":null}",Files.readString(next)); - } - - @Test void unchangedSavesDoNotGrowJournalsButRealChangesAndTokenPublicationRetainEvidence() throws Exception { - legacy(account,row(id,account));var store=open();FarmAnimal animal=AnimalCodec.load(store.rows.get(id)); - store.save(List.of(animal));var receipts=store.files("ownership/journals");String last=store.lastJournalId; - for(int i=0;i<100;i++)store.save(List.of(animal)); - assertEquals(receipts,store.files("ownership/journals"));assertEquals(last,store.lastJournalId); - animal.setFed(true);store.save(List.of(animal));assertEquals(receipts,store.files("ownership/journals")); - assertFalse(Files.exists(store.path("ownership/journals/"+store.lastJournalId+".json"))); - store.tokenEffectId=UUID.randomUUID().toString();store.save(List.of(animal)); - var receipt=AnimalFiles.object(Files.readString(root.resolve("ownership/journals/"+store.lastJournalId+".json"))); - assertEquals(store.tokenEffectId,receipt.get("tokenEffect"));assertEquals(true,receipt.get("done")); - assertEquals(receipts.size()+1,store.files("ownership/journals").size()); - } - @Test void unchangedSaveStillRejectsSourceInventoryCurrentBytesAndUnsupportedHolds() throws Exception { - legacy(account,row(id,account));var store=open();FarmAnimal animal=AnimalCodec.load(store.rows.get(id));store.save(List.of(animal)); - var receipts=store.files("ownership/journals");Path source=root.resolve("PlayerData/"+account+".json");String legacy=Files.readString(source); - Path extra=root.resolve("CharacterData/"+UUID.randomUUID()+".json");Files.writeString(extra,"{}"); - assertThrows(IOException.class,()->store.save(List.of(animal)));Files.delete(extra); - Files.writeString(source,legacy+" ");assertThrows(IOException.class,()->store.save(List.of(animal)));Files.writeString(source,legacy); - Path current=root.resolve("CharacterData/"+a.characterId()+".json");String original=Files.readString(current); - Files.writeString(current,original+" ");assertThrows(IOException.class,()->store.save(List.of(animal)));Files.writeString(current,original); - store.held.put(UUID.randomUUID(),"unproved hold");assertThrows(IOException.class,()->store.save(List.of(animal))); - assertEquals(receipts,store.files("ownership/journals")); - } - @Test void invalidFilesSchemasAndArchiveChangesNeverBecomeAnEmptyOwnerSet() throws Exception { - legacy(account,row(id,account)); var store=open(); - assertThrows(IOException.class,()->store.path("../outside.json")); assertThrows(IOException.class,()->store.path("/outside.json")); - AnimalFiles.write(root.resolve("PlayerData/"+other+".json"),doc().toJSONString()); assertThrows(IOException.class,store::verifySources); - Path bad=root.resolve("bad"); AnimalFiles.write(bad.resolve("PlayerData/"+account+".json"),"{bad"); - assertThrows(IOException.class,()->new AnimalOwnershipStore(bad).open(true,k->Optional.of(a))); - Path missing=root.resolve("missing"); AnimalFiles.write(missing.resolve("PlayerData/"+account+".json"),"{}"); - assertThrows(IOException.class,()->new AnimalOwnershipStore(missing).open(true,k->Optional.of(a))); - var noPlan=new AnimalOwnershipStore(root.resolve("unplanned")); AnimalFiles.write(noPlan.root.resolve("CharacterData/"+a.characterId()+".json"),doc().toJSONString()); - assertThrows(IOException.class,()->noPlan.open(true,k->Optional.of(a))); - assertThrows(IOException.class,()->new AnimalOwnershipStore(root.resolve("notready")).save(List.of())); - store.ready=false; assertThrows(IOException.class,()->store.forget(id)); // deliberately held by explicit readiness below - } -} diff --git a/src/test/java/net/tfminecraft/breedingbuddies/AnimalReplayTest.java b/src/test/java/net/tfminecraft/breedingbuddies/AnimalReplayTest.java deleted file mode 100644 index 0795a27..0000000 --- a/src/test/java/net/tfminecraft/breedingbuddies/AnimalReplayTest.java +++ /dev/null @@ -1,97 +0,0 @@ -package net.tfminecraft.breedingbuddies; - -import net.tfminecraft.tlibs.character.*; -import net.tfminecraft.tlibs.character.estate.*; -import org.bukkit.plugin.ServicePriority; -import org.json.simple.*; -import org.junit.jupiter.api.Test; -import org.junit.jupiter.api.io.TempDir; -import java.io.IOException; -import java.nio.file.*; -import java.time.Instant; -import java.util.*; -import static org.junit.jupiter.api.Assertions.*; -import static org.mockito.Mockito.*; - -class AnimalReplayTest extends PluginTestBase { - @TempDir Path directory; - final UUID account=UUID.randomUUID(),other=UUID.randomUUID(),animal=UUID.randomUUID(),unrelated=UUID.randomUUID(),death=UUID.randomUUID(); - final CharacterRef from=new CharacterRef(account,UUID.randomUUID()),to=new CharacterRef(other,UUID.randomUUID()),third=new CharacterRef(UUID.randomUUID(),UUID.randomUUID()); - EstateDecision decision; - AnimalOwnershipStore pending(Path root) throws Exception { - AnimalFiles.write(root.resolve("PlayerData/"+account+".json"),AnimalOwnershipStoreTest.doc(AnimalOwnershipStoreTest.row(animal,account,other)).toJSONString()); - AnimalFiles.write(root.resolve("PlayerData/"+other+".json"),AnimalOwnershipStoreTest.doc(AnimalOwnershipStoreTest.row(animal,account,other),AnimalOwnershipStoreTest.row(unrelated,other)).toJSONString()); - var store=new AnimalOwnershipStore(root);store.guard=AnimalEstates::guard;store.open(true,k->Optional.of(k.equals(account)?from:to));setStatic(PluginData.class,"ownership",store);PluginData.rebuildAnimals(); - decision=new EstateDecision(death,from,Optional.of(Instant.EPOCH),Instant.EPOCH,Optional.of(to),Optional.empty(),Optional.empty(),Recipient.FROZEN,Recipient.character(to),Recipient.FROZEN,Recipient.SENDER,1); - var service=mock(EstateService.class);server.getServicesManager().register(EstateService.class,service,plugin,ServicePriority.Highest); - when(service.decision(death)).thenReturn(Optional.of(decision));when(service.stateOf(from.characterId())).thenReturn(Optional.of(EstateState.DECIDED)); - when(characters.info(from.characterId())).thenReturn(Optional.of(new CharacterInfo(from,"Source",CharacterStatus.DEAD,null,Set.of(),0,null)));when(characters.deathId(from.characterId())).thenReturn(Optional.of(death)); - boolean[] armed={false}; - try(var writes=mockStatic(AnimalFiles.class,CALLS_REAL_METHODS)) { - writes.when(()->AnimalFiles.write(any(Path.class),anyString())).thenAnswer(call->{Path file=call.getArgument(0);String text=call.getArgument(1);if(file.toString().contains("ownership/journals")&&text.contains("\"kind\":\"estate\""))armed[0]=true;if(armed[0]&&!file.toString().contains("ownership/"))throw new IOException("cut before estate publication");return call.callRealMethod();}); - assertEquals(Outcome.Kind.RETRY,new AnimalEstates().settle(decision).kind()); - } - return store; - } - Map data(AnimalOwnershipStore store) throws IOException {Map data=new HashMap<>();for(Path file:store.files("CharacterData"))data.put(file,Files.readString(file));data.put(store.root.resolve("character-unowned.json"),Files.readString(store.root.resolve("character-unowned.json")));return data;} - @Test void hashConsistentValidJsonTamperingCannotChangeRecipientAnimalRosterPhysiologyOrPath() throws Exception { - for(String fault:List.of("recipient","animal","care","roster","path","version","kind","relabeled","held")) { - var store=pending(directory.resolve(fault));var untouched=data(store);Path path=store.files("ownership/journals").getLast();JSONObject journal=AnimalFiles.object(Files.readString(path));JSONObject canonical=(JSONObject)journal.get("canonical"); - for(Object value:(JSONArray)journal.get("outputs")) { - JSONObject output=(JSONObject)value;if(!("CharacterData/"+to.characterId()+".json").equals(output.get("path")))continue; - JSONObject after=AnimalFiles.object((String)output.get("after")); - if(fault.equals("recipient")) { - for(Object row:(JSONArray)after.get("animals"))if(animal.toString().equals(((JSONObject)row).get("uuid"))) {JSONArray owners=new JSONArray();owners.add(AnimalOwnershipStore.ref(third));((JSONObject)row).put("characterOwners",owners);((JSONObject)canonical.get(animal.toString())).put("characterOwners",owners);} - } else if(fault.equals("animal")) { - JSONObject changed=(JSONObject)canonical.remove(animal.toString());changed.put("uuid",UUID.randomUUID().toString());canonical.put(changed.get("uuid"),changed); - for(Object row:(JSONArray)after.get("animals"))if(animal.toString().equals(((JSONObject)row).get("uuid")))((JSONObject)row).put("uuid",changed.get("uuid")); - } else if(fault.equals("care")) { - ((JSONObject)canonical.get(unrelated.toString())).put("fed",true);for(Object row:(JSONArray)after.get("animals"))if(unrelated.toString().equals(((JSONObject)row).get("uuid")))((JSONObject)row).put("fed",true); - } else if(fault.equals("roster")) {((JSONArray)after.get("animals")).removeIf(item -> unrelated.toString().equals(((JSONObject)item).get("uuid")));} - else if(fault.equals("path")) {output.put("path","CharacterData/"+third.characterId()+".json");output.put("before",null);after.put("owner",AnimalOwnershipStore.ref(third));} - output.put("after",after.toJSONString());output.put("afterSha256",java.util.HexFormat.of().formatHex(java.security.MessageDigest.getInstance("SHA-256").digest(after.toJSONString().getBytes(java.nio.charset.StandardCharsets.UTF_8)))); - } - if(fault.equals("held")){((JSONObject)journal.get("held")).put(animal.toString(),"invented quarantine");canonical.remove(animal.toString());((JSONArray)journal.get("animals")).clear();} - if(fault.equals("relabeled"))journal.put("kind","gameplay"); - if(fault.equals("version"))journal.put("format",2L);if(fault.equals("kind"))journal.put("kind","future-kind");AnimalFiles.write(path,journal.toJSONString()); - assertEquals(Outcome.Kind.BLOCKED,new AnimalEstates().settle(decision).kind(),fault);assertNotNull(store.blocked());assertFalse(store.ready); - assertEquals(untouched,data(store),fault);assertEquals(false,AnimalFiles.object(Files.readString(path)).get("done")); - } - } - @Test void completedReceiptCorruptionIsBlockedBeforeUsingCumulativeIds() throws Exception { - for(String fault:List.of("recipient","ids","care","relabeled","consistent-recipient")) { - var store=pending(directory.resolve("done-"+fault));assertEquals(Outcome.Kind.DONE,new AnimalEstates().settle(decision).kind()); - var untouched=data(store);Path path=store.files("ownership/journals").getLast();JSONObject journal=AnimalFiles.object(Files.readString(path)); - if(fault.equals("recipient"))((JSONObject)journal.get("estate")).put("to",AnimalOwnershipStore.ref(third)); - if(fault.equals("consistent-recipient")) { - ((JSONObject)journal.get("estate")).put("to",AnimalOwnershipStore.ref(third)); - // Even a mutually consistent tampered header/canonical/output must match the captured decision. - JSONObject changed=(JSONObject)((JSONObject)journal.get("canonical")).get(animal.toString());JSONArray slots=new JSONArray();slots.add(AnimalOwnershipStore.ref(third));changed.put("characterOwners",slots); - for(Object item:(JSONArray)journal.get("outputs")) { - JSONObject output=(JSONObject)item;JSONObject after=AnimalFiles.object((String)output.get("after")); - ((JSONArray)after.get("animals")).removeIf(row->animal.toString().equals(((JSONObject)row).get("uuid"))); - if(("CharacterData/"+to.characterId()+".json").equals(output.get("path"))) { - // Existing recipient unrelated rows remain in place; this path is deliberately forbidden. - after.put("owner",AnimalOwnershipStore.ref(third));output.put("path","CharacterData/"+third.characterId()+".json");output.put("before",null);((JSONArray)after.get("animals")).add(changed); - } - output.put("after",after.toJSONString()); - } - } - if(fault.equals("ids"))((JSONArray)journal.get("animals")).add(UUID.randomUUID().toString()); - if(fault.equals("relabeled"))journal.put("kind","gameplay"); - if(fault.equals("care"))((JSONObject)((JSONObject)journal.get("canonical")).get(unrelated.toString())).put("fed",true); - AnimalFiles.write(path,journal.toJSONString()); - assertEquals(Outcome.Kind.BLOCKED,new AnimalEstates().settle(decision).kind(),fault);assertEquals(untouched,data(store));assertNotNull(store.blocked()); - } - } - @Test void duplicateOwnershipAndNestedUnknownKeysHoldOriginalBytes() throws Exception { - for(String fault:List.of("owners","unknown")) { - Path root=directory.resolve(fault),source=root.resolve("PlayerData/"+account+".json");String json=AnimalOwnershipStoreTest.doc(AnimalOwnershipStoreTest.row(animal,account)).toJSONString(); - json=fault.equals("owners")?json.replace("\"ownersUuids\":[","\"ownersUuids\":[],\"ownersUuids\":["):json.replace("\"nested\":null","\"nested\":null,\"nested\":\"different\""); - AnimalFiles.write(source,json);String original=json;var store=new AnimalOwnershipStore(root);assertThrows(IOException.class,()->store.open(true,k->Optional.of(from)));assertFalse(store.ready);assertEquals(original,Files.readString(source));assertFalse(Files.exists(root.resolve("ownership/migration.json"))); - } - var store=pending(directory.resolve("after-duplicate"));var untouched=data(store);Path path=store.files("ownership/journals").getLast();JSONObject journal=AnimalFiles.object(Files.readString(path)); - JSONObject output=(JSONObject)((JSONArray)journal.get("outputs")).stream().filter(value -> ((String)((JSONObject)value).get("after")).contains("\"nested\":null")).findFirst().orElseThrow();output.put("after",((String)output.get("after")).replace("\"nested\":null","\"nested\":null,\"nested\":false"));AnimalFiles.write(path,journal.toJSONString()); - assertEquals(Outcome.Kind.BLOCKED,new AnimalEstates().settle(decision).kind());assertEquals(untouched,data(store)); - } -} diff --git a/src/test/java/net/tfminecraft/breedingbuddies/AnimalTokenIntentTest.java b/src/test/java/net/tfminecraft/breedingbuddies/AnimalTokenIntentTest.java deleted file mode 100644 index 510931b..0000000 --- a/src/test/java/net/tfminecraft/breedingbuddies/AnimalTokenIntentTest.java +++ /dev/null @@ -1,75 +0,0 @@ -package net.tfminecraft.breedingbuddies; - -import net.tfminecraft.breedingbuddies.listeners.TamingListener; -import org.bukkit.entity.Cow; -import org.bukkit.event.player.PlayerInteractEntityEvent; -import org.bukkit.inventory.*; -import org.json.simple.JSONObject; -import org.junit.jupiter.api.Test; -import java.io.IOException; -import java.nio.file.*; -import java.util.*; -import static org.junit.jupiter.api.Assertions.*; -import static org.mockito.Mockito.*; - -class AnimalTokenIntentTest extends PluginTestBase { - ItemStack token(String name){var item=mmoItem("TAMING_ITEM");var meta=item.getItemMeta();meta.setDisplayName(name);item.setItemMeta(meta);ItemUtils.storeDataInItem(item,name,plugin);return item;} - @Test void knownFailureBeforeOwnershipPublicationNeverConsumesTokenOrRenamesEntity() throws Exception { - var player=player();Cow cow=spawn(Cow.class);var token=token("Chosen");player.getInventory().setItemInMainHand(token); - try(var writes=mockStatic(AnimalFiles.class,CALLS_REAL_METHODS)) { - writes.when(()->AnimalFiles.write(any(Path.class),anyString())).thenAnswer(call->{Path file=call.getArgument(0);if(file.toString().contains("ownership/journals"))throw new IOException("known pre-publication failure");return call.callRealMethod();}); - new TamingListener(plugin).onInteract(new PlayerInteractEntityEvent(player,cow,EquipmentSlot.HAND)); - } - assertEquals(1,player.getInventory().getItemInMainHand().getAmount());assertNull(cow.getCustomName());assertFalse(PluginData.ownershipReady()); - var receipt=AnimalFiles.object(Files.readString(PluginData.ownership().files("ownership/token-effects").getFirst()));assertEquals("prepared",receipt.get("status")); - assertEquals(player.getUniqueId().toString(),((JSONObject)receipt.get("recipient")).get("account")); - var before=ItemStack.deserializeBytes(Base64.getDecoder().decode((String)receipt.get("itemBefore")));assertEquals(token,before); - PluginData.loadAllData();assertTrue(PluginData.isHeld(cow.getUniqueId()));assertEquals(1,player.getInventory().getItemInMainHand().getAmount()); - } - @Test void interruptedDebitIsHeldAfterRestartWithoutAutomaticReplayOrRefund() throws Exception { - var player=player();Cow cow=spawn(Cow.class);player.getInventory().setItemInMainHand(token("Chosen")); - try(var writes=mockStatic(AnimalFiles.class,CALLS_REAL_METHODS)) { - writes.when(()->AnimalFiles.write(any(Path.class),anyString())).thenAnswer(call->{Path file=call.getArgument(0);String text=call.getArgument(1);if(file.toString().contains("token-effects")&&text.contains("\"status\":\"done\""))throw new IOException("receipt failure after inventory effect");return call.callRealMethod();}); - new TamingListener(plugin).onInteract(new PlayerInteractEntityEvent(player,cow,EquipmentSlot.HAND)); - } - assertEquals(0,player.getInventory().getItemInMainHand().getAmount());assertEquals("Chosen",cow.getCustomName()); - JSONObject receipt=AnimalFiles.object(Files.readString(PluginData.ownership().files("ownership/token-effects").getFirst()));assertEquals("debit-prepared",receipt.get("status"));assertNotNull(receipt.get("ownerJournal")); - JSONObject after=(JSONObject)receipt.get("animalAfter");assertEquals("Chosen",after.get("name"));assertEquals("HAPPY",after.get("state")); - PluginData.loadAllData();assertTrue(PluginData.isHeld(cow.getUniqueId()));assertFalse(OwnershipManager.isOwner(player.getUniqueId(),cow.getUniqueId()));assertEquals(0,player.getInventory().getItemInMainHand().getAmount()); - } - @Test void compatibilityTameAndNameHelpersRemainCallableWithCheckedCurrentOwnership() { - var player=player();Cow cow=spawn(Cow.class);cow.setCustomName("Before");var listener=new TamingListener(plugin);listener.tame(player,cow); - assertEquals("Before",OwnershipManager.getAnimal(cow.getUniqueId(),player.getUniqueId()).getName()); - var item=token("After");item.setAmount(2);assertTrue(listener.name(cow,item));assertEquals("After",cow.getCustomName());assertEquals(1,item.getAmount()); - when(characters.currentSession(player)).thenReturn(Optional.empty());Cow refused=spawn(Cow.class);listener.tame(player,refused);assertFalse(OwnershipManager.hasOwner(refused.getUniqueId())); - } - - @Test void changedSessionsOrPayloadsHoldThePreparedEffectBeforeAnyDebit() throws Exception { - var player=player();Cow cow=spawn(Cow.class);var animal=own(player,cow);var item=token("Chosen"); - var receipt=AnimalTokenIntent.prepare(player,player,animal,item);item.setAmount(2); - assertThrows(IllegalStateException.class,()->receipt.finish(player,player,animal,item,()->fail("effect must not run"))); - assertEquals(2,item.getAmount());assertTrue(PluginData.isHeld(cow.getUniqueId())); - PluginData.loadAllData();assertTrue(PluginData.isHeld(cow.getUniqueId())); - try(var writes=mockStatic(AnimalFiles.class,CALLS_REAL_METHODS)) { - writes.when(()->AnimalFiles.write(any(Path.class),anyString())).thenThrow(new IOException("receipt unavailable")); - assertThrows(IllegalStateException.class,()->AnimalTokenIntent.prepare(player,player,animal,item)); - } - } - @Test void tokenFinishRefusesAnUnrelatedPublicationOrAnUnavailableStoreBeforeDebit() throws Exception { - var player=player();Cow cow=spawn(Cow.class);var animal=own(player,cow);var store=PluginData.ownership(); - var item=token("Chosen");var intent=AnimalTokenIntent.prepare(player,player,animal,item); - store.tokenEffectId=null;animal.setFed(true);PluginData.saveAnimals(); - String unrelated=store.lastJournalId; - assertThrows(IllegalStateException.class,()->intent.finish(player,player,animal,item,()->fail("effect must not run"))); - assertEquals(1,item.getAmount());assertNull(cow.getCustomName());assertTrue(store.held.containsKey(animal.getUuid())); - JSONObject effect=AnimalFiles.object(Files.readString(store.files("ownership/token-effects").getFirst())); - assertEquals("prepared",effect.get("status"));assertFalse(effect.containsKey("ownerJournal")); - assertNotEquals(effect.get("id"),unrelated); - store.held.clear();var second=AnimalTokenIntent.prepare(player,player,animal,item);PluginData.saveAnimals(); - Path journal=store.path("ownership/journals/"+store.lastJournalId+".json");assertTrue(Files.exists(journal)); - store.ready=false;PluginData.saveAnimals(); - assertThrows(IllegalStateException.class,()->second.finish(player,player,animal,item,()->fail("effect must not run"))); - assertEquals(1,item.getAmount());assertTrue(store.held.containsKey(animal.getUuid())); - for(Path receipt:store.files("ownership/token-effects")) {var raw=AnimalFiles.object(Files.readString(receipt));assertEquals("prepared",raw.get("status"));assertFalse(raw.containsKey("ownerJournal"));} - } -} diff --git a/src/test/java/net/tfminecraft/breedingbuddies/BreedingBuddiesCommandTest.java b/src/test/java/net/tfminecraft/breedingbuddies/BreedingBuddiesCommandTest.java new file mode 100644 index 0000000..c91257b --- /dev/null +++ b/src/test/java/net/tfminecraft/breedingbuddies/BreedingBuddiesCommandTest.java @@ -0,0 +1,158 @@ +package net.tfminecraft.breedingbuddies; + +import static org.junit.jupiter.api.Assertions.*; +import static org.mockito.ArgumentMatchers.*; +import static org.mockito.Mockito.*; + +import java.util.ArrayList; +import java.util.List; +import java.util.Map; + +import org.bukkit.command.Command; +import org.bukkit.command.CommandSender; +import org.bukkit.entity.Cow; +import org.bukkit.entity.EntityType; +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; + +import net.tfminecraft.breedingbuddies.husbandry.HusbandryAnimal; +import net.tfminecraft.breedingbuddies.husbandry.HusbandryConfig; +import net.tfminecraft.breedingbuddies.husbandry.HusbandryEntities; +import net.tfminecraft.breedingbuddies.husbandry.HusbandryRepository; +import net.tfminecraft.breedingbuddies.husbandry.HusbandrySpawner; +import net.tfminecraft.breedingbuddies.husbandry.HusbandrySpecies; + +class BreedingBuddiesCommandTest { + private final List> statics = new ArrayList<>(); + private final BreedingBuddiesCommand commands = new BreedingBuddiesCommand(); + private final Command command = mock(Command.class); + private ServerMock server; + private PlayerMock player; + private BreedingBuddies plugin; + private BreedingBuddies previousPlugin; + + @BeforeEach void setUp() { + server = MockBukkit.mock(); + player = server.addPlayer("Admin"); + player.setOp(true); + previousPlugin = BreedingBuddies.plugin; + plugin = mock(BreedingBuddies.class); + BreedingBuddies.plugin = plugin; + } + + @AfterEach void tearDown() { + MockBukkit.unmock(); + for (int i = statics.size() - 1; i >= 0; i--) statics.get(i).close(); + BreedingBuddies.plugin = previousPlugin; + } + + @Test void staffOnlyCommandsListTheirUsageAndReload() { + player.setOp(false); + run("reload"); + assertEquals(List.of("§cNo permission."), messages()); + assertTrue(tabs("").isEmpty()); + player.setOp(true); + run(); + assertEquals(3, messages().size()); + run("unknown"); + assertEquals(3, messages().size()); + run("reload"); + verify(plugin).reloadAll(); + assertEquals(List.of("§aBreedingBuddies config reloaded."), messages()); + BreedingBuddies.plugin = null; + run("reload"); + assertEquals(List.of("§cBreedingBuddies is not running."), messages()); + } + + @Test void spawnRejectsConsolesAndMalformedArguments() { + CommandSender console = mock(CommandSender.class); + when(console.hasPermission(BreedingBuddiesCommand.PERMISSION)).thenReturn(true); + run(console, "spawn", "cow"); + verify(console).sendMessage("§cOnly players can spawn animals."); + run("spawn"); + assertTrue(messages().getFirst().contains("Usage:")); + run("spawn", "not-an-entity"); + assertEquals(List.of("§cUnknown entity type."), messages()); + MockedStatic config = staticMock(HusbandryConfig.class); + run("spawn", "COW"); + assertEquals(List.of("§cThat type is not a husbandry species."), messages()); + config.when(() -> HusbandryConfig.species(EntityType.COW)).thenReturn(mock(HusbandrySpecies.class)); + run("spawn", "cow", "bad"); + assertEquals(List.of("§cGenetics must be a number."), messages()); + run("spawn", "cow", "2", "bad"); + assertEquals(List.of("§cCare must be a number."), messages()); + } + + @Test void spawnUsesDefaultsAndReportsClampedGeneticsAndCare() { + 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("spawn", "cow"); + spawner.verify(() -> HusbandrySpawner.spawn(player, EntityType.COW, 20, 0)); + assertEquals(List.of("§cFailed to spawn animal."), messages()); + Cow cow = mock(Cow.class); + when(cow.getName()).thenReturn("Daisy"); + spawner.when(() -> HusbandrySpawner.spawn(player, EntityType.COW, 2000, -2)).thenReturn(cow); + run("spawn", "cow", "2000", "-2"); + assertEquals(List.of("§aSpawned Daisy genetics 1000 care 0."), messages()); + } + + @Test void saveRequiresTheDatabaseAndFlushesOnlyLoadedAnimals() { + BreedingBuddies.plugin = null; + run("save"); + assertEquals(List.of("§cThe animal database is not open."), messages()); + BreedingBuddies.plugin = plugin; + run("save"); + assertEquals(List.of("§cThe animal 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("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("save"); + verify(repository).upsertAnimals(loaded); + verify(repository, times(2)).checkpointWal(false); + assertEquals(List.of("§aSaved 1 loaded animals."), messages()); + } + + @Test void completionsOfferSubcommandsSpeciesAndStatHints() { + assertEquals(List.of("reload", "spawn", "save"), tabs("")); + assertEquals(List.of("spawn", "save"), tabs("s")); + MockedStatic config = staticMock(HusbandryConfig.class); + config.when(HusbandryConfig::species).thenReturn(Map.of(EntityType.PIG, mock(HusbandrySpecies.class), + EntityType.COW, mock(HusbandrySpecies.class), EntityType.CHICKEN, mock(HusbandrySpecies.class))); + assertEquals(List.of("CHICKEN", "COW"), tabs("spawn", "c")); + assertEquals(List.of("0", "20", "1000"), tabs("spawn", "cow", "")); + assertEquals(List.of("0", "100", "200"), tabs("spawn", "cow", "1", "")); + assertEquals(List.of("20"), tabs("spawn", "cow", "2")); + assertEquals(List.of("100"), tabs("spawn", "cow", "1", "1")); + assertTrue(tabs("spawn", "cow", "9").isEmpty()); + assertTrue(tabs("spawn", "cow", "1", "9").isEmpty()); + assertTrue(tabs("spawn", "cow", "1", "2", "").isEmpty()); + assertTrue(tabs("save", "").isEmpty()); + } + + private void run(String... args) { run(player, args); } + private void run(CommandSender sender, String... args) { assertTrue(commands.onCommand(sender, command, "breedingbuddies", args)); } + private List tabs(String... args) { return commands.onTabComplete(player, command, "breedingbuddies", args); } + private List messages() { + List out = new ArrayList<>(); String value; + while ((value = player.nextMessage()) != null) out.add(value); + return out; + } + private MockedStatic staticMock(Class type) { + MockedStatic value = mockStatic(type); statics.add(value); return value; + } +} diff --git a/src/test/java/net/tfminecraft/breedingbuddies/BreedingBuddiesLifecycleTest.java b/src/test/java/net/tfminecraft/breedingbuddies/BreedingBuddiesLifecycleTest.java new file mode 100644 index 0000000..a404ab1 --- /dev/null +++ b/src/test/java/net/tfminecraft/breedingbuddies/BreedingBuddiesLifecycleTest.java @@ -0,0 +1,201 @@ +package net.tfminecraft.breedingbuddies; + +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.List; + +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.mockbukkit.mockbukkit.MockBukkit; +import org.mockbukkit.mockbukkit.ServerMock; +import org.mockito.MockedStatic; + +import net.tfminecraft.breedingbuddies.husbandry.HusbandryAnimalsCommand; +import net.tfminecraft.breedingbuddies.husbandry.HusbandryConfig; +import net.tfminecraft.breedingbuddies.husbandry.HusbandryIdentity; +import net.tfminecraft.breedingbuddies.husbandry.HusbandryLifecycleListener; +import net.tfminecraft.breedingbuddies.husbandry.HusbandryLoaderCoverageTest; +import net.tfminecraft.breedingbuddies.husbandry.HusbandryLocator; +import net.tfminecraft.breedingbuddies.husbandry.HusbandryRepository; +import net.tfminecraft.breedingbuddies.husbandry.HusbandryTickTask; +import net.tfminecraft.cooking.Cooking; + +class BreedingBuddiesLifecycleTest { + private final List> statics = new ArrayList<>(); + private ServerMock server; + private BreedingBuddies previousPlugin; + private Cooking previousCooking; + private HusbandryRepository repository; + private MockedStatic repositories; + private MockedStatic lifecycle; + private MockedStatic ticks; + private MockedStatic locator; + private MockedStatic identities; + private HusbandryLoaderCoverageTest.ConfigSnapshot config; + + @BeforeEach void setUp() throws Exception { + config = new HusbandryLoaderCoverageTest.ConfigSnapshot(); + server = MockBukkit.mock(); + previousPlugin = BreedingBuddies.plugin; + previousCooking = Cooking.plugin; + Cooking.plugin = null; + repository = mock(HusbandryRepository.class); + repositories = staticMock(HusbandryRepository.class); + repositories.when(() -> HusbandryRepository.open(any(File.class))).thenReturn(repository); + lifecycle = staticMock(HusbandryLifecycleListener.class); + ticks = staticMock(HusbandryTickTask.class); + locator = staticMock(HusbandryLocator.class); + identities = staticMock(HusbandryIdentity.class); + } + + @AfterEach void tearDown() throws Exception { + config.close(); + MockBukkit.unmock(); + for (int i = statics.size() - 1; i >= 0; i--) statics.get(i).close(); + BreedingBuddies.plugin = previousPlugin; + Cooking.plugin = previousCooking; + } + + @Test void enablingWritesTheConfigOpensTheDatabaseAndRegistersCommandsListenersAndTasks() throws Exception { + BreedingBuddies plugin = loadPlugin(); + + assertSame(plugin, BreedingBuddies.plugin); + assertSame(repository, plugin.getHusbandryRepository()); + assertTrue(new File(plugin.getDataFolder(), "Data").isDirectory()); + Path config = plugin.getDataFolder().toPath().resolve("config.yml"); + assertFalse(Files.readString(config).isBlank()); + new YamlConfiguration().load(config.toFile()); + assertEquals(15, HusbandryConfig.maxAnimals()); + repositories.verify(() -> HusbandryRepository.open(new File(plugin.getDataFolder(), "Data/husbandry.db"))); + lifecycle.verify(HusbandryLifecycleListener::applyStatsRevision); + identities.verify(() -> HusbandryIdentity.start(plugin, repository)); + assertInstanceOf(BreedingBuddiesCommand.class, plugin.getCommand("breedingbuddies").getExecutor()); + assertSame(plugin.getCommand("breedingbuddies").getExecutor(), plugin.getCommand("breedingbuddies").getTabCompleter()); + assertInstanceOf(HusbandryAnimalsCommand.class, plugin.getCommand("animals").getExecutor()); + assertSame(plugin.getCommand("animals").getExecutor(), plugin.getCommand("animals").getTabCompleter()); + assertTrue(plugin.getCommand("animals").getAliases().contains("livestock")); + assertEquals(10, HandlerList.getRegisteredListeners(plugin).stream().map(listener -> listener.getListener().getClass()).distinct().count()); + ticks.verifyNoInteractions(); + + server.getScheduler().performOneTick(); + + lifecycle.verify(HusbandryLifecycleListener::resumeLoadedWorlds); + ticks.verify(HusbandryTickTask::start); + locator.verify(HusbandryLocator::scanUnloaded); + + server.getPluginManager().disablePlugin(plugin); + + ticks.verify(HusbandryTickTask::stop); + lifecycle.verify(HusbandryLifecycleListener::flushLoadedForDisable); + verify(repository).close(); + assertNull(plugin.getHusbandryRepository()); + plugin.onDisable(); + verify(repository, times(1)).close(); + } + + @Test void anExistingConfigIsKeptAndReloadRefreshesStatsAndTheTick() throws Exception { + BreedingBuddies plugin = loadPlugin(); + Path config = plugin.getDataFolder().toPath().resolve("config.yml"); + Files.writeString(config, "max-animals: 3\n"); + + plugin.reloadAll(); + + assertEquals(3, HusbandryConfig.maxAnimals()); + lifecycle.verify(HusbandryLifecycleListener::applyStatsRevision, times(2)); + ticks.verify(HusbandryTickTask::stop); + ticks.verify(HusbandryTickTask::start); + + server.getPluginManager().disablePlugin(plugin); + Files.writeString(config, "max-animals: 4\n"); + server.getPluginManager().enablePlugin(plugin); + assertEquals("max-animals: 4\n", Files.readString(config)); + assertEquals(4, HusbandryConfig.maxAnimals()); + } + + @Test void cookingStillRunningLivestockKeepsBreedingBuddiesOff() { + Cooking cooking = mock(Cooking.class); + when(cooking.isEnabled()).thenReturn(true); + when(cooking.isHusbandryEnabled()).thenReturn(true); + Cooking.plugin = cooking; + + BreedingBuddies plugin = loadPlugin(); + + assertFalse(plugin.isEnabled()); + assertNull(BreedingBuddies.plugin); + repositories.verifyNoInteractions(); + identities.verifyNoInteractions(); + plugin.onDisable(); + ticks.verifyNoInteractions(); + } + + @Test void onlyAnEnabledCookingWithHusbandryOnBlocksStartup() { + assertFalse(BreedingBuddies.cookingRunsLivestock()); + Cooking cooking = mock(Cooking.class); + Cooking.plugin = cooking; + assertFalse(BreedingBuddies.cookingRunsLivestock()); + when(cooking.isEnabled()).thenReturn(true); + assertFalse(BreedingBuddies.cookingRunsLivestock()); + when(cooking.isHusbandryEnabled()).thenReturn(true); + assertTrue(BreedingBuddies.cookingRunsLivestock()); + } + + @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); + assertNull(BreedingBuddies.plugin.getHusbandryRepository()); + identities.verifyNoInteractions(); + } + + @Test void aFailedTakeoverOfCookingRecordsStopsStartupBeforeAnEmptyDatabaseIsCreated() { + MockedStatic migration = staticMock(CookingMigration.class); + java.io.IOException failure = new java.io.IOException("disk full"); + migration.when(() -> CookingMigration.run(any(), any())).thenThrow(failure); + + Throwable error = assertThrows(RuntimeException.class, this::loadPlugin); + + while (error.getCause() != null && error.getCause() != failure) error = error.getCause(); + assertSame(failure, error.getCause()); + repositories.verifyNoInteractions(); + } + + @Test void aDataFolderTheServerCannotCreateFailsWithAClearMessage(@org.junit.jupiter.api.io.TempDir Path folder) throws Exception { + Path blocked = Files.writeString(folder.resolve("BreedingBuddies"), "not a folder"); + IllegalStateException error = assertThrows(IllegalStateException.class, + () -> BreedingBuddies.ensureDirectory(blocked.resolve("Data").toFile())); + assertTrue(error.getMessage().startsWith("Could not create "), error.getMessage()); + BreedingBuddies.ensureDirectory(folder.resolve("Fine").toFile()); + BreedingBuddies.ensureDirectory(folder.resolve("Fine").toFile()); + assertTrue(Files.isDirectory(folder.resolve("Fine"))); + } + + @Test void databaseCloseFailureStillClearsTheRepository() { + BreedingBuddies plugin = loadPlugin(); + doThrow(new IllegalStateException("close failed")).when(repository).close(); + + assertDoesNotThrow(() -> server.getPluginManager().disablePlugin(plugin)); + + assertNull(plugin.getHusbandryRepository()); + } + + private BreedingBuddies loadPlugin() { + MockBukkit.createMockPlugin("TLibs"); + MockBukkit.createMockPlugin("Cooking"); + return MockBukkit.load(BreedingBuddies.class); + } + + private MockedStatic staticMock(Class type) { + MockedStatic value = mockStatic(type); statics.add(value); return value; + } +} diff --git a/src/test/java/net/tfminecraft/breedingbuddies/BreedingBuddiesTest.java b/src/test/java/net/tfminecraft/breedingbuddies/BreedingBuddiesTest.java deleted file mode 100644 index af6969b..0000000 --- a/src/test/java/net/tfminecraft/breedingbuddies/BreedingBuddiesTest.java +++ /dev/null @@ -1,390 +0,0 @@ -package net.tfminecraft.breedingbuddies; - -import net.tfminecraft.breedingbuddies.configurables.CustomItems; -import net.tfminecraft.breedingbuddies.configurables.Messages; -import net.tfminecraft.breedingbuddies.configurables.Numbers; -import net.tfminecraft.breedingbuddies.events.DayChangeEvent; -import org.bukkit.NamespacedKey; -import org.bukkit.attribute.Attribute; -import org.bukkit.command.Command; -import org.bukkit.command.CommandSender; -import org.bukkit.configuration.file.YamlConfiguration; -import org.bukkit.entity.Cow; -import org.bukkit.entity.Entity; -import org.bukkit.entity.Horse; -import org.bukkit.event.EventHandler; -import org.bukkit.event.Listener; -import org.bukkit.event.block.Action; -import org.bukkit.event.player.PlayerInteractEvent; -import org.bukkit.inventory.EquipmentSlot; -import org.bukkit.persistence.PersistentDataType; -import org.junit.jupiter.api.Test; -import org.mockbukkit.mockbukkit.command.ConsoleCommandSenderMock; -import org.mockbukkit.mockbukkit.entity.PlayerMock; - -import java.nio.file.Files; -import java.nio.file.Path; -import java.time.Instant; -import java.util.HashSet; -import java.util.List; -import java.util.UUID; - -import static org.junit.jupiter.api.Assertions.*; -import static org.mockito.Mockito.mock; -import static org.mockito.Mockito.when; - -class BreedingBuddiesTest extends PluginTestBase { - - private org.bukkit.command.PluginCommand command() { - return plugin.getCommand("breedingbuddies"); - } - - private boolean run(CommandSender sender, String... args) { - return plugin.onCommand(sender, command(), "breedingbuddies", args); - } - - private PlayerMock admin() { - PlayerMock admin = player(); - admin.setOp(true); - return admin; - } - - private Entity onlyEntityOf(Class type) { - List found = world.getEntitiesByClass(type).stream().toList(); - assertEquals(1, found.size()); - return found.get(0); - } - - @Test - void enablingWritesUsableDefaultConfigs() throws Exception { - Path folder = plugin.getDataFolder().toPath(); - for (String name : List.of("bundles.yaml", "items.yaml", "messages.yaml", "config.yaml")) { - assertTrue(Files.exists(folder.resolve(name)), name); - } - assertSame(plugin, BreedingBuddies.getInstance()); - assertSame(plugin, command().getTabCompleter()); - assertEquals(plugin.getDataFolder().toPath().resolve("config.yaml").toFile(), plugin.getNumbersFile()); - - assertEquals(10_000, Numbers.maxFriendshipAndGenetics); - assertEquals(200, Numbers.initialGeneticMax); - assertEquals(21, Numbers.startingDayHour); - assertEquals(1, Numbers.startingDayMin); - assertEquals(8, Numbers.hoursBetweenRewards); - assertEquals(2.0, Numbers.mountNerfDivisor); - assertFalse(Numbers.nerfMountStats); - long lastChange = YamlConfiguration.loadConfiguration(plugin.getNumbersFile()).getLong("lastDayChangeDate"); - assertTrue(Math.abs(Instant.now().getEpochSecond() - lastChange) < 60); - - assertEquals("TAMING_ITEM", CustomItems.tamingItem); - assertEquals("CARROT_ON_A_STICK", CustomItems.mountStatsItem); - assertEquals("SHEARS", CustomItems.neuterItem); - assertEquals("Animal Info", Messages.inventoryName); - assertEquals("§6You are no longer the owner of %s.", Messages.removeOwnership); - assertEquals(1.0, MountStats.getMountStats("HORSE").maxJump); - } - - @Test - void listenersAreRegisteredWhenEnabled() { - PlayerMock player = player(); - PlayerInteractEvent click = new PlayerInteractEvent(player, Action.RIGHT_CLICK_AIR, - mmoItem(CustomItems.stableChunkItem), null, org.bukkit.block.BlockFace.SELF, EquipmentSlot.HAND); - - server.getPluginManager().callEvent(click); - - assertTrue(ChunkManager.isStableChunk(player.getLocation().getChunk())); - } - - @Test - void savedDataIsLoadedOnEnable() throws Exception { - UUID owner = player().getUniqueId(); - FarmAnimal daisy = new FarmAnimal(UUID.randomUUID(), "Daisy", new HashSet<>()); - daisy.setState(AnimalStates.HAPPY); - OwnershipManager.registerOwnership(owner, daisy); - - server.getPluginManager().disablePlugin(plugin); - PluginData.getPlayerAnimals().clear(); - server.getPluginManager().enablePlugin(plugin); - - assertEquals("Daisy", OwnershipManager.getAnimal(daisy.getUuid(), owner).getName()); - } - - @Test - void dataIsAutosavedAndSavedOnDisable() throws Exception { - PlayerMock alice = player(); - FarmAnimal saved = own(alice, spawn(Cow.class)); - - server.getScheduler().performOneTick(); - assertTrue(Files.exists(playerFile(alice.getUniqueId()))); - - saved.setFed(true); - server.getPluginManager().disablePlugin(plugin); - assertTrue(Files.readString(playerFile(alice.getUniqueId())).contains("\"fed\":true")); - } - - @Test - void disablingWithADayChangeQueuedStillSaves() throws Exception { - PlayerMock alice = player(); - own(alice, spawn(Cow.class)); - server.getPluginManager().callEvent(new DayChangeEvent()); - assertTrue(net.tfminecraft.breedingbuddies.listeners.DayChangeListener.isIsChangingDay()); - - server.getPluginManager().disablePlugin(plugin); - - assertTrue(Files.exists(playerFile(alice.getUniqueId()))); - // Disabling drops the queued task, so nothing clears the flag. - setStatic(net.tfminecraft.breedingbuddies.listeners.DayChangeListener.class, "isChangingDay", false); - } - - @Test - void commandsNeedASubcommandItRecognises() { - PlayerMock admin = admin(); - - assertFalse(run(admin)); - assertFalse(run(admin, "jump")); - assertEquals("§cUnknown subcommand. Use /breedingbuddies ", admin.nextMessage()); - - Command other = mock(Command.class); - when(other.getName()).thenReturn("other"); - assertFalse(plugin.onCommand(admin, other, "other", new String[] {"reload"})); - } - - @Test - void theCommandIsReachableThroughTheServer() { - PlayerMock admin = admin(); - - assertTrue(server.dispatchCommand(admin, "breedingbuddies stablechunk")); - - assertEquals("§aStable chunk: false", admin.nextMessage()); - } - - @Test - void reloadRereadsTheConfigFiles() throws Exception { - PlayerMock admin = admin(); - Path config = plugin.getNumbersFile().toPath(); - Files.writeString(config, Files.readString(config).replace("maxAnimalsPerChunk: 20", "maxAnimalsPerChunk: 7")); - - assertTrue(run(admin, "reload")); - - assertEquals(7, Numbers.maxAnimalsPerChunk); - assertEquals("§a[BreedingBuddies]§e Config reloaded successfully!", admin.nextMessage()); - assertNull(admin.nextMessage()); - } - - @Test - void anUnwritableConfigFileFallsBackToBuiltInItemDefaults() throws Exception { - PlayerMock admin = admin(); - Path items = plugin.getDataFolder().toPath().resolve("items.yaml"); - Files.delete(items); - Files.createSymbolicLink(items, plugin.getDataFolder().toPath().resolve("missing").resolve("items.yaml")); - - assertTrue(run(admin, "reload")); - - assertEquals("minecraft:carrot", CustomItems.tamingItem); - assertEquals("minecraft:paper", CustomItems.coownershipItem); - assertEquals("minecraft:wheat", CustomItems.universalFeed); - assertEquals("minecraft:golden_apple", CustomItems.caringItem); - assertEquals("minecraft:book", CustomItems.farmReportItem); - assertEquals("minecraft:fence", CustomItems.stableChunkItem); - assertEquals("minecraft:hopper", CustomItems.collectorsItem); - assertEquals("minecraft:carrot_on_a_stick", CustomItems.mountStatsItem); - assertEquals("minecraft:shears", CustomItems.neuterItem); - } - - @Test - void stableChunkReportsWhereThePlayerStandsAndNeedsAPlayer() { - PlayerMock admin = admin(); - ChunkManager.addChunk(admin.getLocation().getChunk()); - - assertTrue(run(admin, "stablechunk")); - assertEquals("§aStable chunk: true", admin.nextMessage()); - - assertFalse(run(new ConsoleCommandSenderMock(), "stablechunk")); - } - - @Test - void changeDayFiresTheDayChangeEvent() { - PlayerMock admin = admin(); - int[] fired = {0}; - server.getPluginManager().registerEvents(new Listener() { - @EventHandler - public void on(DayChangeEvent event) { - fired[0]++; - } - }, plugin); - - assertTrue(run(admin, "changeday")); - - assertEquals(1, fired[0]); - assertEquals("§a[BreedingBuddies]§e Day changed!", admin.nextMessage()); - } - - @Test - void spawnAnimalCreatesAnUnownedSpawnedAnimal() { - PlayerMock admin = admin(); - - assertTrue(run(admin, "spawnanimal", "cow", "300", "4000")); - - Entity cow = onlyEntityOf(Cow.class); - FarmAnimal animal = UnownedAnimalsManager.getAnimal(cow.getUniqueId()); - assertEquals(AnimalStates.SPAWNED, animal.getState()); - assertEquals(300, animal.getFriendshipPoints()); - assertEquals(4000, animal.getGeneticPoints()); - assertFalse(OwnershipManager.hasOwner(cow.getUniqueId())); - assertEquals("§a[BreedingBuddies]§e Animal spawned successfully!", admin.nextMessage()); - } - - @Test - void spawnAnimalRejectsBadInput() { - PlayerMock admin = admin(); - String usage = "§cUsage: /breedingbuddies spawnanimal "; - - assertFalse(run(admin, "spawnanimal", "pig", "1", "1")); - assertEquals("§cInvalid entity type specified in config.", admin.nextMessage()); - assertFalse(run(admin, "spawnanimal", "cow", "lots", "1")); - assertEquals(usage, admin.nextMessage()); - assertFalse(run(admin, "spawnanimal", "cow")); - assertEquals(usage, admin.nextMessage()); - ConsoleCommandSenderMock console = new ConsoleCommandSenderMock(); - assertFalse(run(console, "spawnanimal", "cow", "1", "1")); - assertEquals(usage, console.nextMessage()); - assertTrue(world.getEntitiesByClass(Cow.class).isEmpty()); - } - - @Test - void spawnMountCreatesAMountWithTheGivenStats() { - PlayerMock admin = admin(); - - assertTrue(run(admin, "spawnmount", "HORSE", "500", "28", "0.3", "0.9")); - - Horse horse = (Horse) onlyEntityOf(Horse.class); - assertEquals(28, horse.getAttribute(Attribute.MAX_HEALTH).getBaseValue()); - assertEquals(28, horse.getHealth()); - assertEquals(0.3, horse.getAttribute(Attribute.MOVEMENT_SPEED).getBaseValue()); - assertEquals(0.9, horse.getJumpStrength(), 1e-9); - assertEquals((byte) 1, horse.getPersistentDataContainer() - .get(new NamespacedKey(plugin, "is_nerfed"), PersistentDataType.BYTE)); - FarmAnimal animal = UnownedAnimalsManager.getAnimal(horse.getUniqueId()); - assertEquals(AnimalStates.SPAWNED, animal.getState()); - assertEquals(500, animal.getFriendshipPoints()); - assertFalse(animal.isStableNeeded()); - assertEquals("§a[BreedingBuddies]§e Animal spawned successfully!", admin.nextMessage()); - } - - @Test - void spawnMountRejectsBadInput() { - PlayerMock admin = admin(); - String usage = "§cUsage: /breedingbuddies spawnmount "; - String notMount = "§cEntity type must be a mount (like HORSE, DONKEY, etc)."; - - assertFalse(run(admin, "spawnmount", "UNICORN", "1", "20", "0.2", "0.5")); - assertEquals("§cInvalid entity type.", admin.nextMessage()); - assertFalse(run(admin, "spawnmount", "COW", "1", "20", "0.2", "0.5")); - assertEquals(notMount, admin.nextMessage()); - assertFalse(run(admin, "spawnmount", "UNKNOWN", "1", "20", "0.2", "0.5")); - assertEquals(notMount, admin.nextMessage()); - assertFalse(run(admin, "spawnmount", "HORSE", "1", "20.5", "0.2", "0.5")); - assertEquals(usage, admin.nextMessage()); - assertFalse(run(admin, "spawnmount", "HORSE", "1", "20", "fast", "0.5")); - assertEquals(usage, admin.nextMessage()); - assertFalse(run(admin, "spawnmount", "HORSE")); - assertEquals(usage, admin.nextMessage()); - assertTrue(world.getEntitiesByClass(Horse.class).isEmpty()); - } - - @Test - void saveAndLoadDataCommandsReachTheDisk() throws Exception { - PlayerMock admin = admin(); - FarmAnimal daisy = own(admin, spawn(Cow.class)); - - assertTrue(run(admin, "savedata")); - assertEquals("§a[BreedingBuddies] Data saved.", admin.nextMessage()); - assertTrue(Files.exists(playerFile(admin.getUniqueId()))); - - PluginData.getPlayerAnimals().clear(); - assertTrue(run(admin, "loaddata")); - assertEquals("§a[BreedingBuddies] Data loaded.", admin.nextMessage()); - assertEquals("Daisy", OwnershipManager.getAnimal(daisy.getUuid(), admin.getUniqueId()).getName()); - } - - @Test - void fixMakesEveryOwnedAnimalHappy() { - PlayerMock admin = admin(); - FarmAnimal sad = own(admin, spawn(Cow.class)); - sad.setState(AnimalStates.SAD); - FarmAnimal escaped = own(player(), spawn(Cow.class)); - escaped.setState(AnimalStates.ESCAPED); - - assertTrue(run(admin, "fix")); - - assertEquals(AnimalStates.HAPPY, sad.getState()); - assertEquals(AnimalStates.HAPPY, escaped.getState()); - assertEquals("§a[BreedingBuddies] Animals fixed.", admin.nextMessage()); - } - - @Test - void tabCompletionSuggestsSubcommandsTypesAndValues() { - PlayerMock admin = admin(); - Command command = command(); - - assertEquals(List.of("changeday", "fix", "loaddata", "reload", "savedata", "spawnanimal", "spawnmount", "stablechunk"), - plugin.onTabComplete(admin, command, "bb", new String[] {""})); - assertEquals(List.of("spawnanimal", "spawnmount"), plugin.onTabComplete(admin, command, "bb", new String[] {"SP"})); - - List animals = plugin.onTabComplete(admin, command, "bb", new String[] {"spawnanimal", "c"}); - assertEquals(List.of("COW"), animals); - assertFalse(animals.contains("COD"), "fish are not animals"); - assertFalse(animals.contains("CREEPER")); - assertEquals(List.of("100", "1000", "10000"), plugin.onTabComplete(admin, command, "bb", new String[] {"spawnanimal", "COW", ""})); - assertEquals(List.of("100", "1000", "10000"), plugin.onTabComplete(admin, command, "bb", new String[] {"spawnanimal", "COW", "1", ""})); - assertEquals(List.of(), plugin.onTabComplete(admin, command, "bb", new String[] {"spawnanimal", "COW", "1", "1", ""})); - - assertEquals(List.of("HORSE"), plugin.onTabComplete(admin, command, "bb", new String[] {"spawnmount", "ho"})); - assertEquals(List.of("100", "1000", "10000"), plugin.onTabComplete(admin, command, "bb", new String[] {"spawnmount", "HORSE", ""})); - assertEquals(List.of("15", "30"), plugin.onTabComplete(admin, command, "bb", new String[] {"spawnmount", "HORSE", "1", ""})); - assertEquals(List.of("0.2", "0.25", "0.3"), plugin.onTabComplete(admin, command, "bb", new String[] {"spawnmount", "HORSE", "1", "1", ""})); - assertEquals(List.of("0.5", "0.7", "1.0"), plugin.onTabComplete(admin, command, "bb", new String[] {"spawnmount", "HORSE", "1", "1", "1", ""})); - assertEquals(List.of(), plugin.onTabComplete(admin, command, "bb", new String[] {"spawnmount", "HORSE", "1", "1", "1", "1", ""})); - - assertEquals(List.of(), plugin.onTabComplete(admin, command, "bb", new String[] {"reload", ""})); - Command other = mock(Command.class); - when(other.getName()).thenReturn("other"); - assertNull(plugin.onTabComplete(admin, other, "other", new String[] {""})); - } - @Test - void animalCompletionUsesTheReloadedTopLevelConfiguredAnimals() throws Exception { - PlayerMock admin = admin(); - assertEquals(List.of("COW", "HORSE"), plugin.onTabComplete(admin, command(), "bb", new String[]{"spawnanimal", ""})); - assertEquals(List.of(), plugin.onTabComplete(admin, command(), "bb", new String[]{"spawnanimal", "ca"})); - Path path = plugin.getDataFolder().toPath().resolve("bundles.yaml"); - var config = YamlConfiguration.loadConfiguration(path.toFile()); - config.set("COW", null); - config.set("CAT", config.getConfigurationSection("HORSE").getValues(true)); - config.set("COD.enabled", true); config.set("CREEPER.enabled", true); config.set("NOT_A_TYPE.enabled", true); - config.save(path.toFile()); - run(admin, "reload"); - assertEquals(List.of("CAT", "HORSE"), plugin.onTabComplete(admin, command(), "bb", new String[]{"SPAWNANIMAL", ""})); - assertEquals(List.of("CAT"), plugin.onTabComplete(admin, command(), "bb", new String[]{"spawnanimal", "cA"})); - assertEquals(List.of(), plugin.onTabComplete(admin, command(), "bb", new String[]{"spawnanimal", "cow"})); - } - - @Test - void numericPresetsRespectCurrentTokenAtEveryPositionWithoutRestrictingFreeNumbers() { - PlayerMock admin = admin(); - for (String[] path : List.of(new String[]{"spawnanimal", "COW"}, new String[]{"spawnanimal", "COW", "100"}, - new String[]{"spawnmount", "HORSE"}, new String[]{"spawnmount", "HORSE", "100"}, - new String[]{"spawnmount", "HORSE", "100", "30"}, new String[]{"spawnmount", "HORSE", "100", "30", "0.3"})) { - String match = path.length == 2 || path[0].equals("spawnanimal") ? "1000" - : path.length == 3 ? "3" : path.length == 4 ? "0.3" : "0.7"; - List expected = match.equals("1000") ? List.of("1000", "10000") - : match.equals("3") ? List.of("30") : List.of(match); - String[] args = java.util.Arrays.copyOf(path, path.length + 1); - args[path.length] = match; - assertEquals(expected, plugin.onTabComplete(admin, command(), "bb", args)); - args[path.length] = "9"; - assertEquals(List.of(), plugin.onTabComplete(admin, command(), "bb", args)); - } - assertTrue(run(admin, "spawnanimal", "COW", "9", "11")); - assertNotNull(onlyEntityOf(Cow.class)); - } - -} diff --git a/src/test/java/net/tfminecraft/breedingbuddies/ChunkManagerTest.java b/src/test/java/net/tfminecraft/breedingbuddies/ChunkManagerTest.java deleted file mode 100644 index aa27bce..0000000 --- a/src/test/java/net/tfminecraft/breedingbuddies/ChunkManagerTest.java +++ /dev/null @@ -1,164 +0,0 @@ -package net.tfminecraft.breedingbuddies; - -import net.tfminecraft.breedingbuddies.configurables.Numbers; -import org.bukkit.Chunk; -import org.bukkit.Location; -import org.bukkit.Material; -import org.bukkit.block.data.BlockData; -import org.bukkit.entity.Cow; -import org.bukkit.entity.Entity; -import org.bukkit.entity.Horse; -import org.bukkit.entity.Zombie; -import org.junit.jupiter.api.Test; - -import java.util.ArrayList; -import java.util.List; -import java.util.Set; - -import static org.junit.jupiter.api.Assertions.*; -import static org.mockito.ArgumentMatchers.any; -import static org.mockito.Mockito.*; - -class ChunkManagerTest extends PluginTestBase { - - private Chunk chunk(int x, int z) { - return world.getChunkAt(x, z); - } - - /** A chunk whose block search reports the given materials, since MockBukkit chunks hold no blocks. */ - private static Chunk chunkContaining(Material... materials) { - Chunk chunk = mock(Chunk.class); - boolean[] forced = {false}; - doAnswer(call -> forced[0] = call.getArgument(0)).when(chunk).setForceLoaded(anyBoolean()); - when(chunk.isForceLoaded()).thenAnswer(call -> forced[0]); - when(chunk.contains(any(BlockData.class))).thenReturn(false); - for (Material material : materials) { - when(chunk.contains(argThat((BlockData data) -> data != null && data.getMaterial() == material))).thenReturn(true); - } - return chunk; - } - - @Test - void chunksAreAdjacentOnlyAlongAnEdge() { - ChunkArea area = new ChunkArea(chunk(0, 0)); - - assertTrue(area.isAdjacent(chunk(1, 0))); - assertTrue(area.isAdjacent(chunk(0, -1))); - assertFalse(area.isAdjacent(chunk(1, 1)), "diagonal"); - assertFalse(area.isAdjacent(chunk(2, 0))); - assertFalse(area.isAdjacent(chunk(0, 0)), "the chunk itself"); - } - - @Test - void areasTrackTheirChunksAndAbandonment() { - ChunkArea area = new ChunkArea(chunk(0, 0)); - area.addChunk(chunk(0, 1)); - area.addChunks(Set.of(chunk(0, 2))); - area.increaseDaysAbandoned(); - area.increaseDaysAbandoned(); - - assertEquals(Set.of(chunk(0, 0), chunk(0, 1), chunk(0, 2)), area.getChunks()); - assertTrue(area.containsChunk(chunk(0, 2))); - assertFalse(area.containsChunk(chunk(5, 5))); - assertEquals(2, area.getDaysAbandoned()); - area.setDaysAbandoned(0); - assertEquals(0, area.getDaysAbandoned()); - } - - @Test - void theAreaHoldingAChunkCanBeFound() { - ChunkArea first = new ChunkArea(chunk(0, 0)); - ChunkArea second = new ChunkArea(chunk(9, 9)); - ArrayList areas = new ArrayList<>(List.of(first, second)); - - assertSame(second, ChunkArea.findAreaForChunk(areas, chunk(9, 9))); - assertNull(ChunkArea.findAreaForChunk(areas, chunk(4, 4))); - assertNull(ChunkArea.findAreaForChunk(null, chunk(0, 0))); - assertNull(ChunkArea.findAreaForChunk(areas, null)); - } - - @Test - void aLoneStableChunkStartsItsOwnArea() { - ChunkManager.addChunk(chunk(0, 0)); - ChunkManager.addChunk(chunk(5, 5)); - - assertEquals(2, PluginData.getChunkAreas().size()); - assertTrue(ChunkManager.isStableChunk(chunk(0, 0))); - assertTrue(ChunkManager.isStableChunk(chunk(5, 5))); - assertFalse(ChunkManager.isStableChunk(chunk(1, 1))); - } - - @Test - void aChunkBridgingTwoAreasMergesThemIntoOne() { - ChunkManager.addChunk(chunk(0, 0)); - ChunkManager.addChunk(chunk(2, 0)); - assertEquals(2, PluginData.getChunkAreas().size()); - - ChunkManager.addChunk(chunk(1, 0)); - - assertEquals(1, PluginData.getChunkAreas().size()); - assertEquals(Set.of(chunk(0, 0), chunk(1, 0), chunk(2, 0)), PluginData.getChunkAreas().get(0).getChunks()); - } - - @Test - void onlyAnimalsCountTowardsAnAreasPopulationAndChunksAreReleasedAfterwards() { - Location in = new Location(world, 8, 64, 8); - world.spawn(in, Cow.class); - world.spawn(in, Horse.class); - world.spawn(in, Zombie.class); - Chunk loaded = chunk(0, 0); - Chunk alreadyForced = chunk(1, 0); - alreadyForced.setForceLoaded(true); - ChunkArea area = new ChunkArea(new java.util.HashSet<>(Set.of(loaded, alreadyForced))); - - assertEquals(2, ChunkManager.animalsContained(area)); - assertFalse(loaded.isForceLoaded()); - assertTrue(alreadyForced.isForceLoaded()); - } - - @Test - void areasNeedMoreSpaceOnceThePerChunkLimitIsExceeded() { - Location in = new Location(world, 8, 64, 8); - ChunkArea area = new ChunkArea(chunk(0, 0)); - for (int i = 0; i < Numbers.maxAnimalsPerChunk; i++) { - world.spawn(in, Cow.class); - } - assertFalse(ChunkManager.spaceNeeded(area)); - - Entity extra = world.spawn(in, Cow.class); - assertTrue(ChunkManager.spaceNeeded(area)); - - area.addChunk(chunk(1, 0)); - assertFalse(ChunkManager.spaceNeeded(area), "a second chunk doubles the allowance"); - extra.remove(); - } - - @Test - void waterOrAWaterCauldronAnywhereInTheAreaCounts() { - assertTrue(ChunkManager.containsWater(new ChunkArea(chunkContaining(Material.WATER)))); - assertTrue(ChunkManager.containsWater(new ChunkArea(chunkContaining(Material.WATER_CAULDRON)))); - assertFalse(ChunkManager.containsWater(new ChunkArea(chunkContaining(Material.STONE)))); - assertFalse(ChunkManager.containsWater(new ChunkArea(chunk(0, 0)))); - } - - @Test - void findingWaterReleasesTheChunkItTemporarilyLoaded() { - Chunk wet = chunkContaining(Material.WATER); - - assertTrue(ChunkManager.containsWater(new ChunkArea(wet))); - - assertFalse(wet.isForceLoaded()); - verify(wet).setForceLoaded(true); - verify(wet).setForceLoaded(false); - } - - @Test - void chunksThatWereAlreadyForceLoadedStaySo() { - Chunk wet = chunkContaining(Material.WATER); - wet.setForceLoaded(true); - - ChunkManager.containsWater(new ChunkArea(wet)); - - assertTrue(wet.isForceLoaded()); - } -} diff --git a/src/test/java/net/tfminecraft/breedingbuddies/CookingMigrationTest.java b/src/test/java/net/tfminecraft/breedingbuddies/CookingMigrationTest.java new file mode 100644 index 0000000..3b11351 --- /dev/null +++ b/src/test/java/net/tfminecraft/breedingbuddies/CookingMigrationTest.java @@ -0,0 +1,132 @@ +package net.tfminecraft.breedingbuddies; + +import static org.junit.jupiter.api.Assertions.*; +import static org.mockito.Mockito.*; + +import java.io.File; +import java.io.IOException; +import java.nio.file.Files; +import java.nio.file.Path; +import java.nio.file.StandardCopyOption; +import java.sql.Connection; +import java.sql.DriverManager; +import java.util.logging.Logger; + +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.io.TempDir; + +class CookingMigrationTest { + @TempDir Path plugins; + private final Logger logger = mock(Logger.class); + private File data; + private Path cooking; + + @BeforeEach void setUp() throws Exception { + data = plugins.resolve("BreedingBuddies").toFile(); + Files.createDirectories(data.toPath().resolve("Data")); + cooking = plugins.resolve("Cooking"); + } + + @Test void aFreshServerWithoutCookingRecordsHasNothingToTakeOver() throws Exception { + CookingMigration.run(data, logger); + + assertFalse(Files.exists(data.toPath().resolve(CookingMigration.DATABASE))); + assertFalse(Files.exists(data.toPath().resolve("config.yml"))); + verifyNoInteractions(logger); + } + + @Test void theFirstStartCopiesCookingsDatabaseAndJournalButNotItsSettings() throws Exception { + write(cooking.resolve("Data/husbandry.db"), "records"); + write(cooking.resolve("Data/husbandry.db-wal"), "journal"); + write(cooking.resolve("Data/husbandry.db-shm"), "index"); + write(cooking.resolve("husbandry.yml"), "enabled: false\nmax-animals: 9\n"); + + CookingMigration.run(data, logger); + + Path target = data.toPath().resolve(CookingMigration.DATABASE); + assertEquals("records", Files.readString(target)); + assertEquals("journal", Files.readString(Path.of(target + "-wal"))); + assertFalse(Files.exists(Path.of(target + "-shm")), "SQLite rebuilds the index; a stale one is never copied"); + assertFalse(Files.exists(data.toPath().resolve("config.yml")), "BreedingBuddies starts from its own defaults"); + assertTrue(Files.exists(cooking.resolve("Data/husbandry.db"))); + verify(logger).info(anyString()); + } + + @Test void anUnfinishedCopyIsRedoneButExistingRecordsAreNeverReplaced() throws Exception { + write(cooking.resolve("Data/husbandry.db"), "records"); + write(data.toPath().resolve(CookingMigration.DATABASE + "-wal"), "partial"); + write(data.toPath().resolve(CookingMigration.DATABASE + "-shm"), "stale index"); + write(data.toPath().resolve(CookingMigration.DATABASE + ".migrating"), "partial database"); + + CookingMigration.run(data, logger); + + Path target = data.toPath().resolve(CookingMigration.DATABASE); + assertEquals("records", Files.readString(target)); + assertFalse(Files.exists(Path.of(target + "-wal")), "A journal from an interrupted copy must not replay"); + assertFalse(Files.exists(Path.of(target + "-shm"))); + assertFalse(Files.exists(Path.of(target + ".migrating"))); + + write(cooking.resolve("Data/husbandry.db"), "newer"); + CookingMigration.run(data, logger); + + assertEquals("records", Files.readString(target)); + } + + @Test void retryDoesNotReplayAnOldJournalOverCookingsNewerRecords() throws Exception { + Path source = cooking.resolve(CookingMigration.DATABASE); + Path target = data.toPath().resolve(CookingMigration.DATABASE); + Files.createDirectories(source.getParent()); + try (Connection database = DriverManager.getConnection("jdbc:sqlite:" + source)) { + execute(database, "PRAGMA journal_mode=WAL"); + execute(database, "CREATE TABLE animals (name TEXT)"); + execute(database, "INSERT INTO animals VALUES ('Old horse')"); + // The first attempt stopped after copying the journal, before publishing the database. + Files.copy(Path.of(source + "-wal"), Path.of(target + "-wal")); + } + try (Connection database = DriverManager.getConnection("jdbc:sqlite:" + source)) { + execute(database, "UPDATE animals SET name='New horse'"); + } + assertFalse(Files.exists(Path.of(source + "-wal")), "Cooking checkpointed its newer records"); + + CookingMigration.run(data, logger); + + try (Connection database = DriverManager.getConnection("jdbc:sqlite:" + target); + var statement = database.createStatement(); + var rows = statement.executeQuery("SELECT name FROM animals")) { + assertTrue(rows.next()); + assertEquals("New horse", rows.getString(1)); + } + } + + @Test void aFailedDatabaseCopyCannotLookLikeACompletedMigration() throws Exception { + Path source = cooking.resolve(CookingMigration.DATABASE); + Path target = data.toPath().resolve(CookingMigration.DATABASE); + Path staged = Path.of(target + ".migrating"); + write(source, "complete records"); + try (var files = mockStatic(Files.class, CALLS_REAL_METHODS)) { + files.when(() -> Files.copy(source, staged, StandardCopyOption.REPLACE_EXISTING)).thenAnswer(call -> { + Files.writeString(staged, "partial"); + throw new IOException("Interrupted database copy"); + }); + assertThrows(IOException.class, () -> CookingMigration.run(data, logger)); + } + assertFalse(Files.exists(target), "Only a complete database may mark the migration as done"); + assertFalse(Files.exists(staged)); + + CookingMigration.run(data, logger); + + assertEquals("complete records", Files.readString(target)); + } + + private static void execute(Connection database, String sql) throws Exception { + try (var statement = database.createStatement()) { + statement.execute(sql); + } + } + + private static void write(Path file, String text) throws Exception { + Files.createDirectories(file.getParent()); + Files.writeString(file, text); + } +} diff --git a/src/test/java/net/tfminecraft/breedingbuddies/DayChangeSchedulerTest.java b/src/test/java/net/tfminecraft/breedingbuddies/DayChangeSchedulerTest.java deleted file mode 100644 index 981a7f2..0000000 --- a/src/test/java/net/tfminecraft/breedingbuddies/DayChangeSchedulerTest.java +++ /dev/null @@ -1,115 +0,0 @@ -package net.tfminecraft.breedingbuddies; - -import net.tfminecraft.breedingbuddies.events.DayChangeEvent; -import org.bukkit.event.Event; -import org.bukkit.plugin.PluginManager; -import org.bukkit.plugin.java.JavaPlugin; -import org.junit.jupiter.api.Test; - -import java.time.Clock; -import java.time.Instant; -import java.time.ZoneId; -import java.time.ZoneOffset; - -import static org.junit.jupiter.api.Assertions.*; -import static org.mockito.ArgumentMatchers.any; -import static org.mockito.Mockito.*; - -class DayChangeSchedulerTest extends PluginTestBase { - - /** A UTC clock the test moves by hand. */ - private static final class ManualClock extends Clock { - private Instant now; - - ManualClock(String isoInstant) { - now = Instant.parse(isoInstant); - } - - void set(String isoInstant) { - now = Instant.parse(isoInstant); - } - - @Override - public ZoneId getZone() { - return ZoneOffset.UTC; - } - - @Override - public Clock withZone(ZoneId zone) { - return this; - } - - @Override - public Instant instant() { - return now; - } - } - - private static JavaPlugin pluginWith(PluginManager events) { - JavaPlugin owner = mock(JavaPlugin.class, RETURNS_DEEP_STUBS); - when(owner.getServer().getPluginManager()).thenReturn(events); - return owner; - } - - @Test - void theDayChangesOnceWhenTheTargetMinuteArrives() { - PluginManager events = mock(PluginManager.class); - ManualClock clock = new ManualClock("2026-10-10T20:59:30Z"); - DayChangeScheduler scheduler = new DayChangeScheduler(pluginWith(events), 21, 1, clock); - - scheduler.run(); - clock.set("2026-10-10T21:00:30Z"); - scheduler.run(); - verify(events, never()).callEvent(any(Event.class)); - - clock.set("2026-10-10T21:01:30Z"); - scheduler.run(); - verify(events, times(1)).callEvent(any(DayChangeEvent.class)); - - clock.set("2026-10-10T21:01:50Z"); - scheduler.run(); - verify(events, times(1)).callEvent(any(DayChangeEvent.class)); - - clock.set("2026-10-10T21:02:30Z"); - scheduler.run(); - clock.set("2026-10-11T21:01:10Z"); - scheduler.run(); - verify(events, times(2)).callEvent(any(DayChangeEvent.class)); - } - - @Test - void startingDuringTheTargetMinuteDoesNotTriggerAChange() { - PluginManager events = mock(PluginManager.class); - ManualClock clock = new ManualClock("2026-10-10T21:01:05Z"); - DayChangeScheduler scheduler = new DayChangeScheduler(pluginWith(events), 21, 1, clock); - - clock.set("2026-10-10T21:01:45Z"); - scheduler.run(); - - verify(events, never()).callEvent(any(Event.class)); - } - - @Test - void theRightHourIsNeededAsWellAsTheRightMinute() { - PluginManager events = mock(PluginManager.class); - ManualClock clock = new ManualClock("2026-10-10T19:00:30Z"); - DayChangeScheduler scheduler = new DayChangeScheduler(pluginWith(events), 21, 1, clock); - - clock.set("2026-10-10T19:01:30Z"); - scheduler.run(); - - verify(events, never()).callEvent(any(Event.class)); - } - - @Test - void theSchedulerChecksEveryMinuteUntilCancelled() { - DayChangeScheduler scheduler = new DayChangeScheduler(plugin, 21, 1); - - scheduler.startScheduler(); - int taskId = scheduler.getTaskId(); - assertTrue(server.getScheduler().isQueued(taskId)); - - scheduler.cancel(); - assertFalse(server.getScheduler().isQueued(taskId)); - } -} diff --git a/src/test/java/net/tfminecraft/breedingbuddies/EffectsAndHelpersTest.java b/src/test/java/net/tfminecraft/breedingbuddies/EffectsAndHelpersTest.java deleted file mode 100644 index 2177265..0000000 --- a/src/test/java/net/tfminecraft/breedingbuddies/EffectsAndHelpersTest.java +++ /dev/null @@ -1,124 +0,0 @@ -package net.tfminecraft.breedingbuddies; - -import org.bukkit.Location; -import org.bukkit.Material; -import org.bukkit.Particle; -import org.bukkit.Sound; -import org.bukkit.World; -import org.bukkit.configuration.file.YamlConfiguration; -import org.bukkit.entity.Cow; -import org.bukkit.entity.Entity; -import org.bukkit.entity.Horse; -import org.bukkit.entity.LivingEntity; -import org.bukkit.entity.Pig; -import org.bukkit.inventory.ItemStack; -import org.junit.jupiter.api.Test; -import org.mockbukkit.mockbukkit.entity.PlayerMock; - -import static org.hamcrest.MatcherAssert.assertThat; -import static org.mockbukkit.mockbukkit.matcher.sound.SoundReceiverSoundHeardMatcher.hasHeard; -import static org.junit.jupiter.api.Assertions.*; -import static org.mockito.Mockito.*; - -class EffectsAndHelpersTest extends PluginTestBase { - - @Test - void animalSoundsAreHeardByNearbyPlayers() { - PlayerMock listener = player(); - Cow cow = spawn(Cow.class); - Horse foal = spawn(Horse.class); - - SoundManager.playAmethystStepSound(cow); - SoundManager.playAmethystClusterStepSound(cow); - SoundManager.playAngryCowSound(cow); - SoundManager.playFoalScream(foal); - - assertThat(listener, hasHeard("block.amethyst_block.step")); - assertThat(listener, hasHeard(Sound.BLOCK_AMETHYST_CLUSTER_STEP)); - assertThat(listener, hasHeard("entity.cow.ambient")); - assertThat(listener, hasHeard(Sound.ENTITY_HORSE_DEATH)); - } - - @Test - void playerFeedbackSoundsArePlayedToThePlayer() { - PlayerMock player = player(); - - SoundManager.playBookOpenSound(player); - SoundManager.playExperienceSound(player); - SoundManager.playSignSound(player); - - assertThat(player, hasHeard(Sound.ITEM_BOOK_PAGE_TURN)); - assertThat(player, hasHeard(Sound.ENTITY_EXPERIENCE_ORB_PICKUP)); - assertThat(player, hasHeard(Sound.BLOCK_HANGING_SIGN_PLACE)); - } - - @Test - void entitiesThatCannotMakeSoundsOrParticlesAreSkipped() { - Entity item = mock(Entity.class); - - SoundManager.playAmethystStepSound(item); - SoundManager.playAmethystClusterStepSound(item); - SoundManager.playAngryCowSound(item); - SoundManager.playFoalScream(item); - ParticleManager.heartParticles(item); - ParticleManager.cherryParticles(item); - - verifyNoInteractions(item); - } - - @Test - void particlesAppearJustAboveTheAnimal() { - LivingEntity cow = mock(LivingEntity.class); - World mockWorld = mock(World.class); - when(cow.getWorld()).thenReturn(mockWorld); - when(cow.getLocation()).thenReturn(new Location(mockWorld, 1, 64, 3)); - - ParticleManager.heartParticles(cow); - ParticleManager.cherryParticles(cow); - - verify(mockWorld).spawnParticle(Particle.HEART, 1.0, 65.0, 3.0, 20, 0.5, 0.5, 0.5, 0.1); - verify(mockWorld).spawnParticle(Particle.CHERRY_LEAVES, 1.0, 65.0, 3.0, 10, 0.5, 0.5, 0.5, 0.1); - } - - @Test - void farmAnimalsAreTheSpeciesListedInTheBundleConfig() throws Exception { - assertTrue(AnimalUtils.isFarmAnimal(spawn(Cow.class))); - assertTrue(AnimalUtils.isFarmAnimal(spawn(Horse.class))); - assertFalse(AnimalUtils.isFarmAnimal(spawn(Pig.class))); - - YamlConfiguration bundles = new YamlConfiguration(); - bundles.loadFromString("PIG:\n bundlesEnabled: false\nUNICORN:\n bundlesEnabled: false\n"); - try (LogCapture log = new LogCapture(org.bukkit.Bukkit.getLogger())) { - new AnimalUtils(bundles); - assertTrue(log.contains("Invalid entity type in configuration: UNICORN")); - } - - assertTrue(AnimalUtils.isFarmAnimal(spawn(Pig.class))); - assertFalse(AnimalUtils.isFarmAnimal(spawn(Cow.class)), "species removed on reload stop counting"); - } - - @Test - void onlyEmptyStorageSlotsCountAsSpace() { - PlayerMock player = player(); - assertEquals(36, InventoryUtils.countEmptySlots(player)); - - player.getInventory().setItem(0, new ItemStack(Material.STONE)); - assertEquals(35, InventoryUtils.countEmptySlots(player)); - - for (int slot = 0; slot < 36; slot++) { - player.getInventory().setItem(slot, new ItemStack(Material.STONE)); - } - assertEquals(0, InventoryUtils.countEmptySlots(player), "empty armour slots cannot take a bundle"); - } - - @Test - void animalMenusKnowWhichAnimalTheyShow() { - java.util.UUID animal = java.util.UUID.randomUUID(); - - AnimalInventoryHolder holder = new AnimalInventoryHolder(animal, 27, "Animal Info"); - - assertEquals(animal, holder.getEntityUUID()); - assertSame(holder, holder.getInventory().getHolder()); - assertEquals(27, holder.getInventory().getSize()); - } -} diff --git a/src/test/java/net/tfminecraft/breedingbuddies/FarmAnimalTest.java b/src/test/java/net/tfminecraft/breedingbuddies/FarmAnimalTest.java deleted file mode 100644 index 78e4b21..0000000 --- a/src/test/java/net/tfminecraft/breedingbuddies/FarmAnimalTest.java +++ /dev/null @@ -1,238 +0,0 @@ -package net.tfminecraft.breedingbuddies; - -import net.tfminecraft.breedingbuddies.configurables.Numbers; -import org.bukkit.attribute.Attribute; -import org.bukkit.entity.Cow; -import org.bukkit.entity.Horse; -import org.junit.jupiter.api.Test; -import org.junit.jupiter.params.ParameterizedTest; -import org.junit.jupiter.params.provider.CsvSource; - -import java.time.Duration; -import java.util.Date; -import java.util.HashSet; -import java.util.Set; -import java.util.UUID; - -import static org.junit.jupiter.api.Assertions.*; - -class FarmAnimalTest extends PluginTestBase { - - private static Date ago(Duration duration) { - return new Date(System.currentTimeMillis() - duration.toMillis()); - } - - private Horse maxStatHorse() { - Horse horse = spawn(Horse.class); - horse.getAttribute(Attribute.MAX_HEALTH).setBaseValue(30); - horse.getAttribute(Attribute.MOVEMENT_SPEED).setBaseValue(0.3375); - horse.setJumpStrength(1.0); - return horse; - } - - @Test - void tamedFarmAnimalStartsHappyWithItsOwnerAndRandomGenetics() { - Cow cow = spawn(Cow.class); - UUID owner = UUID.randomUUID(); - - FarmAnimal animal = new FarmAnimal(cow.getUniqueId(), "Daisy", owner, cow); - - assertEquals(cow.getUniqueId(), animal.getUuid()); - assertEquals("Daisy", animal.getName()); - assertEquals(Set.of(owner), animal.getOwnersUuids()); - assertEquals(AnimalStates.HAPPY, animal.getState()); - assertTrue(animal.getGeneticPoints() >= 0 && animal.getGeneticPoints() < Numbers.initialGeneticMax); - assertTrue(animal.isStableNeeded()); - assertEquals(0, animal.getFriendshipPoints()); - } - - @Test - void mountsDoNotNeedAStableAndTakeGeneticsFromTheirStats() { - Horse horse = maxStatHorse(); - UUID owner = UUID.randomUUID(); - - FarmAnimal tamed = new FarmAnimal(horse.getUniqueId(), "Storm", owner, horse); - FarmAnimal inherited = new FarmAnimal(horse.getUniqueId(), "Storm", owner, horse, 12); - FarmAnimal spawned = new FarmAnimal(horse.getUniqueId(), "HORSE", owner, horse, 12, 300); - FarmAnimal bred = new FarmAnimal(horse.getUniqueId(), 12, horse); - FarmAnimal born = new FarmAnimal(horse.getUniqueId(), horse); - - for (FarmAnimal animal : new FarmAnimal[] {tamed, inherited, spawned, bred, born}) { - assertFalse(animal.isStableNeeded()); - assertEquals(Numbers.maxFriendshipAndGenetics, animal.getGeneticPoints()); - } - } - - @Test - void inheritedGeneticsAreKeptForNonMounts() { - Cow cow = spawn(Cow.class); - UUID owner = UUID.randomUUID(); - - FarmAnimal animal = new FarmAnimal(cow.getUniqueId(), "Daisy", owner, cow, 1234); - - assertEquals(1234, animal.getGeneticPoints()); - assertEquals(Set.of(owner), animal.getOwnersUuids()); - assertEquals(AnimalStates.HAPPY, animal.getState()); - assertTrue(animal.isStableNeeded()); - } - - @Test - void unownedAnimalsAreNamelessUntilTamed() { - Cow cow = spawn(Cow.class); - - FarmAnimal spawned = new FarmAnimal(cow.getUniqueId(), "COW", UUID.randomUUID(), cow, 70, 300); - FarmAnimal bred = new FarmAnimal(cow.getUniqueId(), 55, cow); - FarmAnimal born = new FarmAnimal(cow.getUniqueId(), cow); - - assertEquals("???", spawned.getName()); - assertEquals(AnimalStates.UNOWNED, spawned.getState()); - assertEquals(300, spawned.getFriendshipPoints()); - assertEquals(70, spawned.getGeneticPoints()); - assertTrue(spawned.getOwnersUuids().isEmpty()); - assertEquals("???", bred.getName()); - assertEquals(AnimalStates.UNOWNED, bred.getState()); - assertEquals(55, bred.getGeneticPoints()); - assertEquals("???", born.getName()); - assertEquals(0, born.getGeneticPoints()); - assertTrue(born.isStableNeeded()); - } - - @Test - void loadedAnimalsUseTheStoredOwnerSet() { - Set owners = new HashSet<>(Set.of(UUID.randomUUID())); - FarmAnimal animal = new FarmAnimal(UUID.randomUUID(), "Bessie", owners); - - assertSame(owners, animal.getOwnersUuids()); - Set replaced = new HashSet<>(); - animal.setOwnersUuids(replaced); - assertSame(replaced, animal.getOwnersUuids()); - animal.setName("Clover"); - assertEquals("Clover", animal.getName()); - } - - @Test - void friendshipGrowsAndNeverDropsBelowZero() { - FarmAnimal animal = new FarmAnimal(UUID.randomUUID(), "Daisy", new HashSet<>()); - - animal.increaseFriendship(150); - animal.increaseFriendship(50); - assertEquals(200, animal.getFriendshipPoints()); - - animal.setFriendshipPoints(-5); - assertEquals(0, animal.getFriendshipPoints()); - } - - @Test - void wellKeptAnimalIsHappyAfterTheDayAndItsDailyCareResets() { - FarmAnimal animal = new FarmAnimal(UUID.randomUUID(), "Daisy", new HashSet<>()); - animal.setFriendshipPoints(500); - animal.setFed(true); - animal.setCared(true); - - animal.changeDay(false, false, true, 100); - - assertEquals(AnimalStates.HAPPY, animal.getState()); - assertFalse(animal.getFed()); - assertFalse(animal.getCared()); - assertEquals(400, animal.getFriendshipPoints()); - assertTrue(animal.isSleptInStable()); - assertFalse(animal.isWaterNeeded()); - assertFalse(animal.isSpaceNeeded()); - assertNotNull(animal.getLastUpdated()); - } - - @ParameterizedTest - @CsvSource({ - "true,false,true,true,true", - "false,true,true,true,true", - "false,false,false,true,true", - "false,false,true,false,true", - "false,false,true,true,false" - }) - void anyMissingNeedMakesTheAnimalSad(boolean water, boolean space, boolean slept, boolean fed, boolean cared) { - FarmAnimal animal = new FarmAnimal(UUID.randomUUID(), "Daisy", new HashSet<>()); - animal.setFed(fed); - animal.setCared(cared); - - animal.changeDay(water, space, slept, 0); - - assertEquals(AnimalStates.SAD, animal.getState()); - assertEquals(water, animal.isWaterNeeded()); - assertEquals(space, animal.isSpaceNeeded()); - assertEquals(slept, animal.isSleptInStable()); - } - - @Test - void aDayChangeWithinTwentyFourHoursOfTheLastIsIgnored() { - FarmAnimal animal = new FarmAnimal(UUID.randomUUID(), "Daisy", new HashSet<>()); - animal.setState(AnimalStates.HAPPY); - animal.setFriendshipPoints(300); - animal.setFed(true); - Date recent = ago(Duration.ofHours(23)); - animal.setLastUpdated(recent); - - animal.changeDay(true, true, false, 100); - - assertEquals(AnimalStates.HAPPY, animal.getState()); - assertEquals(300, animal.getFriendshipPoints()); - assertTrue(animal.getFed()); - assertSame(recent, animal.getLastUpdated()); - - animal.setLastUpdated(ago(Duration.ofHours(25))); - animal.changeDay(true, true, false, 100); - assertEquals(AnimalStates.SAD, animal.getState()); - assertEquals(200, animal.getFriendshipPoints()); - } - - @Test - void daysOutCountUpAndCanBeRestored() { - FarmAnimal animal = new FarmAnimal(UUID.randomUUID(), "Daisy", new HashSet<>()); - animal.incrementDaysOut(); - animal.incrementDaysOut(); - assertEquals(2, animal.getDaysOut()); - animal.setDaysOut(7); - assertEquals(7, animal.getDaysOut()); - } - - @Test - void onlyHappyAnimalsGiveRewardsAndOnlyAfterTheCooldown() { - FarmAnimal animal = new FarmAnimal(UUID.randomUUID(), "Daisy", new HashSet<>()); - animal.setState(AnimalStates.SAD); - assertFalse(animal.canCollectReward()); - - animal.setState(AnimalStates.HAPPY); - assertTrue(animal.canCollectReward()); - - animal.setLastRewardCollected(ago(Duration.ofHours(7))); - assertFalse(animal.canCollectReward()); - - animal.setLastRewardCollected(ago(Duration.ofHours(8).plusMinutes(1))); - assertTrue(animal.canCollectReward()); - } - - @Test - void timeUntilTheNextBundleIsShownAsHoursAndMinutes() { - Cow cow = spawn(Cow.class); - FarmAnimal animal = new FarmAnimal(cow.getUniqueId(), "Daisy", UUID.randomUUID(), cow); - - assertEquals("00:00", animal.timeForNextReward(cow)); - - animal.setLastRewardCollected(ago(Duration.ofHours(2).plusMinutes(29).plusSeconds(30))); - assertEquals("05:30", animal.timeForNextReward(cow)); - - animal.setLastRewardCollected(ago(Duration.ofDays(2))); - assertEquals("00:00", animal.timeForNextReward(cow)); - - animal.setState(AnimalStates.SAD); - assertEquals("No bundle", animal.timeForNextReward(cow)); - } - - @Test - void speciesWithoutBundlesNeverShowABundleTimer() { - Horse horse = spawn(Horse.class); - FarmAnimal animal = new FarmAnimal(horse.getUniqueId(), "Storm", UUID.randomUUID(), horse); - - assertEquals(AnimalStates.HAPPY, animal.getState()); - assertEquals("No bundle", animal.timeForNextReward(horse)); - } -} diff --git a/src/test/java/net/tfminecraft/breedingbuddies/ItemUtilsTest.java b/src/test/java/net/tfminecraft/breedingbuddies/ItemUtilsTest.java deleted file mode 100644 index b8c700e..0000000 --- a/src/test/java/net/tfminecraft/breedingbuddies/ItemUtilsTest.java +++ /dev/null @@ -1,99 +0,0 @@ -package net.tfminecraft.breedingbuddies; - -import net.Indyuce.mmoitems.MMOItems; -import net.Indyuce.mmoitems.api.Type; -import org.bukkit.Bukkit; -import org.bukkit.Material; -import org.bukkit.inventory.ItemStack; -import org.bukkit.inventory.meta.ItemMeta; -import org.junit.jupiter.api.Test; - -import java.util.Map; - -import static org.junit.jupiter.api.Assertions.*; -import static org.mockito.Mockito.*; - -class ItemUtilsTest extends PluginTestBase { - - private static ItemStack named(Material material, String name) { - ItemStack item = new ItemStack(material); - ItemMeta meta = item.getItemMeta(); - meta.setDisplayName(name); - item.setItemMeta(meta); - return item; - } - - @Test - void itemsAreIdentifiedByMmoItemsIdOrElseByMaterial() { - assertEquals("TAMING_ITEM", ItemUtils.getItemId(mmoItem("TAMING_ITEM"))); - assertEquals("SHEARS", ItemUtils.getItemId(new ItemStack(Material.SHEARS))); - } - - @Test - void customNamesAreReadOnlyWhenPresent() { - assertEquals("Daisy", ItemUtils.getCustomName(named(Material.PAPER, "Daisy"))); - assertNull(ItemUtils.getCustomName(new ItemStack(Material.PAPER))); - assertFalse(ItemUtils.hasCustomName(null)); - assertFalse(ItemUtils.hasCustomName(new ItemStack(Material.AIR))); - } - - @Test - void pluginDataRoundTripsThroughAnItemAndHidesAttributes() { - ItemStack item = new ItemStack(Material.PAPER); - - ItemUtils.storeDataInItem(item, "COW", plugin); - - assertEquals("COW", ItemUtils.getDataFromItem(item, plugin)); - assertTrue(item.getItemMeta().hasItemFlag(org.bukkit.inventory.ItemFlag.HIDE_ATTRIBUTES)); - assertNull(ItemUtils.getDataFromItem(new ItemStack(Material.PAPER), plugin)); - } - - @Test - void missingItemsOrDataAreIgnored() { - ItemStack air = new ItemStack(Material.AIR); - ItemStack paper = new ItemStack(Material.PAPER); - - ItemUtils.storeDataInItem(null, "COW", plugin); - ItemUtils.storeDataInItem(paper, null, plugin); - ItemUtils.storeDataInItem(air, "COW", plugin); - - assertNull(ItemUtils.getDataFromItem(paper, plugin)); - assertNull(ItemUtils.getDataFromItem(null, plugin)); - assertNull(ItemUtils.getDataFromItem(air, plugin)); - } - - @Test - void mmoItemsAreBuiltThroughMmoItemsInTheRequestedAmount() { - ItemStack built = new ItemStack(Material.BUNDLE); - stubMmoItems(Map.of("PETS:COMMON_ANIMAL_BUNDLE", built)); - - ItemStack item = ItemUtils.generateMMOItem("pets", "common_animal_bundle", 3); - - assertEquals(Material.BUNDLE, item.getType()); - assertEquals(3, item.getAmount()); - } - - @Test - void unknownMmoItemsGiveNothing() { - stubMmoItems(Map.of("PETS:COMMON_ANIMAL_BUNDLE", new ItemStack(Material.BUNDLE))); - - assertNull(ItemUtils.generateMMOItem("PETS", "MISSING", 1)); - } - - @Test - void withoutBothPluginsEnabledAPlaceholderIsGivenAndTheErrorLogged() { - MMOItems mmoItems = stubMmoItems(Map.of("PETS:COMMON_ANIMAL_BUNDLE", new ItemStack(Material.BUNDLE))); - Bukkit.getPluginManager().disablePlugin(Bukkit.getPluginManager().getPlugin("MythicLib")); - - try (LogCapture log = new LogCapture(Bukkit.getLogger())) { - ItemStack item = ItemUtils.generateMMOItem("PETS", "COMMON_ANIMAL_BUNDLE", 3); - - assertEquals(new ItemStack(Material.DIRT, 1), item); - assertTrue(log.contains("requires MMOItems and MythicLib")); - } - verify(mmoItems, never()).getItem(any(Type.class), anyString()); - assertTrue(ItemUtils.pluginExists("MMOItems")); - assertFalse(ItemUtils.pluginExists("MythicLib")); - assertFalse(ItemUtils.pluginExists("Missing")); - } -} diff --git a/src/test/java/net/tfminecraft/breedingbuddies/MountUtilsTest.java b/src/test/java/net/tfminecraft/breedingbuddies/MountUtilsTest.java deleted file mode 100644 index 317ebf3..0000000 --- a/src/test/java/net/tfminecraft/breedingbuddies/MountUtilsTest.java +++ /dev/null @@ -1,196 +0,0 @@ -package net.tfminecraft.breedingbuddies; - -import org.bukkit.attribute.Attribute; -import org.bukkit.configuration.file.YamlConfiguration; -import org.bukkit.entity.Cow; -import org.bukkit.entity.Horse; -import org.bukkit.entity.LivingEntity; -import org.bukkit.entity.SkeletonHorse; -import org.junit.jupiter.api.Test; - -import static org.junit.jupiter.api.Assertions.*; -import static org.mockito.Mockito.mock; -import static org.mockito.Mockito.never; -import static org.mockito.Mockito.verify; - -class MountUtilsTest extends PluginTestBase { - - private Horse horse(double health, double speed, double jump) { - Horse horse = spawn(Horse.class); - horse.getAttribute(Attribute.MAX_HEALTH).setBaseValue(health); - horse.setHealth(Math.min(health, horse.getHealth())); - horse.getAttribute(Attribute.MOVEMENT_SPEED).setBaseValue(speed); - horse.setJumpStrength(jump); - return horse; - } - - @Test - void speciesStatsComeFromTheBundleConfigWhateverTheCase() { - MountStats stats = MountStats.getMountStats("horse"); - - assertEquals(15, stats.getMinHealth()); - assertEquals(30, stats.getMaxHealth()); - assertEquals(0.1125, stats.getMinSpeed()); - assertEquals(0.3375, stats.getMaxSpeed()); - assertEquals(0.4, stats.getMinJump()); - assertEquals(1.0, stats.getMaxJump()); - } - - @Test - void statsFallBackPerValueToConfiguredDefaultsAndThenBuiltInDefaults() throws Exception { - YamlConfiguration bundles = new YamlConfiguration(); - bundles.loadFromString("DONKEY:\n mountStats:\n maxHealth: 40\n"); - YamlConfiguration numbers = new YamlConfiguration(); - numbers.loadFromString("defaultMountStats:\n minSpeed: 0.2\n"); - MountStats.setConfigs(bundles, numbers); - - MountStats donkey = MountStats.getMountStats("DONKEY"); - assertEquals(40, donkey.maxHealth); - assertEquals(0.2, donkey.minSpeed); - assertEquals(MountStats.defMinHealth, donkey.minHealth); - assertEquals(MountStats.defMaxSpeed, donkey.maxSpeed); - assertEquals(MountStats.defMinJump, donkey.minJump); - assertEquals(MountStats.defMaxJump, donkey.maxJump); - - MountStats.setConfigs(bundles, new YamlConfiguration()); - MountStats unknown = MountStats.getMountStats("CAMEL"); - assertEquals(MountStats.defMinHealth, unknown.getMinHealth()); - assertEquals(MountStats.defMinSpeed, unknown.getMinSpeed()); - } - - @Test - void onlyHorseLikeEntitiesCarryMountStats() { - Horse horse = horse(20, 0.2, 0.7); - Cow cow = spawn(Cow.class); - - assertTrue(MountUtils.isFullStatMount(horse)); - assertFalse(MountUtils.isFullStatMount(cow)); - assertEquals(0.7, MountUtils.getJumpStrength(horse), 1e-9); - assertEquals(-1, MountUtils.getJumpStrength(cow)); - - MountUtils.setJumpStrength(cow, 0.9); - MountUtils.setJumpStrength(horse, 0.9); - assertEquals(0.9, horse.getJumpStrength(), 1e-9); - } - - @Test - void healthAndSpeedAreReadAndWrittenThroughAttributes() { - Horse horse = horse(30, 0.2, 0.7); - horse.setHealth(30); - - MountUtils.setMaxHealth(horse, 22); - MountUtils.setSpeed(horse, 0.25); - - assertEquals(22, MountUtils.getMaxHealth(horse)); - assertEquals(22, horse.getHealth(), "current health is capped at the new maximum"); - assertEquals(0.25, MountUtils.getSpeed(horse)); - } - - @Test - void entitiesWithoutTheAttributesAreLeftAloneAndReportMinusOne() { - LivingEntity entity = mock(LivingEntity.class); - - MountUtils.setMaxHealth(entity, 20); - MountUtils.setSpeed(entity, 0.3); - - assertEquals(-1, MountUtils.getMaxHealth(entity)); - assertEquals(-1, MountUtils.getSpeed(entity)); - verify(entity, never()).setHealth(org.mockito.ArgumentMatchers.anyDouble()); - } - - @Test - void displayConversionsMatchVanillaUnits() { - assertEquals(15, MountUtils.healthPointsToHearts(31)); - assertEquals(9.486, MountUtils.speedToBlocksPerSecond(0.225), 1e-9); - assertEquals(1.6201, MountUtils.jumpStrengthToBlockHeight(0.5), 1e-3); - assertEquals(5.2923, MountUtils.jumpStrengthToBlockHeight(1.0), 1e-3); - } - - @Test - void breedingTwoTopParentsAlwaysGivesTheTopValue() { - for (int i = 0; i < 50; i++) { - assertEquals(30, MountUtils.breedAttribute(30, 30, 15, 30), 1e-9); - } - } - - @Test - void breedingTwoWeakParentsStaysInTheBottomQuarter() { - for (int i = 0; i < 200; i++) { - double child = MountUtils.breedAttribute(15, 15, 15, 30); - assertTrue(child >= 15 && child <= 15 + 0.25 * 15, "child health " + child); - } - } - - @Test - void bredValuesStayWithinTheSpeciesRange() { - for (int i = 0; i < 200; i++) { - double child = MountUtils.breedAttribute(0.12, 0.33, 0.1125, 0.3375); - assertTrue(child >= 0.1125 && child <= 0.3375, "child speed " + child); - } - } - - @Test - void foalsInheritTheirParentsCappedStats() { - Horse mother = horse(60, 0.9, 1.0); - Horse father = horse(30, 0.3375, 1.0); - Horse foal = horse(15, 0.1125, 0.4); - - MountUtils.applyInheritedStats(foal, mother, father); - - assertEquals(30, MountUtils.getMaxHealth(foal), 1e-9); - assertEquals(0.3375, MountUtils.getSpeed(foal), 1e-9); - assertEquals(1.0, foal.getJumpStrength(), 1e-9); - } - - @Test - void inheritanceNeedsHorseLikeParents() { - Horse mother = horse(30, 0.3375, 1.0); - Cow father = spawn(Cow.class); - Horse foal = horse(15, 0.1125, 0.4); - - MountUtils.applyInheritedStats(foal, mother, father); - - assertEquals(15, MountUtils.getMaxHealth(foal)); - assertEquals(0.1125, MountUtils.getSpeed(foal)); - assertEquals(0.4, foal.getJumpStrength(), 1e-9); - } - - @Test - void geneticsAverageTheNormalisedStats() { - assertEquals(10_000, MountUtils.calculateGeneticsFromStats(30, 0.3375, 1.0, "HORSE")); - assertEquals(0, MountUtils.calculateGeneticsFromStats(15, 0.1125, 0.4, "HORSE")); - assertEquals(5_000, MountUtils.calculateGeneticsFromStats(22.5, 0.225, 0.7, "HORSE")); - assertEquals(10_000, MountUtils.calculateGeneticsFromStats(99, 9, 9, "HORSE"), "out-of-range stats are capped"); - assertEquals(0, MountUtils.calculateGeneticsFromStats(1, 0, 0, "HORSE")); - } - - @Test - void namedMountsAreRatedAgainstTheirSpeciesStats() throws Exception { - YamlConfiguration bundles = new YamlConfiguration(); - bundles.loadFromString("SKELETON_HORSE:\n mountStats:\n minHealth: 10\n maxHealth: 20\n" - + " minSpeed: 0.1\n maxSpeed: 0.2\n minJump: 0.5\n maxJump: 0.9\n"); - MountStats.setConfigs(bundles, new YamlConfiguration()); - SkeletonHorse mount = spawn(SkeletonHorse.class); - mount.setCustomName("Bones"); - mount.getAttribute(Attribute.MAX_HEALTH).setBaseValue(20); - mount.getAttribute(Attribute.MOVEMENT_SPEED).setBaseValue(0.2); - mount.setJumpStrength(0.9); - - assertEquals(10_000, MountUtils.getGenetics(mount)); - } - - @Test - void neuteringIsStoredOnTheEntityAndLegacyTagsStillCount() { - Horse horse = spawn(Horse.class); - assertFalse(MountUtils.isNeutered(horse)); - - MountUtils.setNeuteredFlag(horse, true); - assertTrue(MountUtils.isNeutered(horse)); - - MountUtils.setNeuteredFlag(horse, false); - assertFalse(MountUtils.isNeutered(horse)); - - horse.addScoreboardTag("ho.isNeutered"); - assertTrue(MountUtils.isNeutered(horse)); - } -} diff --git a/src/test/java/net/tfminecraft/breedingbuddies/OwnershipManagerTest.java b/src/test/java/net/tfminecraft/breedingbuddies/OwnershipManagerTest.java deleted file mode 100644 index f8ca2ef..0000000 --- a/src/test/java/net/tfminecraft/breedingbuddies/OwnershipManagerTest.java +++ /dev/null @@ -1,105 +0,0 @@ -package net.tfminecraft.breedingbuddies; - -import org.junit.jupiter.api.Test; - -import java.util.HashSet; -import java.util.Set; -import java.util.UUID; - -import static org.junit.jupiter.api.Assertions.*; - -class OwnershipManagerTest extends PluginTestBase { - private UUID alice, bob; - @org.junit.jupiter.api.BeforeEach void actors() { alice = player().getUniqueId(); bob = player().getUniqueId(); } - - private static FarmAnimal animal() { - FarmAnimal a = new FarmAnimal(UUID.randomUUID(), "Daisy", new HashSet<>()); a.setState(AnimalStates.HAPPY); return a; - } - - @Test - void registeringOwnershipLinksBothSides() { - FarmAnimal daisy = animal(); - - OwnershipManager.registerOwnership(alice, daisy); - OwnershipManager.registerOwnership(alice, daisy); - - assertEquals(Set.of(daisy), OwnershipManager.getAnimals(alice)); - assertEquals(Set.of(alice), daisy.getOwnersUuids()); - assertTrue(OwnershipManager.isOwner(alice, daisy.getUuid())); - assertFalse(OwnershipManager.isOwner(bob, daisy.getUuid())); - assertFalse(OwnershipManager.isOwner(alice, UUID.randomUUID())); - assertTrue(OwnershipManager.hasOwner(daisy.getUuid())); - } - - @Test - void animalsCanBeFoundForAnyOwnerOrAParticularOne() { - FarmAnimal daisy = animal(); - FarmAnimal clover = animal(); - OwnershipManager.registerOwnership(alice, daisy); - OwnershipManager.registerOwnership(alice, clover); - OwnershipManager.registerOwnership(bob, daisy); - - assertSame(daisy, OwnershipManager.getAnimal(daisy.getUuid())); - assertSame(daisy, OwnershipManager.getAnimal(daisy.getUuid(), bob)); - assertNull(OwnershipManager.getAnimal(clover.getUuid(), bob)); - assertNull(OwnershipManager.getAnimal(daisy.getUuid(), UUID.randomUUID())); - assertNull(OwnershipManager.getAnimal(UUID.randomUUID())); - assertTrue(OwnershipManager.getAnimals(UUID.randomUUID()).isEmpty()); - } - - @Test - void anAnimalListedWithNoOwnersHasNoOwner() { - FarmAnimal daisy = animal(); - OwnershipManager.registerOwnership(alice, daisy); - OwnershipManager.removeOwnership(alice, daisy); - - assertFalse(OwnershipManager.hasOwner(daisy.getUuid())); - assertFalse(OwnershipManager.hasOwner(UUID.randomUUID())); - } - - @Test - void givingUpOwnershipLeavesOtherCoOwnersInPlace() { - FarmAnimal daisy = animal(); - OwnershipManager.registerOwnership(alice, daisy); - OwnershipManager.registerOwnership(bob, daisy); - - OwnershipManager.removeOwnership(alice, daisy); - OwnershipManager.removeOwnership(UUID.randomUUID(), daisy); - - assertFalse(OwnershipManager.isOwner(alice, daisy.getUuid())); - assertTrue(OwnershipManager.isOwner(bob, daisy.getUuid())); - assertEquals(Set.of(bob), daisy.getOwnersUuids()); - } - - @Test - void removingAnAnimalDropsItFromEveryOwner() { - FarmAnimal daisy = animal(); - FarmAnimal clover = animal(); - OwnershipManager.registerOwnership(alice, daisy); - OwnershipManager.registerOwnership(bob, daisy); - OwnershipManager.registerOwnership(bob, clover); - - OwnershipManager.removeAnimal(daisy.getUuid()); - - assertTrue(OwnershipManager.getAnimals(alice).isEmpty()); - assertEquals(Set.of(clover), OwnershipManager.getAnimals(bob)); - assertTrue(daisy.getOwnersUuids().isEmpty()); - assertEquals(Set.of(bob), clover.getOwnersUuids()); - } - - @Test - void unownedRegistryKeepsTheFirstRecordForAnAnimal() { - FarmAnimal first = animal(); - FarmAnimal duplicate = new FarmAnimal(first.getUuid(), "Other", new HashSet<>()); - - UnownedAnimalsManager.addUnownedAnimal(first); - UnownedAnimalsManager.addUnownedAnimal(duplicate); - - assertTrue(UnownedAnimalsManager.isUnowned(first.getUuid())); - assertSame(first, UnownedAnimalsManager.getAnimal(first.getUuid())); - - UnownedAnimalsManager.removeUnownedAnimal(first.getUuid()); - assertFalse(UnownedAnimalsManager.isUnowned(first.getUuid())); - assertNull(UnownedAnimalsManager.getAnimal(first.getUuid())); - } -} diff --git a/src/test/java/net/tfminecraft/breedingbuddies/PluginDataTest.java b/src/test/java/net/tfminecraft/breedingbuddies/PluginDataTest.java deleted file mode 100644 index 24a70ce..0000000 --- a/src/test/java/net/tfminecraft/breedingbuddies/PluginDataTest.java +++ /dev/null @@ -1,229 +0,0 @@ -package net.tfminecraft.breedingbuddies; - -import net.tfminecraft.breedingbuddies.listeners.DayChangeListener; -import org.bukkit.Bukkit; -import org.junit.jupiter.api.Test; -import org.mockbukkit.mockbukkit.entity.PlayerMock; - -import java.io.ByteArrayOutputStream; -import java.io.PrintStream; -import java.nio.file.Files; -import java.util.Date; -import java.util.HashSet; -import java.util.Set; -import java.util.UUID; - -import static org.junit.jupiter.api.Assertions.*; - -class PluginDataTest extends PluginTestBase { - - private static FarmAnimal detailedAnimal(String name) { - FarmAnimal animal = new FarmAnimal(UUID.randomUUID(), name, new HashSet<>()); - animal.setStableNeeded(false); - animal.setFriendshipPoints(1200); - animal.setGeneticPoints(340); - animal.setState(AnimalStates.SAD); - animal.setFed(true); - animal.setCared(true); - animal.setSleptInStable(false); - animal.setSpaceNeeded(true); - animal.setWaterNeeded(true); - animal.setDaysOut(2); - animal.setLastRewardCollected(new Date(1_700_000_000_000L)); - animal.setLastUpdated(new Date(1_700_000_500_000L)); - return animal; - } - - private static void assertSameDetails(FarmAnimal expected, FarmAnimal actual) { - assertEquals(expected.getUuid(), actual.getUuid()); - assertEquals(expected.getName(), actual.getName()); - assertEquals(expected.isStableNeeded(), actual.isStableNeeded()); - assertEquals(expected.getFriendshipPoints(), actual.getFriendshipPoints()); - assertEquals(expected.getGeneticPoints(), actual.getGeneticPoints()); - assertEquals(expected.getState(), actual.getState()); - assertEquals(expected.getFed(), actual.getFed()); - assertEquals(expected.getCared(), actual.getCared()); - assertEquals(expected.isSleptInStable(), actual.isSleptInStable()); - assertEquals(expected.isSpaceNeeded(), actual.isSpaceNeeded()); - assertEquals(expected.isWaterNeeded(), actual.isWaterNeeded()); - assertEquals(expected.getDaysOut(), actual.getDaysOut()); - assertEquals(expected.getLastRewardCollected(), actual.getLastRewardCollected()); - assertEquals(expected.getLastUpdated(), actual.getLastUpdated()); - } - - @Test - void onlinePlayersAnimalsSurviveASaveAndReload() { - PlayerMock alice = player(); - PlayerMock bob = player(); - FarmAnimal shared = detailedAnimal("Daisy"); - FarmAnimal fresh = new FarmAnimal(UUID.randomUUID(), "Clover", new HashSet<>()); - fresh.setState(AnimalStates.HAPPY); - OwnershipManager.registerOwnership(alice.getUniqueId(), shared); - OwnershipManager.registerOwnership(bob.getUniqueId(), shared); - OwnershipManager.registerOwnership(bob.getUniqueId(), fresh); - - PluginData.saveAllData(false); - PluginData.getPlayerAnimals().clear(); - PluginData.loadAllData(); - - FarmAnimal aliceDaisy = OwnershipManager.getAnimal(shared.getUuid(), alice.getUniqueId()); - FarmAnimal bobDaisy = OwnershipManager.getAnimal(shared.getUuid(), bob.getUniqueId()); - assertSameDetails(shared, aliceDaisy); - assertSame(aliceDaisy, bobDaisy, "co-owners share one loaded record"); - assertEquals(Set.of(alice.getUniqueId(), bob.getUniqueId()), aliceDaisy.getOwnersUuids()); - FarmAnimal loadedClover = OwnershipManager.getAnimal(fresh.getUuid(), bob.getUniqueId()); - assertNull(loadedClover.getLastRewardCollected()); - assertNull(loadedClover.getLastUpdated()); - assertEquals(AnimalStates.HAPPY, loadedClover.getState()); - } - - @Test - void offlineOwnersAreSavedAndReloaded() { - UUID offline = player().getUniqueId(); - FarmAnimal daisy = detailedAnimal("Daisy"); - OwnershipManager.registerOwnership(offline, daisy); - - PluginData.saveAllData(false); - PluginData.getPlayerAnimals().clear(); - PluginData.loadAllData(); - - assertSameDetails(daisy, OwnershipManager.getAnimal(daisy.getUuid(), offline)); - } - - @Test - void anOwnerWhoGaveUpTheirLastAnimalDoesNotGetItBackAfterAReload() { - UUID offline = player().getUniqueId(); - FarmAnimal daisy = detailedAnimal("Daisy"); - OwnershipManager.registerOwnership(offline, daisy); - PluginData.saveAllData(false); - - OwnershipManager.removeOwnership(offline, daisy); - PluginData.saveAllData(false); - PluginData.getPlayerAnimals().clear(); - PluginData.loadAllData(); - - assertTrue(OwnershipManager.getAnimals(offline).isEmpty()); - } - - @Test - void unownedAnimalsSurviveASaveAndReload() { - FarmAnimal stray = detailedAnimal("???"); - - FarmAnimal plain = new FarmAnimal(UUID.randomUUID(), "???", new HashSet<>()); - plain.setState(AnimalStates.UNOWNED); - UnownedAnimalsManager.addUnownedAnimal(stray); - UnownedAnimalsManager.addUnownedAnimal(plain); - - PluginData.saveAllData(true); - PluginData.getUnownedAnimals().clear(); - PluginData.loadAllData(); - - assertSameDetails(stray, UnownedAnimalsManager.getAnimal(stray.getUuid())); - assertEquals(stray.getOwnersUuids(), UnownedAnimalsManager.getAnimal(stray.getUuid()).getOwnersUuids()); - assertSameDetails(plain, UnownedAnimalsManager.getAnimal(plain.getUuid())); - } - - @Test - void stableAreasSurviveASaveAndReload() { - ChunkManager.addChunk(world.getChunkAt(0, 0)); - ChunkManager.addChunk(world.getChunkAt(0, 1)); - ChunkManager.addChunk(world.getChunkAt(7, 7)); - PluginData.getChunkAreas().get(0).setDaysAbandoned(3); - int abandoned0 = PluginData.getChunkAreas().get(0).getDaysAbandoned(); - Set chunks0 = Set.copyOf(PluginData.getChunkAreas().get(0).getChunks()); - - PluginData.saveAllData(false); - PluginData.getChunkAreas().clear(); - PluginData.loadAllData(); - - assertEquals(2, PluginData.getChunkAreas().size()); - ChunkArea reloaded = ChunkArea.findAreaForChunk(PluginData.getChunkAreas(), chunks0.iterator().next()); - assertEquals(chunks0, reloaded.getChunks()); - assertEquals(abandoned0, reloaded.getDaysAbandoned()); - } - - @Test - void stableChunksInMissingWorldsAreSkipped() throws Exception { - Files.createDirectories(DATA_DIR); - Files.writeString(DATA_DIR.resolve("stablechunks.json"), - "[{\"daysAbandoned\":1,\"chunks\":[{\"world\":\"gone\",\"x\":1,\"z\":2},{\"world\":\"world\",\"x\":3,\"z\":4}]}]"); - PrintStream original = System.err; - ByteArrayOutputStream err = new ByteArrayOutputStream(); - System.setErr(new PrintStream(err, true)); - try { - PluginData.loadStableChunks(); - } finally { - System.setErr(original); - } - - assertEquals(Set.of(world.getChunkAt(3, 4)), PluginData.getChunkAreas().get(0).getChunks()); - assertEquals(1, PluginData.getChunkAreas().get(0).getDaysAbandoned()); - assertTrue(err.toString().contains("World gone not found.")); - } - - @Test - void savingWhileADayChangeRunsIsLeftToTheDayChange() throws Exception { - PlayerMock alice = player(); - OwnershipManager.registerOwnership(alice.getUniqueId(), detailedAnimal("Daisy")); - setStatic(DayChangeListener.class, "isChangingDay", true); - - PluginData.saveAllData(false); - String committed = Files.readString(playerFile(alice.getUniqueId())); - PluginData.saveAllData(false); - assertEquals(committed, Files.readString(playerFile(alice.getUniqueId()))); - - PluginData.saveAllData(true); - assertTrue(Files.exists(playerFile(alice.getUniqueId()))); - setStatic(DayChangeListener.class, "isChangingDay", false); - } - - @Test - void nothingIsLoadedWhenNoDataExists() { - PluginData.loadAllData(); - - assertTrue(PluginData.getPlayerAnimals().isEmpty()); - assertTrue(PluginData.getUnownedAnimals().isEmpty()); - assertTrue(PluginData.getChunkAreas().isEmpty()); - } - - @Test - void strayFilesInThePlayerFolderAreIgnoredOrReported() throws Exception { - Files.createDirectories(DATA_DIR.resolve("PlayerData").resolve("nested")); - Files.writeString(DATA_DIR.resolve("PlayerData").resolve("notes.txt"), "hello"); - Files.writeString(DATA_DIR.resolve("PlayerData").resolve("not-a-uuid.json"), "{}"); - - try (LogCapture log = new LogCapture(Bukkit.getLogger())) { - PluginData.loadAllData(); - assertTrue(log.contains("Animal ownership held")); - assertFalse(log.contains("notes.txt")); - } - assertTrue(PluginData.getPlayerAnimals().isEmpty()); - } - - @Test - void corruptFilesDoNotReplaceWhatIsAlreadyLoaded() throws Exception { - ChunkManager.addChunk(world.getChunkAt(0, 0)); - UUID player = player().getUniqueId(); - Files.createDirectories(DATA_DIR.resolve("PlayerData")); - Files.writeString(DATA_DIR.resolve("stablechunks.json"), "{not json"); - Files.writeString(DATA_DIR.resolve("unownedanimals.json"), "{not json"); - Files.writeString(DATA_DIR.resolve("PlayerData").resolve(player + ".json"), "{not json"); - - assertDoesNotThrow(PluginData::loadAllData); - - assertEquals(1, PluginData.getChunkAreas().size()); - assertTrue(PluginData.getUnownedAnimals().isEmpty()); - assertFalse(PluginData.getPlayerAnimals().containsKey(player)); - } - - @Test void failedDurableFanoutRefusesFurtherOwnership() throws Exception { - PlayerMock alice = player(); - FarmAnimal animal = detailedAnimal("Daisy"); - OwnershipManager.registerOwnership(alice.getUniqueId(), animal); - Files.writeString(playerFile(alice.getUniqueId()), "{not json"); - assertThrows(IllegalStateException.class, () -> PluginData.saveAllData(true)); - assertFalse(PluginData.ownershipReady()); - assertFalse(OwnershipManager.isOwner(alice.getUniqueId(), animal.getUuid())); - assertTrue(OwnershipManager.hasOwner(animal.getUuid())); - } -} diff --git a/src/test/java/net/tfminecraft/breedingbuddies/PluginTestBase.java b/src/test/java/net/tfminecraft/breedingbuddies/PluginTestBase.java deleted file mode 100644 index 6f4173b..0000000 --- a/src/test/java/net/tfminecraft/breedingbuddies/PluginTestBase.java +++ /dev/null @@ -1,254 +0,0 @@ -package net.tfminecraft.breedingbuddies; - -import io.lumine.mythic.lib.api.item.NBTItem; -import net.Indyuce.mmoitems.MMOItems; -import net.Indyuce.mmoitems.api.Type; -import net.tfminecraft.breedingbuddies.listeners.DayChangeListener; -import org.bukkit.Chunk; -import org.bukkit.Location; -import org.bukkit.Material; -import org.bukkit.NamespacedKey; -import org.bukkit.block.data.BlockData; -import org.bukkit.configuration.file.YamlConfiguration; -import org.bukkit.entity.Entity; -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.mockbukkit.mockbukkit.MockBukkit; -import org.mockbukkit.mockbukkit.ServerMock; -import org.mockbukkit.mockbukkit.entity.PlayerMock; -import org.mockbukkit.mockbukkit.world.WorldMock; -import org.mockito.MockedStatic; - -import java.io.IOException; -import java.lang.reflect.Field; -import java.nio.file.Files; -import java.nio.file.Path; -import java.time.Instant; -import java.util.ArrayList; -import java.util.Arrays; -import java.util.HashSet; -import java.util.Comparator; -import java.util.HashMap; -import java.util.List; -import java.util.Map; -import java.util.UUID; -import java.util.logging.Handler; -import java.util.logging.LogRecord; -import java.util.logging.Logger; -import java.util.stream.Stream; - -import static org.junit.jupiter.api.Assertions.assertFalse; -import static org.mockito.ArgumentMatchers.any; -import static org.mockito.ArgumentMatchers.anyBoolean; -import static org.mockito.ArgumentMatchers.anyString; -import static org.mockito.Mockito.RETURNS_DEEP_STUBS; -import static org.mockito.Mockito.doAnswer; -import static org.mockito.Mockito.mock; -import static org.mockito.Mockito.mockStatic; -import static org.mockito.Mockito.when; - -/** - * Starts a MockBukkit server with the plugin enabled and its default configuration. - * MMOItems and MythicLib are present as stub plugins; MythicLib's NBT lookup is - * faked so that items made with {@link #mmoItem(String)} report an MMOItems ID. - */ -public abstract class PluginTestBase { - protected static final Path DATA_DIR = Path.of("plugins", "BreedingBuddies", "Data"); - private static final NamespacedKey TEST_MMOITEM_ID = new NamespacedKey("bbtest", "mmoitems_id"); - - protected ServerMock server; - protected BreedingBuddies plugin; - protected WorldMock world; - private MockedStatic nbtItems; - protected net.tfminecraft.tlibs.character.CharacterIdentity characters; - protected final Map sessions = new HashMap<>(); - - @BeforeEach - void startServer() throws IOException { - deleteRecursively(Path.of("plugins")); - PluginData.getUnownedAnimals().clear(); - PluginData.getPlayerAnimals().clear(); - PluginData.getChunkAreas().clear(); - nbtItems = mockStatic(NBTItem.class); - nbtItems.when(() -> NBTItem.get(any(ItemStack.class))).thenAnswer(call -> fakeNbt(call.getArgument(0))); - server = MockBukkit.mock(); - MockBukkit.createMockPlugin("MMOItems"); - MockBukkit.createMockPlugin("MythicLib"); - var tlibs = MockBukkit.createMockPlugin("TLibs"); - characters = mock(net.tfminecraft.tlibs.character.CharacterIdentity.class); - when(characters.legacyHeirsReady()).thenReturn(true); - when(characters.currentSession(any(org.bukkit.entity.Player.class))).thenAnswer(call -> { - UUID account = ((org.bukkit.entity.Player) call.getArgument(0)).getUniqueId(); - return java.util.Optional.ofNullable(sessions.computeIfAbsent(account, key -> new net.tfminecraft.tlibs.character.CharacterSession(new net.tfminecraft.tlibs.character.CharacterRef(key, key), 1L))); - }); - when(characters.isCurrent(any())).thenAnswer(call -> { var session = (net.tfminecraft.tlibs.character.CharacterSession) call.getArgument(0); return session != null && session.equals(sessions.get(session.ref().ownerId())); }); - when(characters.legacyHeir(any())).thenAnswer(call -> { UUID account = call.getArgument(0); return java.util.Optional.of(new net.tfminecraft.tlibs.character.CharacterRef(account, account)); }); - server.getServicesManager().register(net.tfminecraft.tlibs.character.CharacterIdentity.class, characters, tlibs, org.bukkit.plugin.ServicePriority.Normal); - world = server.addSimpleWorld("world"); - plugin = MockBukkit.load(BreedingBuddies.class); - } - - @AfterEach - void stopServer() throws Exception { - try { - awaitDayChange(); - } finally { - try { - MockBukkit.unmock(); - } finally { - nbtItems.close(); - MMOItems.plugin = null; - setStatic(DayChangeListener.class, "isChangingDay", false); - setStatic(BreedingBuddies.class, "instance", null); - } - } - } - - private static NBTItem fakeNbt(ItemStack item) { - NBTItem nbt = mock(NBTItem.class); - String id = null; - if (item != null && item.hasItemMeta()) { - id = item.getItemMeta().getPersistentDataContainer().get(TEST_MMOITEM_ID, PersistentDataType.STRING); - } - when(nbt.hasType()).thenReturn(id != null); - when(nbt.getString("MMOITEMS_ITEM_ID")).thenReturn(id); - return nbt; - } - - /** An item that the faked MythicLib NBT lookup reports as the given MMOItems ID. */ - protected static ItemStack mmoItem(String id) { - ItemStack item = new ItemStack(Material.PAPER); - ItemMeta meta = item.getItemMeta(); - meta.getPersistentDataContainer().set(TEST_MMOITEM_ID, PersistentDataType.STRING, id); - item.setItemMeta(meta); - return item; - } - - /** - * Installs an MMOItems stub that builds a copy of each registered item; unknown - * IDs build nothing, as MMOItems does. Keys are {@code TYPE:ID}. - */ - protected static MMOItems stubMmoItems(Map items) { - MMOItems mmoItems = mock(MMOItems.class, RETURNS_DEEP_STUBS); - Map types = new HashMap<>(); - when(mmoItems.getTypes().get(anyString())).thenAnswer(call -> types.computeIfAbsent(call.getArgument(0), name -> { - Type type = mock(Type.class); - when(type.getId()).thenReturn(name); - return type; - })); - when(mmoItems.getItem(any(Type.class), anyString())).thenAnswer(call -> { - ItemStack item = items.get(((Type) call.getArgument(0)).getId() + ":" + call.getArgument(1)); - return item == null ? null : item.clone(); - }); - MMOItems.plugin = mmoItems; - return mmoItems; - } - - protected Location spawnPoint() { - return new Location(world, 8, 64, 8); - } - - protected T spawn(Class type) { - return world.spawn(spawnPoint(), type); - } - - protected PlayerMock player() { - PlayerMock player = server.addPlayer(); - player.teleport(spawnPoint()); - return player; - } - - protected static FarmAnimal own(PlayerMock owner, Entity entity) { - FarmAnimal animal = new FarmAnimal(entity.getUniqueId(), "Daisy", owner.getUniqueId(), entity); - OwnershipManager.registerOwnership(owner.getUniqueId(), animal); - return animal; - } - - /** Points the stored last day change at the given number of days ago. */ - protected void setLastDayChange(long daysAgo) { - YamlConfiguration numbers = YamlConfiguration.loadConfiguration(plugin.getNumbersFile()); - numbers.set("lastDayChangeDate", Instant.now().getEpochSecond() - daysAgo * 86_400 - 60); - DayChangeListener.setNumbersConfig(numbers); - } - - /** - * A chunk at the given coordinates holding the given entities. MockBukkit chunks - * contain no blocks, so this stands in when an area needs water. - */ - protected static Chunk fakeChunk(int x, int z, boolean water, Entity... entities) { - Chunk chunk = mock(Chunk.class); - boolean[] forced = {false}; - doAnswer(call -> forced[0] = call.getArgument(0)).when(chunk).setForceLoaded(anyBoolean()); - when(chunk.isForceLoaded()).thenAnswer(call -> forced[0]); - when(chunk.getX()).thenReturn(x); - when(chunk.getZ()).thenReturn(z); - when(chunk.contains(any(BlockData.class))).thenReturn(water); - when(chunk.getEntities()).thenReturn(entities); - return chunk; - } - - protected static ChunkArea stable(Chunk... chunks) { - ChunkArea area = new ChunkArea(new HashSet<>(Arrays.asList(chunks))); - PluginData.getChunkAreas().add(area); - return area; - } - - /** Runs the next server tick, where a scheduled day change runs, and checks it finished. */ - protected void awaitDayChange() { - server.getScheduler().performOneTick(); - assertFalse(DayChangeListener.isIsChangingDay(), "day change did not finish"); - } - - protected static void setStatic(Class owner, String name, Object value) throws ReflectiveOperationException { - Field field = owner.getDeclaredField(name); - field.setAccessible(true); - field.set(null, value); - } - - protected static void deleteRecursively(Path root) throws IOException { - if (!Files.exists(root, java.nio.file.LinkOption.NOFOLLOW_LINKS)) { - return; - } - try (Stream paths = Files.walk(root)) { - for (Path path : paths.sorted(Comparator.reverseOrder()).toList()) { - Files.delete(path); - } - } - } - - protected static Path playerFile(UUID player) { - return DATA_DIR.resolve("CharacterData").resolve(player + ".json"); - } - - /** Collects log records written to a logger while a test runs. */ - protected static final class LogCapture extends Handler implements AutoCloseable { - private final Logger logger; - public final List records = new ArrayList<>(); - - public LogCapture(Logger logger) { - this.logger = logger; - logger.addHandler(this); - } - - public boolean contains(String text) { - return records.stream().anyMatch(record -> record.getMessage().contains(text)); - } - - @Override - public synchronized void publish(LogRecord record) { - records.add(record); - } - - @Override - public void flush() { - } - - @Override - public void close() { - logger.removeHandler(this); - } - } -} diff --git a/src/test/java/net/tfminecraft/breedingbuddies/ReviewRegressionTest.java b/src/test/java/net/tfminecraft/breedingbuddies/ReviewRegressionTest.java deleted file mode 100644 index 3a45ac3..0000000 --- a/src/test/java/net/tfminecraft/breedingbuddies/ReviewRegressionTest.java +++ /dev/null @@ -1,112 +0,0 @@ -package net.tfminecraft.breedingbuddies; - -import net.tfminecraft.breedingbuddies.listeners.*; -import net.tfminecraft.tlibs.character.*; -import org.bukkit.Material; -import org.bukkit.configuration.file.YamlConfiguration; -import org.bukkit.entity.*; -import org.bukkit.event.entity.*; -import org.bukkit.event.player.PlayerInteractEntityEvent; -import org.bukkit.inventory.*; -import org.junit.jupiter.api.Test; -import java.nio.file.Files; -import java.nio.file.Path; -import java.util.*; -import static org.junit.jupiter.api.Assertions.*; -import static org.mockito.Mockito.*; - -class ReviewRegressionTest extends PluginTestBase { - private CharacterSession select(Player player) { - var session = new CharacterSession(new CharacterRef(player.getUniqueId(), UUID.randomUUID()), 7L); - assertNotEquals(session.ref().ownerId(), session.ref().characterId()); - sessions.put(player.getUniqueId(), session); - return session; - } - @Test void unavailableOwnershipBlocksConfiguredAnimalsButAllowsOtherEntitiesAndSpecies() { - var player = player(); var session = select(player); - Horse horse = spawn(Horse.class); own(player, horse); - assertEquals(Set.of(session.ref().characterId()), OwnershipManager.getAnimal(horse.getUniqueId()).getOwnerRefs().keySet()); - var config = new YamlConfiguration(); config.set("HORSE", Map.of()); new AnimalUtils(config); - PluginData.ownership().ready = false; - var listener = new MountListener(); - for (EntityType type : List.of(EntityType.VILLAGER, EntityType.ITEM_FRAME, EntityType.PLAYER, EntityType.ZOMBIE, EntityType.PIG)) { - Entity entity = mock(Entity.class); when(entity.getType()).thenReturn(type); when(entity.getUniqueId()).thenReturn(UUID.randomUUID()); - var event = new PlayerInteractEntityEvent(player, entity, EquipmentSlot.HAND); - listener.onInteract(event); assertFalse(event.isCancelled(), type.toString()); - } - Animals pig = mock(Animals.class); when(pig.getType()).thenReturn(EntityType.PIG); when(pig.getUniqueId()).thenReturn(UUID.randomUUID()); - var unrelatedMount = new EntityMountEvent(player, pig); listener.onMount(unrelatedMount); assertFalse(unrelatedMount.isCancelled()); - var farmClick = new PlayerInteractEntityEvent(player, horse, EquipmentSlot.HAND); - listener.onInteract(farmClick); assertTrue(farmClick.isCancelled()); - var farmMount = new EntityMountEvent(player, horse); listener.onMount(farmMount); assertTrue(farmMount.isCancelled()); - } - @Test void ordinaryFeedingDoesNotEmitTokenReadinessMessageButOwnershipTokensStillRefuse() { - var player = player(); select(player); Cow cow = spawn(Cow.class); - when(characters.currentSession(player)).thenReturn(Optional.empty()); PluginData.ownership().ready = false; - var listener = new TamingListener(plugin); - player.getInventory().setItemInMainHand(new ItemStack(Material.WHEAT)); - var feed = new PlayerInteractEntityEvent(player, cow, EquipmentSlot.HAND); listener.onInteract(feed); - assertFalse(feed.isCancelled()); assertNull(player.nextMessage()); - for (String id : List.of("TAMING_ITEM", "COOWNERSHIP_ITEM")) { - ItemStack token = mmoItem(id); player.getInventory().setItemInMainHand(token); - var event = new PlayerInteractEntityEvent(player, cow, EquipmentSlot.HAND); listener.onInteract(event); - assertTrue(event.isCancelled()); assertNotNull(player.nextMessage()); assertEquals(1, token.getAmount()); - } - assertTrue(PluginData.ownership().rows.isEmpty()); assertNull(cow.getCustomName()); - } - @Test void deadUnownedAnimalCannotReturnFromFanoutAfterRestartAndHeldAnimalRemains() { - var player = player(); select(player); Cow cow = spawn(Cow.class); - FarmAnimal animal = new FarmAnimal(cow.getUniqueId(), 777, cow); UnownedAnimalsManager.addUnownedAnimal(animal); - PluginData.saveAnimals(); assertTrue(PluginData.ownership().rows.containsKey(cow.getUniqueId())); - EntityDeathEvent death = mock(EntityDeathEvent.class); when(death.getEntity()).thenReturn(cow); - new AnimalDeathListener().onAnimalDeath(death); - assertFalse(PluginData.ownership().rows.containsKey(cow.getUniqueId())); - PluginData.loadAnimals(); assertFalse(PluginData.getUnownedAnimals().containsKey(cow.getUniqueId())); - assertFalse(PluginData.ownership().rows.containsKey(cow.getUniqueId())); - UnownedAnimalsManager.addUnownedAnimal(animal); PluginData.saveAnimals(); - PluginData.ownership().held.put(cow.getUniqueId(), "staff hold"); OwnershipManager.removeAnimal(cow.getUniqueId()); - assertSame(animal, UnownedAnimalsManager.getAnimal(cow.getUniqueId())); assertTrue(PluginData.ownership().rows.containsKey(cow.getUniqueId())); - PluginData.ownership().held.clear(); - } - @Test void unchangedOwnershipWithTokenEffectKeepsItsPublicationJournalThroughCompletionAndRestart() throws Exception { - var player = player(); var session = select(player); Cow cow = spawn(Cow.class); FarmAnimal animal = own(player, cow); - var store = PluginData.ownership(); var before = new LinkedHashMap<>(store.expected); String previous = store.lastJournalId; - ItemStack item = mmoItem("COOWNERSHIP_ITEM"); - var intent = AnimalTokenIntent.prepare(player, player, animal, item); - OwnershipManager.registerOwnership(player.getUniqueId(), animal); - assertEquals(before, store.expected); assertNotEquals(previous, store.lastJournalId); - intent.finish(player, player, animal, item, () -> {}); assertEquals(0, item.getAmount()); - var effect = AnimalFiles.object(Files.readString(store.files("ownership/token-effects").getFirst())); - assertEquals("done", effect.get("status")); assertEquals(store.lastJournalId, effect.get("ownerJournal")); - var ownerJournal = store.path("ownership/journals/" + effect.get("ownerJournal") + ".json"); - var publication=AnimalFiles.object(Files.readString(ownerJournal));assertEquals(true,publication.get("done")); - assertEquals(effect.get("id"),publication.get("tokenEffect"));assertEquals(effect.get("id"),store.lastJournalToken); - var receipts = store.files("ownership/journals"); PluginData.saveAnimals(); assertEquals(receipts, store.files("ownership/journals")); - PluginData.loadAnimals(); assertTrue(Files.exists(ownerJournal)); - assertTrue(OwnershipManager.isOwner(player.getUniqueId(), cow.getUniqueId())); - assertEquals(Set.of(session.ref().characterId()), OwnershipManager.getAnimal(cow.getUniqueId()).getOwnerRefs().keySet()); - } - @Test void removedConfiguredSpeciesKeepKnownRowsAndSpecificHoldsProtected() throws Exception { - var owner=player();select(owner);Horse horse=spawn(Horse.class);own(owner,horse); - var stranger=player();select(stranger);new AnimalUtils(new YamlConfiguration()); - var mounts=new MountListener(); - var denied=new PlayerInteractEntityEvent(stranger,horse,EquipmentSlot.HAND);mounts.onInteract(denied);assertTrue(denied.isCancelled()); - var ride=new EntityMountEvent(stranger,horse);mounts.onMount(ride);assertTrue(ride.isCancelled()); - var allowed=new EntityMountEvent(owner,horse);mounts.onMount(allowed);assertFalse(allowed.isCancelled()); - PluginData.ownership().held.put(horse.getUniqueId(),"Malformed stored owner");owner.addAttachment(plugin,"breedingbuddies.use",true); - var held=new PlayerInteractEntityEvent(owner,horse,EquipmentSlot.HAND);mounts.onInteract(held);assertTrue(held.isCancelled()); - PluginData.ownership().rows.remove(horse.getUniqueId()); - var opaque=new EntityMountEvent(owner,horse);mounts.onMount(opaque);assertTrue(opaque.isCancelled()); - Entity unrelated=mock(Entity.class);when(unrelated.getType()).thenReturn(EntityType.VILLAGER);when(unrelated.getUniqueId()).thenReturn(UUID.randomUUID()); - var ordinary=new PlayerInteractEntityEvent(owner,unrelated,EquipmentSlot.HAND);mounts.onInteract(ordinary);assertFalse(ordinary.isCancelled()); - PluginData.ownership().held.clear(); - } - @Test void plainSaveWithHeldTokenPrunesSnapshotButPreservesAuthoritativeTokenReceipt() throws Exception { - var player=player();select(player);Cow cow=spawn(Cow.class);var animal=own(player,cow);var store=PluginData.ownership(); - var token=AnimalTokenIntent.prepare(player,player,animal,mmoItem("TAMING_ITEM"));token.hold(animal); - Path receipt=store.files("ownership/token-effects").getFirst();String before=Files.readString(receipt);var retained=store.files("ownership/journals"); - PluginData.saveAnimals();assertTrue(store.ready);assertEquals(retained,store.files("ownership/journals")); - assertFalse(Files.exists(store.path("ownership/journals/"+store.lastJournalId+".json")));assertEquals(before,Files.readString(receipt)); - PluginData.loadAnimals();assertTrue(PluginData.isHeld(cow.getUniqueId()));assertEquals(before,Files.readString(receipt)); - } -} diff --git a/src/test/java/net/tfminecraft/breedingbuddies/UnavailableStoreScopeTest.java b/src/test/java/net/tfminecraft/breedingbuddies/UnavailableStoreScopeTest.java deleted file mode 100644 index f1dbc98..0000000 --- a/src/test/java/net/tfminecraft/breedingbuddies/UnavailableStoreScopeTest.java +++ /dev/null @@ -1,105 +0,0 @@ -package net.tfminecraft.breedingbuddies; - -import net.tfminecraft.tlibs.character.*; -import org.bukkit.configuration.file.YamlConfiguration; -import org.bukkit.entity.*; -import org.bukkit.event.player.PlayerInteractEntityEvent; -import org.bukkit.event.entity.EntityBreedEvent; -import org.bukkit.event.entity.EntityDeathEvent; -import org.bukkit.inventory.ItemStack; -import org.bukkit.Material; -import org.bukkit.inventory.EquipmentSlot; -import org.junit.jupiter.api.Test; -import org.junit.jupiter.api.io.TempDir; -import org.json.simple.*; -import java.nio.file.*; -import java.util.*; -import static org.junit.jupiter.api.Assertions.*; -import static org.mockito.Mockito.*; - -class UnavailableStoreScopeTest extends PluginTestBase { - @TempDir Path root; - private CharacterSession select(Player player) { - var session = new CharacterSession(new CharacterRef(player.getUniqueId(), UUID.randomUUID()), 7L); - sessions.put(player.getUniqueId(), session); - return session; - } - private void farmTypes(String... types) { var config = new YamlConfiguration(); for (String type : types) config.set(type, Map.of()); new AnimalUtils(config); } - private PlayerInteractEntityEvent click(Player player, Entity entity) { - var event = new PlayerInteractEntityEvent(player, entity, EquipmentSlot.HAND); server.getPluginManager().callEvent(event); return event; - } - @Test void unavailableStoreLeavesUnrelatedEntitiesAloneAndStillRefusesConfiguredAnimals() { - var owner = player(); select(owner); var other = player(); select(other); farmTypes("COW"); - Cow cow = spawn(Cow.class); own(owner, cow); - Villager villager = spawn(Villager.class); ArmorStand stand = spawn(ArmorStand.class); - PluginData.ownership().ready = false; - Entity frame=mock(Entity.class),furniture=mock(Entity.class); - when(frame.getType()).thenReturn(EntityType.ITEM_FRAME);when(frame.getUniqueId()).thenReturn(UUID.randomUUID()); - when(furniture.getType()).thenReturn(EntityType.INTERACTION);when(furniture.getUniqueId()).thenReturn(UUID.randomUUID()); - for (Entity entity : List.of(villager, stand, owner,frame,furniture)) { - var event = click(other, entity); - assertFalse(event.isCancelled(), entity.getType().toString()); assertNull(other.nextMessage(), entity.getType().toString()); - } - assertTrue(click(other, cow).isCancelled()); - } - @Test void removedSpeciesKnownRowStillRefusesForeignCare() { - var owner = player(); select(owner); var other = player(); select(other); farmTypes("COW"); - Cow cow = spawn(Cow.class); own(owner, cow); farmTypes("HORSE"); - assertTrue(click(other, cow).isCancelled()); assertNotNull(other.nextMessage()); - assertFalse(click(owner, cow).isCancelled()); - } - @Test void sessionStartKeepsUnrelatedVehiclesButLeavesAForeignKnownMount() { - var player = player(); var first = select(player); farmTypes("HORSE"); - for(Entity vehicle:List.of(spawn(Boat.class),spawn(Minecart.class))) { - assertTrue(vehicle.addPassenger(player));PluginData.ownership().ready=false; - server.getPluginManager().callEvent(new CharacterSessionStartEvent(player,first));assertEquals(vehicle,player.getVehicle());player.leaveVehicle(); - } - player.leaveVehicle(); PluginData.ownership().ready = true; - Horse horse = spawn(Horse.class); own(player, horse); assertTrue(horse.addPassenger(player)); - var second = select(player); - server.getPluginManager().callEvent(new CharacterSessionStartEvent(player, second)); - assertNull(player.getVehicle()); - } - @Test void breedingOnlyRequiresReadyOwnershipForConfiguredOrKnownParentsAndStillChecksNeutering() { - var player=player();select(player);farmTypes("HORSE"); - Pig mother=spawn(Pig.class),father=spawn(Pig.class),child=spawn(Pig.class);PluginData.ownership().ready=false; - when(characters.currentSession(player)).thenReturn(Optional.empty()); - var vanilla=new EntityBreedEvent(child,mother,father,player,new ItemStack(Material.CARROT),1);server.getPluginManager().callEvent(vanilla);assertFalse(vanilla.isCancelled()); - var configured=new EntityBreedEvent(spawn(Horse.class),spawn(Horse.class),spawn(Horse.class),player,new ItemStack(Material.WHEAT),1);server.getPluginManager().callEvent(configured);assertTrue(configured.isCancelled()); - PluginData.ownership().held.put(mother.getUniqueId(),"Known removed species needs review"); - var known=new EntityBreedEvent(child,mother,father,player,new ItemStack(Material.CARROT),1);server.getPluginManager().callEvent(known);assertTrue(known.isCancelled());PluginData.ownership().held.clear(); - farmTypes();Horse mare=spawn(Horse.class),stallion=spawn(Horse.class);MountUtils.setNeuteredFlag(mare,true);mare.setLoveModeTicks(600);stallion.setLoveModeTicks(600); - var neutered=new EntityBreedEvent(spawn(Horse.class),mare,stallion,player,new ItemStack(Material.WHEAT),1);server.getPluginManager().callEvent(neutered);assertTrue(neutered.isCancelled()); - assertEquals(0,mare.getLoveModeTicks());assertEquals(0,stallion.getLoveModeTicks()); - } - @Test void escapePatchesExistingRawRowsAndRetainsUnresolvedLegacySharesAcrossRestart() throws Exception { - var player=player();var session=select(player);Cow cow=spawn(Cow.class);UUID unresolved=UUID.randomUUID(); - JSONObject row=AnimalOwnershipStoreTest.row(cow.getUniqueId(),player.getUniqueId(),unresolved); - row.put("futureField","keep-me");row.put("future-null",null); - AnimalFiles.write(root.resolve("PlayerData/"+player.getUniqueId()+".json"),AnimalOwnershipStoreTest.doc(row).toJSONString()); - var store=new AnimalOwnershipStore(root);store.open(true,account->account.equals(player.getUniqueId())?Optional.of(session.ref()):Optional.empty()); - setStatic(PluginData.class,"ownership",store);PluginData.rebuildAnimals(); - JSONArray held= (JSONArray)AnimalFiles.copy(store.rows.get(cow.getUniqueId())).get("heldOwners");assertEquals(1,held.size()); - FarmAnimal live=OwnershipManager.getAnimal(cow.getUniqueId());live.setDaysOut(100);live.setStableNeeded(true); - net.tfminecraft.breedingbuddies.listeners.DayChangeListener.globalChangeDay(null,1);PluginData.saveAnimals(); - var reboot=new AnimalOwnershipStore(root);reboot.open(false,account->{fail("fixed migration must not resolve again");return Optional.empty();});setStatic(PluginData.class,"ownership",reboot);PluginData.rebuildAnimals(); - JSONObject after=reboot.rows.get(cow.getUniqueId());assertEquals("ESCAPED",after.get("state"));assertEquals("keep-me",after.get("futureField"));assertTrue(after.containsKey("future-null"));assertNull(after.get("future-null")); - assertEquals(held,after.get("heldOwners"));assertEquals(0,((JSONArray)after.get("characterOwners")).size()); - assertTrue(OwnershipManager.hasOwner(cow.getUniqueId()));assertTrue(OwnershipManager.hasHeldSlots(cow.getUniqueId()));assertFalse(OwnershipManager.isOwner(player.getUniqueId(),cow.getUniqueId())); - var escaped=UnownedAnimalsManager.getAnimal(cow.getUniqueId());assertNotNull(escaped);assertEquals(AnimalStates.ESCAPED,escaped.getState()); - var before=AnimalFiles.copy(after);assertThrows(IllegalStateException.class,()->OwnershipManager.registerOwnership(player.getUniqueId(),escaped));assertEquals(before,reboot.rows.get(cow.getUniqueId())); - assertEquals(row,((JSONArray)AnimalFiles.object(Files.readString(root.resolve("PlayerData/"+player.getUniqueId()+".json"))).get("animals")).getFirst()); - } - @Test void ordinaryEscapedAnimalCanBeClaimedAndConfirmedDeathStillForgetsItsRow() throws Exception { - var first=player();select(first);Cow cow=spawn(Cow.class);var animal=own(first,cow); - PluginData.ownership().rows.get(cow.getUniqueId()).put("future-null",null);PluginData.ownership().rows.get(cow.getUniqueId()).put("futureField","retained");PluginData.saveAnimals(); - animal.setDaysOut(100);animal.setStableNeeded(true);net.tfminecraft.breedingbuddies.listeners.DayChangeListener.globalChangeDay(null,1);PluginData.saveAnimals(); - assertFalse(OwnershipManager.hasOwner(cow.getUniqueId()));assertEquals(AnimalStates.ESCAPED,UnownedAnimalsManager.getAnimal(cow.getUniqueId()).getState()); - var recipient=player();select(recipient);ItemStack item=mmoItem("TAMING_ITEM");var meta=item.getItemMeta();meta.setDisplayName("Adopted");item.setItemMeta(meta);ItemUtils.storeDataInItem(item,"Adopted",plugin);recipient.getInventory().setItemInMainHand(item); - assertEquals(1,recipient.getInventory().getItemInMainHand().getAmount());click(recipient,cow);assertEquals(0,recipient.getInventory().getItemInMainHand().getAmount());assertTrue(OwnershipManager.isOwner(recipient.getUniqueId(),cow.getUniqueId())); - var after=PluginData.ownership().rows.get(cow.getUniqueId());assertEquals("retained",after.get("futureField"));assertTrue(after.containsKey("future-null"));assertNull(after.get("future-null")); - EntityDeathEvent death=mock(EntityDeathEvent.class);when(death.getEntity()).thenReturn(cow);when(death.getHandlers()).thenReturn(EntityDeathEvent.getHandlerList());server.getPluginManager().callEvent(death); - assertFalse(PluginData.ownership().rows.containsKey(cow.getUniqueId()));assertFalse(PluginData.getUnownedAnimals().containsKey(cow.getUniqueId())); - PluginData.loadAnimals();assertFalse(PluginData.ownership().rows.containsKey(cow.getUniqueId()));assertFalse(OwnershipManager.hasOwner(cow.getUniqueId())); - } -} diff --git a/src/test/java/net/tfminecraft/breedingbuddies/husbandry/CookingApiStub.java b/src/test/java/net/tfminecraft/breedingbuddies/husbandry/CookingApiStub.java new file mode 100644 index 0000000..1b967b8 --- /dev/null +++ b/src/test/java/net/tfminecraft/breedingbuddies/husbandry/CookingApiStub.java @@ -0,0 +1,50 @@ +package net.tfminecraft.breedingbuddies.husbandry; + +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.anyInt; +import static org.mockito.Mockito.mockStatic; + +import java.util.Map; +import java.util.OptionalInt; + +import org.bukkit.Material; +import org.bukkit.NamespacedKey; +import org.bukkit.entity.Player; +import org.bukkit.inventory.ItemStack; +import org.bukkit.inventory.meta.ItemMeta; +import org.bukkit.persistence.PersistentDataType; +import org.mockito.MockedStatic; + +import net.tfminecraft.cooking.api.CookingFoodApi; + +/** Stands in for Cooking's food API so tests never initialise Cooking internals. */ +final class CookingApiStub implements AutoCloseable { + /** Where these tests store an item's stars, as Cooking does. */ + static final NamespacedKey QUALITY = new NamespacedKey("cooking", "quality"); + + final MockedStatic api = mockStatic(CookingFoodApi.class); + + CookingApiStub() { + api.when(() -> CookingFoodApi.qualityOf(any())).thenAnswer(call -> stars(call.getArgument(0))); + api.when(() -> CookingFoodApi.addToInventory(any(), any())).thenAnswer(call -> { + Player player = call.getArgument(0); + Map left = player.getInventory().addItem((ItemStack) call.getArgument(1)); + return left.isEmpty() ? null : left.values().iterator().next(); + }); + api.when(() -> CookingFoodApi.buildMilk(any(), anyInt())).thenAnswer(call -> new ItemStack(Material.MILK_BUCKET)); + } + + private static OptionalInt stars(ItemStack stack) { + if (stack == null || stack.getType().isAir() || !stack.hasItemMeta()) { + return OptionalInt.empty(); + } + ItemMeta meta = stack.getItemMeta(); + Integer stored = meta == null ? null : meta.getPersistentDataContainer().get(QUALITY, PersistentDataType.INTEGER); + return stored == null ? OptionalInt.empty() : OptionalInt.of(Math.max(1, Math.min(5, stored))); + } + + @Override + public void close() { + api.close(); + } +} diff --git a/src/test/java/net/tfminecraft/breedingbuddies/husbandry/HusbandryCharacterAuthorityTest.java b/src/test/java/net/tfminecraft/breedingbuddies/husbandry/HusbandryCharacterAuthorityTest.java new file mode 100644 index 0000000..d248192 --- /dev/null +++ b/src/test/java/net/tfminecraft/breedingbuddies/husbandry/HusbandryCharacterAuthorityTest.java @@ -0,0 +1,242 @@ +package net.tfminecraft.breedingbuddies.husbandry; + +import static org.junit.jupiter.api.Assertions.*; +import static org.mockito.Mockito.*; +import static org.mockito.ArgumentMatchers.*; +import java.nio.file.Path; +import java.time.Instant; +import java.util.*; +import java.util.logging.Logger; +import org.bukkit.Material; +import org.bukkit.entity.*; +import org.bukkit.event.entity.*; +import org.bukkit.event.inventory.*; +import org.bukkit.event.player.PlayerInteractEntityEvent; +import org.bukkit.inventory.*; +import org.bukkit.plugin.ServicePriority; +import org.junit.jupiter.api.*; +import org.junit.jupiter.api.io.TempDir; +import net.tfminecraft.tlibs.character.*; +import net.tfminecraft.tlibs.character.estate.*; +import static net.tfminecraft.breedingbuddies.husbandry.HusbandryOwnershipStore.*; + +class HusbandryCharacterAuthorityTest { + @TempDir Path directory; + HusbandryRuntimeCoverageTest.Farm farm; + @BeforeEach void open() throws Exception { farm = new HusbandryRuntimeCoverageTest.Farm(directory); } + @AfterEach void close() throws Exception { farm.close(); } + + @Test void switchingDoesNotGrantOwnersMenusTameOrMountRightsAndOutgoingSessionDismounts() throws Exception { + var f = farm; var horse = f.spawn(Horse.class); var animal = f.record(horse); f.own(animal, f.player); + CharacterSession outgoing = HusbandryIdentity.session(f.player).orElseThrow(); + HusbandryInspectGui.open(f.player, horse, animal); + var holder = (HusbandryInspectHolder)f.player.getOpenInventory().getTopInventory().getHolder(); + assertTrue(holder.isCurrent(f.player)); assertTrue(holder.belongsTo(outgoing)); + assertFalse(HusbandryIdentity.current(null, f.player)); assertFalse(HusbandryIdentity.current(outgoing, null)); + assertFalse(HusbandryIdentity.current(outgoing, f.neighbour)); + horse.addPassenger(f.player); assertEquals(horse, f.player.getVehicle()); + f.characters.set(f.player, null); + new HusbandryMountListener().onCharacterEnd(new CharacterSessionEndEvent(f.player, outgoing, CharacterSessionEndEvent.Reason.SWITCH)); + assertNull(f.player.getVehicle()); assertNull(f.player.getOpenInventory().getTopInventory()); + f.characters.activate(f.player, UUID.randomUUID()); + assertFalse(HusbandryOwnershipService.isOwner(f.player, animal.uuid())); assertFalse(holder.isCurrent(f.player)); + var mount = new EntityMountEvent(f.player, horse); new HusbandryMountListener().onMount(mount); assertTrue(mount.isCancelled()); + var tame = new EntityTameEvent(horse, f.player); new HusbandryMountListener().protectTame(tame); assertTrue(tame.isCancelled()); + f.player.openInventory(holder.getInventory()); + var click = new InventoryClickEvent(f.player.getOpenInventory(), InventoryType.SlotType.CONTAINER, 8, ClickType.LEFT, InventoryAction.PICKUP_ALL); + new HusbandryInspectListener().onClick(click); assertTrue(click.isCancelled()); + assertEquals(1, f.repository.listOwners(animal.uuid()).size()); + var other = HusbandryIdentity.session(f.player).orElseThrow(); + new HusbandryMountListener().onCharacterEnd(new CharacterSessionEndEvent(f.neighbour, other, CharacterSessionEndEvent.Reason.QUIT)); + new HusbandryMountListener().onCharacterEnd(new CharacterSessionEndEvent(f.player, other, CharacterSessionEndEvent.Reason.QUIT)); + new HusbandryMountListener().protectTame(new EntityTameEvent(horse, f.server.getOfflinePlayer(UUID.randomUUID()))); + f.player.setOp(true); var staffTame = new EntityTameEvent(horse, f.player); new HusbandryMountListener().protectTame(staffTame); assertFalse(staffTame.isCancelled()); + } + + @Test void resolvedCoownerRetainsRightsHeldSlotBlocksTokensAndDistinctAccountCapCannotBeMultiplied() throws Exception { + var f = farm; var animal = f.record(f.spawn(Cow.class)); + update(f.repository.ownershipDatabase().getConnection(), "INSERT INTO owners VALUES (?, ?, 'owner')", animal.uuid().toString(), f.player.getUniqueId().toString()); + animal.setState(HusbandryAnimalState.OWNED); f.repository.upsertAnimal(animal); + f.repository.upsertOwner(new HusbandryOwner(animal.uuid(), f.neighbour.getUniqueId(), f.neighbour.getUniqueId(), "coowner")); + assertTrue(HusbandryOwnershipService.isOwner(f.neighbour, animal.uuid())); + assertEquals(HusbandryOwnershipService.Result.HELD_OWNER, HusbandryOwnershipService.addCoOwner(f.player, f.player, animal)); + assertEquals(HusbandryOwnershipService.Result.OK, HusbandryOwnershipService.removeSelf(f.neighbour, animal)); + assertTrue(HusbandryOwnershipService.hasAnyOwner(animal.uuid())); + assertEquals(HusbandryAnimalState.OWNED, animal.state()); // the held slot still owns the animal + f.own(animal, f.player); var a = HusbandryIdentity.session(f.player).orElseThrow(); + var extra = f.ownedOffline("Second"); assertFalse(HusbandryOwnershipService.canAccept(f.player)); + UUID characterB = UUID.randomUUID(); f.characters.activate(f.player, characterB); + assertFalse(HusbandryOwnershipService.canAccept(f.player)); + f.repository.upsertOwner(new HusbandryOwner(animal.uuid(), f.neighbour.getUniqueId(), f.neighbour.getUniqueId(), "coowner")); + // Explicit resolved grant of another character's slot on the SAME animal does not consume capacity twice. + assertEquals(HusbandryOwnershipService.Result.OK, HusbandryOwnershipService.addCoOwner(f.neighbour, f.player, animal)); + assertTrue(HusbandryOwnershipService.isOwner(f.player, animal.uuid())); assertEquals(2, f.repository.countForPlayer(f.player.getUniqueId())); + assertEquals(1, f.repository.listForCharacter(new CharacterRef(f.player.getUniqueId(), characterB)).size()); + f.characters.set(f.player, null); + assertFalse(HusbandryOwnershipService.canAccept(f.player)); + assertEquals(HusbandryOwnershipService.Result.CHARACTER_REQUIRED, HusbandryOwnershipService.claimOwner(f.player, animal, null, "Late")); + assertEquals(HusbandryOwnershipService.Result.CHARACTER_REQUIRED, HusbandryOwnershipService.addCoOwner(f.neighbour, f.player, animal)); + assertEquals(HusbandryOwnershipService.Result.CHARACTER_REQUIRED, HusbandryOwnershipService.addCoOwner(f.player, f.neighbour, animal)); + var noSessionMount = new EntityMountEvent(f.player, f.spawn(Horse.class)); new HusbandryMountListener().onMount(noSessionMount); assertTrue(noSessionMount.isCancelled()); + } + + @Test void strictEstateFacadeRejectsRevivalRedeathOwnerMismatchAndNewRevision() throws Exception { + var f = farm; var source = HusbandryIdentity.session(f.player).orElseThrow().ref(); var recipient = HusbandryIdentity.session(f.neighbour).orElseThrow().ref(); + var animal = f.record(f.spawn(Cow.class)); f.own(animal, f.player); + f.own(f.record(f.spawn(Cow.class)), f.neighbour); + f.config.set("max-animals", 1); f.reload(); + assertFalse(HusbandryOwnershipService.canAccept(f.neighbour)); + UUID death = UUID.randomUUID(); var decision = new EstateDecision(death, source, Optional.of(Instant.EPOCH), Instant.EPOCH, + Optional.of(recipient), Optional.empty(), Optional.empty(), Recipient.FROZEN, Recipient.character(recipient), Recipient.FROZEN, Recipient.SENDER, 1); + var service = mock(EstateService.class); f.server.getServicesManager().register(EstateService.class, service, f.plugin, ServicePriority.Normal); + when(service.decision(death)).thenReturn(Optional.of(decision)); when(service.stateOf(source.characterId())).thenReturn(Optional.of(EstateState.DECIDED)); + when(f.characters.provider.deathId(source.characterId())).thenReturn(Optional.of(death)); + when(f.characters.provider.info(source.characterId())).thenReturn(Optional.of(new CharacterInfo(source, "A", CharacterStatus.ALIVE, null, Set.of(), 0, null))); + var estate = new HusbandryEstate(f.repository, Logger.getAnonymousLogger()); assertEquals(Outcome.Kind.RETRY, estate.settle(decision).kind()); + when(f.characters.provider.info(source.characterId())).thenReturn(Optional.of(new CharacterInfo(source, "A", CharacterStatus.DEAD, null, Set.of(), 0, null))); + when(f.characters.provider.deathId(source.characterId())).thenReturn(Optional.of(UUID.randomUUID())); assertEquals(Outcome.Kind.RETRY, estate.settle(decision).kind()); + when(f.characters.provider.deathId(source.characterId())).thenReturn(Optional.of(death)); when(service.decision(death)).thenReturn(Optional.empty()); assertEquals(Outcome.Kind.RETRY, estate.settle(decision).kind()); + when(service.decision(death)).thenReturn(Optional.of(decision)); + when(f.characters.provider.info(source.characterId())).thenReturn(Optional.of(new CharacterInfo(new CharacterRef(recipient.ownerId(), source.characterId()), "Conflict", CharacterStatus.DEAD, null, Set.of(), 0, null))); + assertEquals(Outcome.Kind.RETRY, estate.settle(decision).kind()); + when(f.characters.provider.info(source.characterId())).thenReturn(Optional.of(new CharacterInfo(source, "A", CharacterStatus.DEAD, null, Set.of(), 0, null))); + assertEquals(Outcome.Kind.DONE, estate.settle(decision).kind()); assertEquals(recipient, f.repository.listOwners(animal.uuid()).getFirst().ref()); + assertEquals(2, f.repository.countForPlayer(recipient.ownerId())); + assertTrue(f.repository.exists(animal.uuid())); assertFalse(HusbandryOwnershipService.canAccept(f.neighbour)); + when(service.decided()).thenReturn(List.of(decision)); estate.reconcile(); verify(service).report(eq(death), eq("cooking-animals"), eq(1L), any()); + } + + @Test void migrationReconcileAndCapabilityProbeHandleAbsentOldAndFailingProviders() throws Exception { + var f = farm; assertTrue(HusbandryIdentity.supports(HusbandryIdentity.class.getClassLoader(), true)); + ClassLoader absent = new ClassLoader(HusbandryIdentity.class.getClassLoader()) { + @Override protected Class loadClass(String name, boolean resolve) throws ClassNotFoundException { + if (name.equals("net.tfminecraft.tlibs.character.Characters")) throw new ClassNotFoundException("Missing old API"); + return super.loadClass(name, resolve); + } + }; + assertFalse(HusbandryIdentity.supports(absent, false)); + ClassLoader old = new ClassLoader(HusbandryIdentity.class.getClassLoader()) { + @Override protected Class loadClass(String name, boolean resolve) throws ClassNotFoundException { + if (name.startsWith("net.tfminecraft.tlibs.character.estate.")) throw new ClassNotFoundException("Old TLibs"); + return super.loadClass(name, resolve); + } + }; + assertFalse(HusbandryIdentity.supports(old, true)); assertTrue(HusbandryIdentity.supports(old, false)); + assertEquals(0, HusbandryIdentity.session(null).stream().count()); + HusbandryIdentity.start(f.plugin, null); + var plugin = org.mockbukkit.mockbukkit.MockBukkit.createMockPlugin("AnimalEstateFixture"); + HusbandryIdentity.start(plugin, f.repository); + f.server.getScheduler().performTicks(2); + assertEquals("cooking-animals", f.server.getServicesManager().load(EstateParticipant.class).id()); + when(f.characters.provider.legacyHeirsReady()).thenReturn(true); + when(f.characters.provider.legacyHeir(any())).thenReturn(Optional.empty()); + var held = f.ownedOffline("Unknown"); update(f.repository.ownershipDatabase().getConnection(), "INSERT INTO owners VALUES (?, ?, 'owner')", held.uuid().toString(), UUID.randomUUID().toString()); + HusbandryMigration.reconcile(plugin, f.repository); assertEquals(1, f.repository.heldOwners()); + doThrow(new IllegalStateException("Filesystem unavailable")).when(f.repository).migrateOwners(any(), any()); + assertDoesNotThrow(() -> HusbandryMigration.reconcile(plugin, f.repository)); + } + + @Test void noSessionTamingTokensAndRosterFailClosedWithoutConsumingItems() throws Exception { + var f = farm; var cow = f.spawn(Cow.class); var animal = f.record(cow); + f.player.getInventory().setItemInMainHand(new ItemStack(Material.NAME_TAG, 2)); + HusbandryItems.setTameName(f.player.getInventory().getItemInMainHand(), "A's animal"); + f.characters.set(f.player, null); + var listener = new HusbandryTamingListener(); + listener.onInteractEntity(new PlayerInteractEntityEvent(f.player, cow, EquipmentSlot.HAND)); + assertEquals(2, f.player.getInventory().getItemInMainHand().getAmount()); + assertFalse(f.repository.hasAnyOwner(animal.uuid())); + new HusbandryAnimalsCommand().onCommand(f.player, mock(org.bukkit.command.Command.class), "animals", new String[0]); + update(f.repository.ownershipDatabase().getConnection(), "INSERT INTO owners VALUES (?, ?, 'owner')", animal.uuid().toString(), f.player.getUniqueId().toString()); + var token = new ItemStack(Material.PAPER, 2); HusbandryItems.setLinkedAnimal(token, animal.uuid().toString(), "Animal"); + f.player.getInventory().setItemInMainHand(token); + listener.onInteractEntity(new PlayerInteractEntityEvent(f.player, f.neighbour, EquipmentSlot.HAND)); + assertEquals(2, token.getAmount()); + f.characters.activate(f.player, f.player.getUniqueId()); + listener.onInteractEntity(new PlayerInteractEntityEvent(f.player, f.neighbour, EquipmentSlot.HAND)); + assertEquals(2, token.getAmount()); assertTrue(f.repository.hasHeldOwner(animal.uuid())); + } + + @Test void oldApiStartDefersMigrationAndEstateRegistration() { + var f = farm; var plugin = org.mockbukkit.mockbukkit.MockBukkit.createMockPlugin("OldAnimalApi"); + try (var bridge = mockStatic(HusbandryIdentity.class, CALLS_REAL_METHODS)) { + bridge.when(() -> HusbandryIdentity.supports(any(), eq(false))).thenReturn(false); + HusbandryIdentity.start(plugin, f.repository); + assertNull(f.server.getServicesManager().load(EstateParticipant.class)); + bridge.when(() -> HusbandryIdentity.supports(any(), eq(false))).thenReturn(true); + bridge.when(() -> HusbandryIdentity.supports(any(), eq(true))).thenReturn(false); + HusbandryIdentity.start(plugin, f.repository); + assertNull(f.server.getServicesManager().load(EstateParticipant.class)); + } + } + + @Test void stalePreparedEstateHoldsOwnershipWritersWithoutRevokingResolvedCoownerRights() throws Exception { + var f = farm; var cow = f.spawn(Cow.class); var animal = f.record(cow); f.own(animal, f.player); + f.repository.upsertOwner(new HusbandryOwner(animal.uuid(), f.neighbour.getUniqueId(), f.neighbour.getUniqueId(), "coowner")); + update(f.repository.ownershipDatabase().getConnection(), "INSERT INTO husbandry_estate_intents VALUES (?,1,'old decision',?,?,?,?,'prepared')", + UUID.randomUUID().toString(), f.player.getUniqueId().toString(), f.player.getUniqueId().toString(), + f.neighbour.getUniqueId().toString(), f.neighbour.getUniqueId().toString()); + assertTrue(f.repository.isEstateHeld(animal.uuid())); assertFalse(f.repository.isEstateHeld(null)); + assertTrue(HusbandryOwnershipService.isOwner(f.neighbour, animal.uuid())); + assertEquals(HusbandryOwnershipService.Result.HELD_OWNER, HusbandryOwnershipService.claimOwner(f.player, animal, cow, "Not rewritten")); + assertEquals(HusbandryOwnershipService.Result.HELD_OWNER, HusbandryOwnershipService.addCoOwner(f.player, f.neighbour, animal)); + assertEquals(HusbandryOwnershipService.Result.HELD_OWNER, HusbandryOwnershipService.removeSelf(f.neighbour, animal)); + var owner = new HusbandryOwner(animal.uuid(), f.player.getUniqueId(), f.player.getUniqueId(), "owner"); + assertThrows(IllegalStateException.class, () -> f.repository.upsertOwner(owner)); + assertThrows(IllegalStateException.class, () -> f.repository.saveAnimalAndOwner(animal, owner)); + assertThrows(IllegalStateException.class, () -> f.repository.deleteCharacterOwner(animal.uuid(), owner.characterUuid())); + f.player.getInventory().setItemInMainHand(new ItemStack(Material.NAME_TAG,2)); HusbandryItems.setTameName(f.player.getInventory().getItemInMainHand(), "Held"); + new HusbandryTamingListener().onInteractEntity(new PlayerInteractEntityEvent(f.player,cow,EquipmentSlot.HAND)); + assertEquals(2,f.player.getInventory().getItemInMainHand().getAmount()); + assertEquals(2,f.repository.listOwners(animal.uuid()).size()); + update(f.repository.ownershipDatabase().getConnection(), "INSERT INTO owners VALUES (?,?,'owner')", animal.uuid().toString(), UUID.randomUUID().toString()); + HusbandryInspectGui.open(f.neighbour,cow,animal); + assertTrue(f.neighbour.getOpenInventory().getTopInventory().getItem(HusbandryInspectGui.OWNERS_SLOT).getItemMeta().lore().stream().map(line->net.kyori.adventure.text.serializer.plain.PlainTextComponentSerializer.plainText().serialize(line)).anyMatch(line->line.contains("Unresolved"))); + } + + @Test void vanillaTameableInteractionsCannotUseAccountOwnerWhilePublicLivestockInteractionStays() { + var f = farm; var horse = f.spawn(Horse.class); var animal = f.record(horse); f.own(animal, f.player); + var listener = new HusbandryMountListener(); + var owner = new PlayerInteractEntityEvent(f.player,horse,EquipmentSlot.HAND); listener.protectInteract(owner); assertFalse(owner.isCancelled()); + f.characters.activate(f.player,UUID.randomUUID()); + var alternate = new PlayerInteractEntityEvent(f.player,horse,EquipmentSlot.HAND); listener.protectInteract(alternate); assertTrue(alternate.isCancelled()); + f.player.setOp(true); var staff = new PlayerInteractEntityEvent(f.player,horse,EquipmentSlot.HAND); listener.protectInteract(staff); assertFalse(staff.isCancelled()); + f.player.setOp(false); var wild = new PlayerInteractEntityEvent(f.player,f.spawn(Horse.class),EquipmentSlot.HAND); listener.protectInteract(wild); assertFalse(wild.isCancelled()); + var cow=f.spawn(Cow.class); var livestock=f.record(cow); f.own(livestock,f.neighbour); + var publicHarvest=new PlayerInteractEntityEvent(f.player,cow,EquipmentSlot.HAND);listener.protectInteract(publicHarvest);assertFalse(publicHarvest.isCancelled()); + } + + @Test void providerReturnedStaleAndWrongAccountSessionsCannotGrantActionOrMenuAuthority() throws Exception { + var f=farm;var horse=f.spawn(Horse.class);var animal=f.record(horse);f.own(animal,f.player); + var before=HusbandryIdentity.session(f.player).orElseThrow(); + when(f.characters.provider.currentSession(f.player)).thenReturn(Optional.of(before)); + when(f.characters.provider.isCurrent(before)).thenReturn(false); + assertTrue(HusbandryIdentity.session(f.player).isEmpty());assertFalse(HusbandryOwnershipService.isOwner(f.player,animal.uuid())); + assertEquals(HusbandryOwnershipService.Result.CHARACTER_REQUIRED,HusbandryOwnershipService.claimOwner(f.player,animal,horse,"Stale")); + HusbandryInspectGui.open(f.player,horse,animal); + var holder=(HusbandryInspectHolder)f.player.getOpenInventory().getTopInventory().getHolder();assertFalse(holder.isCurrent(f.player)); + var click=new InventoryClickEvent(f.player.getOpenInventory(),InventoryType.SlotType.CONTAINER,35,ClickType.LEFT,InventoryAction.PICKUP_ALL); + new HusbandryInspectListener().onClick(click);assertEquals(1,f.repository.listOwners(animal.uuid()).size()); + var staleMount=new EntityMountEvent(f.player,horse);new HusbandryMountListener().onMount(staleMount);assertTrue(staleMount.isCancelled()); + var wrong=HusbandryIdentity.session(f.neighbour).orElseThrow(); + when(f.characters.provider.currentSession(f.player)).thenReturn(Optional.of(wrong)); + assertTrue(f.characters.provider.isCurrent(wrong));assertTrue(HusbandryIdentity.session(f.player).isEmpty()); + assertFalse(HusbandryOwnershipService.canAccept(f.player));assertFalse(HusbandryOwnershipService.isOwner(f.player,animal.uuid())); + assertEquals(HusbandryOwnershipService.Result.CHARACTER_REQUIRED,HusbandryOwnershipService.claimOwner(f.player,animal,horse,"Wrong owner")); + var wrongMount=new EntityMountEvent(f.player,horse);new HusbandryMountListener().onMount(wrongMount);assertTrue(wrongMount.isCancelled()); + new HusbandryAnimalsCommand().onCommand(f.player,null,"animals",new String[0]); + assertEquals(before.ref(),f.repository.listOwners(animal.uuid()).getFirst().ref()); + } + + @Test void sessionStartEjectsOnlyUnauthorizedCurrentRiderAndOwnerTamingStaysAllowed() throws Exception { + var f = farm; var horse = f.spawn(Horse.class); var animal = f.record(horse); f.own(animal, f.player); + horse.addPassenger(f.player); var owner = HusbandryIdentity.session(f.player).orElseThrow(); + var listener = new HusbandryMountListener(); listener.onCharacterStart(new CharacterSessionStartEvent(f.player, owner)); assertEquals(horse, f.player.getVehicle()); + var tame = new EntityTameEvent(horse, f.player); listener.protectTame(tame); assertFalse(tame.isCancelled()); + f.characters.activate(f.player, UUID.randomUUID()); listener.onCharacterStart(new CharacterSessionStartEvent(f.player, owner)); assertEquals(horse, f.player.getVehicle()); + listener.onCharacterStart(new CharacterSessionStartEvent(f.player, HusbandryIdentity.session(f.player).orElseThrow())); assertNull(f.player.getVehicle()); + listener.onCharacterStart(new CharacterSessionStartEvent(f.player, HusbandryIdentity.session(f.player).orElseThrow())); + horse.addPassenger(f.player); f.player.setOp(true); listener.onCharacterStart(new CharacterSessionStartEvent(f.player, HusbandryIdentity.session(f.player).orElseThrow())); assertEquals(horse, f.player.getVehicle()); + var unowned = f.spawn(Horse.class); listener.protectTame(new EntityTameEvent(unowned, f.neighbour)); + f.player.leaveVehicle(); unowned.addPassenger(f.player); listener.onCharacterStart(new CharacterSessionStartEvent(f.player, HusbandryIdentity.session(f.player).orElseThrow())); assertEquals(unowned, f.player.getVehicle()); + } +} diff --git a/src/test/java/net/tfminecraft/breedingbuddies/husbandry/HusbandryCoOwnTokenTest.java b/src/test/java/net/tfminecraft/breedingbuddies/husbandry/HusbandryCoOwnTokenTest.java new file mode 100644 index 0000000..cba2cdc --- /dev/null +++ b/src/test/java/net/tfminecraft/breedingbuddies/husbandry/HusbandryCoOwnTokenTest.java @@ -0,0 +1,43 @@ +package net.tfminecraft.breedingbuddies.husbandry; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertNull; +import static org.junit.jupiter.api.Assertions.assertTrue; + +import java.util.UUID; + +import org.junit.jupiter.api.Test; + +class HusbandryCoOwnTokenTest { + + private static final UUID ANIMAL = UUID.randomUUID(); + + @Test + void anotherPlayerAcceptsATokenLinkedToAnOwnedAnimal() { + assertTrue(HusbandryCoOwnToken.accepts(ANIMAL, true, false)); + } + + @Test + void ownersKeepLinkingAndGivingInsteadOfAccepting() { + assertFalse(HusbandryCoOwnToken.accepts(ANIMAL, true, true)); + } + + @Test + void tokensForAnimalsWithoutOwnersAreNotAccepted() { + assertFalse(HusbandryCoOwnToken.accepts(ANIMAL, false, false)); + } + + @Test + void unlinkedTokensAreNotAccepted() { + assertFalse(HusbandryCoOwnToken.accepts(null, true, false)); + } + + @Test + void parsesOnlyValidLinks() { + assertEquals(ANIMAL, HusbandryCoOwnToken.parse(ANIMAL.toString())); + assertNull(HusbandryCoOwnToken.parse(null)); + assertNull(HusbandryCoOwnToken.parse(" ")); + assertNull(HusbandryCoOwnToken.parse("not-a-uuid")); + } +} diff --git a/src/test/java/net/tfminecraft/breedingbuddies/husbandry/HusbandryCompanionPetsTest.java b/src/test/java/net/tfminecraft/breedingbuddies/husbandry/HusbandryCompanionPetsTest.java new file mode 100644 index 0000000..d3f1e05 --- /dev/null +++ b/src/test/java/net/tfminecraft/breedingbuddies/husbandry/HusbandryCompanionPetsTest.java @@ -0,0 +1,106 @@ +package net.tfminecraft.breedingbuddies.husbandry; + +import static org.junit.jupiter.api.Assertions.*; +import static org.mockito.Mockito.mock; + +import java.io.File; +import java.nio.file.Path; +import java.util.ArrayList; +import java.util.List; +import java.util.UUID; + +import org.bukkit.Location; +import org.bukkit.Material; +import org.bukkit.NamespacedKey; +import org.bukkit.damage.DamageSource; +import org.bukkit.entity.EntityType; +import org.bukkit.entity.Tameable; +import org.bukkit.event.entity.EntityDeathEvent; +import org.bukkit.event.world.EntitiesLoadEvent; +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.io.TempDir; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.EnumSource; +import org.mockbukkit.mockbukkit.entity.CatMock; +import org.mockbukkit.mockbukkit.entity.WolfMock; + +import net.kyori.adventure.text.Component; + +class HusbandryCompanionPetsTest { + @TempDir Path directory; + private HusbandryRuntimeCoverageTest.Farm farm; + + @BeforeEach void open() throws Exception { + farm = new HusbandryRuntimeCoverageTest.Farm(directory); + new HusbandryLoader().load(new File("src/main/resources/config.yml")); + } + + @AfterEach void close() throws Exception { farm.close(); } + + @ParameterizedTest + @EnumSource(value = EntityType.class, names = {"WOLF", "CAT"}) + void companionPetsSurviveChunkLoadsAndStartupWhileWildAnimalsAreStillRemoved(EntityType type) { + Tameable pet = spawn(type); + pet.setTamed(true); + pet.setOwner(farm.player); + pet.setPersistent(true); + pet.customName(Component.text("Companion")); + NamespacedKey petKey = new NamespacedKey("companionpets", "pet"); + String petId = UUID.randomUUID().toString(); + // CompanionPets stores its own pet ID here, independently of husbandry ownership. + pet.getPersistentDataContainer().set(petKey, PersistentDataType.STRING, petId); + Tameable wild = spawn(type); + + new HusbandryLifecycleListener().onEntitiesLoad( + new EntitiesLoadEvent(pet.getChunk(), List.of(pet, wild))); + + assertTrue(pet.isValid(), "Chunk cleanup must leave CompanionPets bodies intact"); + assertFalse(wild.isValid(), "Untagged wild animals still follow remove-unowned"); + + Tameable otherWild = spawn(type); + HusbandryLifecycleListener.resumeLoadedWorlds(); + + assertTrue(pet.isValid(), "The startup scan must also leave CompanionPets bodies intact"); + assertFalse(otherWild.isValid()); + assertEquals(Component.text("Companion"), pet.customName()); + assertEquals(farm.player.getUniqueId(), pet.getOwner().getUniqueId()); + assertEquals(petId, pet.getPersistentDataContainer().get(petKey, PersistentDataType.STRING)); + assertFalse(HusbandryEntities.isManaged(pet)); + assertFalse(farm.repository.exists(pet.getUniqueId())); + } + + @ParameterizedTest + @EnumSource(value = EntityType.class, names = {"WOLF", "CAT"}) + void companionPetsKeepTheirDropsWhileWildAnimalsLoseThem(EntityType type) { + Tameable pet = spawn(type); + pet.getPersistentDataContainer().set( + new NamespacedKey("companionpets", "pet"), PersistentDataType.STRING, UUID.randomUUID().toString()); + Tameable wild = spawn(type); + HusbandryDeathListener listener = new HusbandryDeathListener(); + + EntityDeathEvent petDeath = death(pet); + listener.onDeath(petDeath); + EntityDeathEvent wildDeath = death(wild); + listener.onDeath(wildDeath); + + assertEquals(List.of(new ItemStack(Material.STRING)), petDeath.getDrops()); + assertEquals(5, petDeath.getDroppedExp()); + assertTrue(wildDeath.getDrops().isEmpty(), "Untagged wild animals still follow remove-unowned"); + assertEquals(0, wildDeath.getDroppedExp()); + } + + private static EntityDeathEvent death(Tameable entity) { + return new EntityDeathEvent(entity, mock(DamageSource.class), + new ArrayList<>(List.of(new ItemStack(Material.STRING))), 5); + } + + private Tameable spawn(EntityType type) { + Location at = new Location(farm.world, 0, 65, 0); + return type == EntityType.WOLF + ? farm.world.spawn(at, WolfMock.class) + : farm.world.spawn(at, CatMock.class); + } +} diff --git a/src/test/java/net/tfminecraft/breedingbuddies/husbandry/HusbandryCookingKeysTest.java b/src/test/java/net/tfminecraft/breedingbuddies/husbandry/HusbandryCookingKeysTest.java new file mode 100644 index 0000000..c9a8df8 --- /dev/null +++ b/src/test/java/net/tfminecraft/breedingbuddies/husbandry/HusbandryCookingKeysTest.java @@ -0,0 +1,75 @@ +package net.tfminecraft.breedingbuddies.husbandry; + +import static org.junit.jupiter.api.Assertions.*; +import static org.mockito.Mockito.*; + +import org.bukkit.Location; +import org.bukkit.Material; +import org.bukkit.NamespacedKey; +import org.bukkit.entity.Cow; +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.mockbukkit.mockbukkit.MockBukkit; +import org.mockbukkit.mockbukkit.ServerMock; +import org.mockbukkit.mockbukkit.world.WorldMock; + +import net.tfminecraft.breedingbuddies.BreedingBuddies; + +class HusbandryCookingKeysTest { + private ServerMock server; + private BreedingBuddies previous; + + @BeforeEach void setUp() { + server = MockBukkit.mock(); + previous = BreedingBuddies.plugin; + BreedingBuddies.plugin = mock(BreedingBuddies.class); + when(BreedingBuddies.plugin.getName()).thenReturn("BreedingBuddies"); + when(BreedingBuddies.plugin.namespace()).thenReturn("breedingbuddies"); + } + + @AfterEach void tearDown() { + MockBukkit.unmock(); + BreedingBuddies.plugin = previous; + } + + @Test void animalsCookingManagedStayManagedAndNewStampsUseBreedingBuddiesKeys() { + WorldMock world = server.addSimpleWorld("farm"); + Cow fromCooking = world.spawn(new Location(world, 0, 65, 0), Cow.class); + fromCooking.getPersistentDataContainer().set(cooking("managed"), PersistentDataType.BYTE, (byte) 1); + assertTrue(HusbandryEntities.isManaged(fromCooking)); + + Cow fresh = world.spawn(new Location(world, 2, 65, 0), Cow.class); + assertFalse(HusbandryEntities.isManaged(fresh)); + HusbandryEntities.stampManaged(fresh); + assertTrue(HusbandryEntities.isManaged(fresh)); + assertNull(fresh.getPersistentDataContainer().get(cooking("managed"), PersistentDataType.BYTE)); + } + + @Test void tokensNamedOrLinkedByCookingStillWorkAndOurOwnValuesWin() { + ItemStack tame = token("tame_name", "Bessie"); + assertEquals("Bessie", HusbandryItems.tameName(tame)); + HusbandryItems.setTameName(tame, "Daisy"); + assertEquals("Daisy", HusbandryItems.tameName(tame)); + + ItemStack share = token("linked_animal", "0000-cooking"); + assertEquals("0000-cooking", HusbandryItems.linkedAnimal(share)); + HusbandryItems.setLinkedAnimal(share, "1111-breedingbuddies", null); + assertEquals("1111-breedingbuddies", HusbandryItems.linkedAnimal(share)); + } + + private static ItemStack token(String key, String value) { + ItemStack stack = new ItemStack(Material.NAME_TAG); + ItemMeta meta = stack.getItemMeta(); + meta.getPersistentDataContainer().set(cooking(key), PersistentDataType.STRING, value); + stack.setItemMeta(meta); + return stack; + } + + private static NamespacedKey cooking(String key) { + return new NamespacedKey("cooking", key); + } +} diff --git a/src/test/java/net/tfminecraft/breedingbuddies/husbandry/HusbandryDecisionEncodingTest.java b/src/test/java/net/tfminecraft/breedingbuddies/husbandry/HusbandryDecisionEncodingTest.java new file mode 100644 index 0000000..398e23c --- /dev/null +++ b/src/test/java/net/tfminecraft/breedingbuddies/husbandry/HusbandryDecisionEncodingTest.java @@ -0,0 +1,146 @@ +package net.tfminecraft.breedingbuddies.husbandry; + +import static org.junit.jupiter.api.Assertions.*; +import static org.mockito.Mockito.*; +import static net.tfminecraft.breedingbuddies.husbandry.HusbandryOwnershipStore.*; + +import java.nio.file.Path; +import java.sql.Connection; +import java.time.Instant; +import java.util.*; +import net.tfminecraft.tlibs.character.CharacterRef; +import net.tfminecraft.tlibs.character.estate.EstateDecision; +import net.tfminecraft.tlibs.character.estate.Outcome; +import net.tfminecraft.tlibs.character.estate.Recipient; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.io.TempDir; + +class HusbandryDecisionEncodingTest { + @TempDir Path directory; + final HusbandryEstateTest fixture = new HusbandryEstateTest(); + + @Test void fixedTwelveFieldRenderingMatchesAll15552PinnedFormatCombinations() throws Exception { + var captured = HusbandryEstate.class.getDeclaredMethod("captured", EstateDecision.class); + captured.setAccessible(true); + var times = List.of(Optional.empty(), Optional.of(Instant.EPOCH), + Optional.of(Instant.ofEpochMilli(1700000000123L)), Optional.of(Instant.ofEpochSecond(123, 456789123))); + var instants = List.of(Instant.EPOCH, Instant.ofEpochMilli(1700000000123L), Instant.ofEpochSecond(123, 456789123)); + List> heirs = List.of(Optional.empty(), Optional.of(fixture.heir)); + List> guilds = List.of(Optional.empty(), Optional.of(UUID.randomUUID())); + List> realms = List.of(Optional.empty(), Optional.of(UUID.randomUUID())); + var money = List.of(Recipient.account("char:" + fixture.heir.characterId() + ":bank"), Recipient.account("guild:" + UUID.randomUUID()), Recipient.FROZEN); + var holdings = List.of(Recipient.character(fixture.heir), Recipient.WAITING, Recipient.FROZEN); + int cases = 0; + for (var diedAt : times) for (var decidedAt : instants) for (var heir : heirs) + for (var guild : guilds) for (var realm : realms) for (var funds : money) + for (var property : holdings) for (var locks : holdings) + for (var letters : List.of(Recipient.character(fixture.heir), Recipient.SENDER)) + for (long revision : new long[]{0, 1, Long.MAX_VALUE}) { + var decision = new EstateDecision(fixture.death, fixture.testator, diedAt, decidedAt, + heir, guild, realm, + funds, property, locks, letters, revision); + assertEquals(decision.toString(), captured.invoke(null, decision)); + cases++; + } + assertEquals(15552, cases); + } + + @Test void runtimeRecordTextDriftAfterRestartKeepsFullReceiptAndCumulativeIdsUnchanged() throws Exception { + var original = fixture.decision(1, Recipient.character(fixture.heir)); + Path path = directory.resolve("drift.db"); + Map state; + Outcome expected; + try (var repository = new HusbandryOwnershipMigrationTest.Repository(path)) { + var repo = repository.value; var animal = fixture.own(repo, fixture.testator, "owner"); + var connection = repo.ownershipDatabase().getConnection(); + update(connection, "ALTER TABLE character_owners ADD COLUMN future BLOB"); + update(connection, "ALTER TABLE character_owners ADD COLUMN unknown TEXT"); + update(connection, "UPDATE character_owners SET future=?", new byte[]{0, 3, -1}); + expected = fixture.estate(repo).settle(original); + assertEquals(Outcome.Kind.DONE, expected.kind()); + assertEquals(List.of(animal.uuid().toString()), ((Outcome.Done)expected).movedIds()); + assertEquals(original.toString(), rows(connection, "SELECT decision FROM husbandry_estate_intents").getFirst()[0]); + state = state(connection); + } + try (var repository = new HusbandryOwnershipMigrationTest.Repository(path)) { + var repo = repository.value; var estate = fixture.estate(repo); + assertEquals(expected, estate.settle(drift(original, "FutureEstateDecision{same accessors}"))); + assertEquals(state, state(repo.ownershipDatabase().getConnection())); + assertEquals(expected, estate.settle(drift(fixture.decision(2, Recipient.WAITING), "Future waiting text"))); + assertEquals(state, state(repo.ownershipDatabase().getConnection())); + } + } + + @Test void everyNonPropertyFieldChangeAtSameRevisionBlocksEvenWhenRuntimeTextLies() throws Exception { + var original = fixture.decision(1, Recipient.character(fixture.heir)); + List changes = List.of( + decision(Optional.empty(), original.decidedAt(), original.heir(), original.deathGuild(), original.deathRealm(), original.money(), original.guildLocks(), original.letters()), + decision(original.diedAt(), Instant.ofEpochMilli(1), original.heir(), original.deathGuild(), original.deathRealm(), original.money(), original.guildLocks(), original.letters()), + decision(original.diedAt(), original.decidedAt(), Optional.empty(), original.deathGuild(), original.deathRealm(), original.money(), original.guildLocks(), original.letters()), + decision(original.diedAt(), original.decidedAt(), original.heir(), Optional.of(UUID.randomUUID()), original.deathRealm(), original.money(), original.guildLocks(), original.letters()), + decision(original.diedAt(), original.decidedAt(), original.heir(), original.deathGuild(), Optional.of(UUID.randomUUID()), original.money(), original.guildLocks(), original.letters()), + decision(original.diedAt(), original.decidedAt(), original.heir(), original.deathGuild(), original.deathRealm(), Recipient.account("char:" + fixture.heir.characterId() + ":bank"), original.guildLocks(), original.letters()), + decision(original.diedAt(), original.decidedAt(), original.heir(), original.deathGuild(), original.deathRealm(), original.money(), Recipient.WAITING, original.letters()), + decision(original.diedAt(), original.decidedAt(), original.heir(), original.deathGuild(), original.deathRealm(), original.money(), original.guildLocks(), Recipient.character(fixture.heir))); + try (var repository = new HusbandryOwnershipMigrationTest.Repository(directory.resolve("authority.db"))) { + var repo = repository.value; fixture.own(repo, fixture.testator, "owner"); var estate = fixture.estate(repo); + assertEquals(Outcome.Kind.DONE, estate.settle(original).kind()); + var before = state(repo.ownershipDatabase().getConnection()); + for (var changed : changes) { + assertEquals(Outcome.Kind.BLOCKED, estate.settle(changed).kind()); + assertEquals(before, state(repo.ownershipDatabase().getConnection())); + assertEquals(Outcome.Kind.BLOCKED, estate.settle(drift(changed, original.toString())).kind()); + assertEquals(before, state(repo.ownershipDatabase().getConnection())); + } + } + } + + @Test void unknownKindInAnyPartBlocksBeforeWritesIncludingWaitingAndSelfRecipients() throws Exception { + try (var repository = new HusbandryOwnershipMigrationTest.Repository(directory.resolve("unknown.db"))) { + var repo = repository.value; fixture.own(repo, fixture.testator, "owner"); var estate = fixture.estate(repo); + var before = state(repo.ownershipDatabase().getConnection()); + Recipient.Character unknown = mock(Recipient.Character.class); + when(unknown.kind()).thenReturn(null); + when(unknown.character()).thenReturn(Optional.of(fixture.heir)); + for (String field : List.of("money", "property", "guildLocks", "letters", "waiting-money", "self-letters")) { + var original = fixture.decision(1, field.equals("waiting-money") ? Recipient.WAITING + : Recipient.character(field.equals("self-letters") ? fixture.testator : fixture.heir)); + var invalid = drift(original, original.toString()); + switch (field) { + case "money", "waiting-money" -> doReturn(unknown).when(invalid).money(); + case "property" -> doReturn(unknown).when(invalid).property(); + case "guildLocks" -> doReturn(unknown).when(invalid).guildLocks(); + default -> doReturn(unknown).when(invalid).letters(); + } + assertEquals(Outcome.Kind.BLOCKED, estate.settle(invalid).kind(), field); + assertEquals(before, state(repo.ownershipDatabase().getConnection()), field); + } + } + } + + private EstateDecision decision(Optional diedAt, Instant decidedAt, + Optional heir, Optional guild, + Optional realm, Recipient money, Recipient locks, Recipient letters) { + return new EstateDecision(fixture.death, fixture.testator, diedAt, decidedAt, heir, guild, realm, + money, Recipient.character(fixture.heir), locks, letters, 1); + } + + private EstateDecision drift(EstateDecision original, String text) { + EstateDecision result = mock(EstateDecision.class, + withSettings().defaultAnswer(call -> call.getMethod().invoke(original, call.getArguments()))); + doReturn(text).when(result).toString(); + return result; + } + + private Map state(Connection connection) throws Exception { + Map result = new TreeMap<>(); + for (String table : List.of("character_owners", "husbandry_estate_intents", "husbandry_estate_slots", "husbandry_estate_owner_archive")) { + if (scalar(connection, "SELECT COUNT(*) FROM sqlite_master WHERE name=? AND type='table'", table) == 0) { + result.put(table, "absent"); continue; + } + var columns = rows(connection, "PRAGMA table_info(" + table + ")").stream().map(row -> "quote(\"" + row[1] + "\")").toList(); + result.put(table, Arrays.deepToString(rows(connection, "SELECT " + String.join(",", columns) + " FROM " + table + " ORDER BY rowid").toArray())); + } + return result; + } +} diff --git a/src/test/java/net/tfminecraft/breedingbuddies/husbandry/HusbandryDropRollerTest.java b/src/test/java/net/tfminecraft/breedingbuddies/husbandry/HusbandryDropRollerTest.java new file mode 100644 index 0000000..f0b68c8 --- /dev/null +++ b/src/test/java/net/tfminecraft/breedingbuddies/husbandry/HusbandryDropRollerTest.java @@ -0,0 +1,86 @@ +package net.tfminecraft.breedingbuddies.husbandry; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertNull; +import static org.junit.jupiter.api.Assertions.assertTrue; + +import java.util.List; +import java.util.Random; +import java.util.UUID; + +import org.junit.jupiter.api.Test; + +class HusbandryDropRollerTest { + + private static final List BANDS = List.of( + new HusbandryAmountBand(0, 1, 1, 0), + new HusbandryAmountBand(500, 5, 2, 1), + new HusbandryAmountBand(750, 7, 3, 2)); + + @Test + void extraIsZeroThenOneThenTwo() { + assertEquals(0, HusbandryConfig.secondaryExtraFor(0, BANDS)); + assertEquals(0, HusbandryConfig.secondaryExtraFor(499, BANDS)); + assertEquals(1, HusbandryConfig.secondaryExtraFor(500, BANDS)); + assertEquals(2, HusbandryConfig.secondaryExtraFor(750, BANDS)); + assertEquals(1, 1 + HusbandryConfig.secondaryExtraFor(0, BANDS)); + assertEquals(3, 1 + HusbandryConfig.secondaryExtraFor(750, BANDS)); + } + + @Test + void oneStarPoolHasNoLegendary() { + HusbandryDropTable table = new HusbandryDropTable( + List.of(new HusbandryDropEntry("common", 1, 50)), + List.of(new HusbandryDropEntry("rare", 1, 5)), + List.of(new HusbandryDropEntry("epic", 1, 5)), + List.of(new HusbandryDropEntry("legendary", 1, 5)), + true); + List pool = HusbandryDropRoller.unlockedPool(table, 1); + assertEquals(1, pool.size()); + assertEquals("common", pool.get(0).path()); + List five = HusbandryDropRoller.unlockedPool(table, 5); + assertEquals(4, five.size()); + } + + @Test + void countedPicksAreIndependent() { + List pool = List.of( + new HusbandryDropEntry("a", 1, 1), + new HusbandryDropEntry("b", 1, 1)); + Random random = new Random(3L); + String first = HusbandryDropRoller.pickEntry(pool, random).path(); + String second = HusbandryDropRoller.pickEntry(pool, random).path(); + assertTrue(first.equals("a") || first.equals("b")); + assertTrue(second.equals("a") || second.equals("b")); + } + + @Test + void sheepOnWoolCooldownDropsNoHides() { + HusbandryAnimal animal = new HusbandryAnimal(UUID.randomUUID(), "SHEEP", "Test"); + animal.setWoolReadyAt(2_000L); + assertTrue(HusbandryDropRoller.woolBlocked(animal.type(), animal, 1_000L)); + assertEquals(0, HusbandryDropRoller.hideCount(animal, 1_000L)); + assertFalse(HusbandryDropRoller.woolBlocked(animal.type(), animal, 3_000L)); + } + + @Test + void rollReturnsEmptyWhenTableMissing() { + HusbandryAnimal animal = new HusbandryAnimal(UUID.randomUUID(), "SHEEP", "Test"); + assertTrue(HusbandryDropRoller.roll( + null, animal, new Random(1L), 0L, HusbandryDropRoller.CountMode.WOOL_COUNT).isEmpty()); + assertTrue(HusbandryDropRoller.roll( + HusbandryDropTable.empty(), animal, new Random(1L), 0L, HusbandryDropRoller.CountMode.SINGLE).isEmpty()); + } + + @Test + void pigMissPathIsEmptyOptional() { + HusbandryDropEntry miss = new HusbandryDropEntry("", 1, 95); + HusbandryDropEntry dust = new HusbandryDropEntry("m.currency.enchanted_dust", 1, 5); + HusbandryDropEntry picked = HusbandryDropRoller.pickEntry(List.of(miss), new Random(1L)); + assertEquals("", picked.path()); + assertTrue(picked.path().isBlank()); + assertNull(HusbandryDropRoller.pickEntry(List.of(), new Random(1L))); + assertEquals("m.currency.enchanted_dust", HusbandryDropRoller.pickEntry(List.of(dust), new Random(1L)).path()); + } +} diff --git a/src/test/java/net/tfminecraft/breedingbuddies/husbandry/HusbandryEggsTest.java b/src/test/java/net/tfminecraft/breedingbuddies/husbandry/HusbandryEggsTest.java new file mode 100644 index 0000000..d690e83 --- /dev/null +++ b/src/test/java/net/tfminecraft/breedingbuddies/husbandry/HusbandryEggsTest.java @@ -0,0 +1,18 @@ +package net.tfminecraft.breedingbuddies.husbandry; + +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertTrue; + +import org.bukkit.Material; +import org.junit.jupiter.api.Test; + +class HusbandryEggsTest { + + @Test + void everyChickenVariantEggIsSuppressed() { + assertTrue(HusbandryEggs.isVanillaEgg(Material.EGG)); + assertTrue(HusbandryEggs.isVanillaEgg(Material.BLUE_EGG)); + assertTrue(HusbandryEggs.isVanillaEgg(Material.BROWN_EGG)); + assertFalse(HusbandryEggs.isVanillaEgg(Material.FEATHER)); + } +} diff --git a/src/test/java/net/tfminecraft/breedingbuddies/husbandry/HusbandryEntitiesCacheTest.java b/src/test/java/net/tfminecraft/breedingbuddies/husbandry/HusbandryEntitiesCacheTest.java new file mode 100644 index 0000000..4e75380 --- /dev/null +++ b/src/test/java/net/tfminecraft/breedingbuddies/husbandry/HusbandryEntitiesCacheTest.java @@ -0,0 +1,51 @@ +package net.tfminecraft.breedingbuddies.husbandry; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertSame; +import static org.junit.jupiter.api.Assertions.assertTrue; + +import java.util.List; +import java.util.UUID; + +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.Test; + +class HusbandryEntitiesCacheTest { + + @AfterEach + void tearDown() { + HusbandryEntities.clearLoaded(); + } + + @Test + void putLoadedIsCanonicalLookup() { + UUID uuid = UUID.randomUUID(); + HusbandryAnimal animal = new HusbandryAnimal(uuid, "COW", "Bess"); + HusbandryEntities.putLoaded(animal); + + assertSame(animal, HusbandryEntities.getLoaded(uuid).orElseThrow()); + assertSame(animal, HusbandryEntities.lookup(uuid).orElseThrow()); + assertEquals(1, HusbandryEntities.snapshotLoaded().size()); + } + + @Test + void evictRemovesCanonicalRecord() { + UUID uuid = UUID.randomUUID(); + HusbandryEntities.putLoaded(new HusbandryAnimal(uuid, "SHEEP", "Wool")); + HusbandryEntities.evict(uuid); + + assertTrue(HusbandryEntities.getLoaded(uuid).isEmpty()); + assertTrue(HusbandryEntities.snapshotLoaded().isEmpty()); + } + + @Test + void snapshotDoesNotExposeLiveMutationByEviction() { + HusbandryAnimal first = new HusbandryAnimal(UUID.randomUUID(), "PIG", "Ham"); + HusbandryEntities.putLoaded(first); + List snapshot = HusbandryEntities.snapshotLoaded(); + HusbandryEntities.clearLoaded(); + + assertEquals(1, snapshot.size()); + assertTrue(HusbandryEntities.snapshotLoaded().isEmpty()); + } +} diff --git a/src/test/java/net/tfminecraft/breedingbuddies/husbandry/HusbandryEntityScanTest.java b/src/test/java/net/tfminecraft/breedingbuddies/husbandry/HusbandryEntityScanTest.java new file mode 100644 index 0000000..db62847 --- /dev/null +++ b/src/test/java/net/tfminecraft/breedingbuddies/husbandry/HusbandryEntityScanTest.java @@ -0,0 +1,323 @@ +package net.tfminecraft.breedingbuddies.husbandry; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertTrue; + +import java.io.ByteArrayOutputStream; +import java.io.DataOutputStream; +import java.io.File; +import java.io.IOException; +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.List; +import java.util.Map; +import java.util.Set; +import java.util.UUID; +import java.util.zip.DeflaterOutputStream; +import java.util.zip.GZIPOutputStream; + +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.io.TempDir; + +class HusbandryEntityScanTest { + + private static final UUID COW = UUID.fromString("b3a3b7bb-af8d-48ff-baa4-c75e7b5cd3cf"); + private static final UUID HORSE = UUID.fromString("4a20145e-0b17-4531-a630-e3fb2faa2a50"); + private static final UUID RIDER = UUID.fromString("da6a6b72-b5d9-403c-9af0-0d89eca049fe"); + private static final UUID GONE = UUID.fromString("ad0c0422-8f97-4a4d-96c0-74a20b74383f"); + + @Test + void findsAnimalsAndPassengersInSavedChunks(@TempDir Path dir) throws IOException { + File entities = dir.resolve("entities").toFile(); + entities.mkdirs(); + Map chunks = Map.of( + 0, zlib(chunk(entity(COW, 12.7, 64.0, -3.2))), + 33, gzip(chunk(entity(HORSE, -40.5, 70.0, 900.9, entity(RIDER, -40.5, 71.5, 900.9))))); + writeRegion(new File(entities, "r.0.-1.mca"), chunks, Map.of()); + + HusbandryEntityScan.Result result = HusbandryEntityScan.scan( + List.of(new HusbandryEntityScan.WorldDir("TFMC_Map", entities)), Set.of(COW, RIDER, GONE)); + + assertTrue(result.complete()); + assertEquals(new HusbandryEntityScan.Found("TFMC_Map", 12, 64, -4), result.found().get(COW)); + assertEquals(new HusbandryEntityScan.Found("TFMC_Map", -41, 71, 900), result.found().get(RIDER)); + assertFalse(result.found().containsKey(HORSE)); + assertFalse(result.found().containsKey(GONE)); + } + + @Test + void readsOversizedChunksFromExternalFiles(@TempDir Path dir) throws IOException { + File entities = dir.resolve("entities").toFile(); + entities.mkdirs(); + // Chunk index 34 in region (-1, 2) is chunk (-30, 65). + Files.write(new File(entities, "c.-30.65.mcc").toPath(), zlib(chunk(entity(COW, 1.0, 2.0, 3.0)))); + writeRegion(new File(entities, "r.-1.2.mca"), Map.of(), Map.of(34, 2 | 0x80)); + + HusbandryEntityScan.Result result = HusbandryEntityScan.scan( + List.of(new HusbandryEntityScan.WorldDir("world", entities)), Set.of(COW)); + + assertTrue(result.complete()); + assertEquals(new HusbandryEntityScan.Found("world", 1, 2, 3), result.found().get(COW)); + } + + @Test + void unreadableChunksMakeTheScanIncomplete(@TempDir Path dir) throws IOException { + File entities = dir.resolve("entities").toFile(); + entities.mkdirs(); + writeRegion(new File(entities, "r.0.0.mca"), + Map.of(0, zlib(chunk(entity(COW, 0, 0, 0))), 1, new byte[] {1, 2, 3}), Map.of(1, 4)); + + HusbandryEntityScan.Result result = HusbandryEntityScan.scan( + List.of(new HusbandryEntityScan.WorldDir("world", entities)), Set.of(COW, GONE)); + + assertFalse(result.complete()); + assertTrue(result.found().containsKey(COW)); + } + + @Test + void truncatedRegionIsIncompleteButEmptyRegionIsNot(@TempDir Path dir) throws IOException { + File entities = dir.resolve("entities").toFile(); + entities.mkdirs(); + Files.write(new File(entities, "r.0.0.mca").toPath(), new byte[0]); + List worlds = List.of(new HusbandryEntityScan.WorldDir("world", entities)); + assertTrue(HusbandryEntityScan.scan(worlds, Set.of(COW)).complete()); + + Files.write(new File(entities, "r.0.1.mca").toPath(), new byte[100]); + assertFalse(HusbandryEntityScan.scan(worlds, Set.of(COW)).complete()); + } + + @Test + void corruptUuidLengthIsUnreadableNotAnAllocation(@TempDir Path dir) throws IOException { + File entities = dir.resolve("entities").toFile(); + entities.mkdirs(); + Body corrupt = out -> { + out.writeByte(11); + out.writeUTF("UUID"); + out.writeInt(Integer.MAX_VALUE); + }; + writeRegion(new File(entities, "r.0.0.mca"), Map.of(0, zlib(chunk(corrupt))), Map.of()); + + HusbandryEntityScan.Result result = HusbandryEntityScan.scan( + List.of(new HusbandryEntityScan.WorldDir("world", entities)), Set.of(COW)); + + assertFalse(result.complete()); + assertTrue(result.found().isEmpty()); + } + + @Test + void outOfRangeChunkHeadersAreUnreadable(@TempDir Path dir) throws IOException { + File entities = dir.resolve("entities").toFile(); + entities.mkdirs(); + byte[] region = new byte[4096 * 3]; + // Chunk 0 points past the end of the file; chunk 1 claims a 2 GiB payload. + region[0] = (byte) 0xFF; + region[1] = (byte) 0xFF; + region[2] = (byte) 0xFF; + region[3] = 1; + region[6] = 2; + region[7] = 1; + region[8192] = 0x7F; + region[8193] = (byte) 0xFF; + region[8194] = (byte) 0xFF; + region[8195] = (byte) 0xFF; + region[8196] = 2; + Files.write(new File(entities, "r.0.0.mca").toPath(), region); + + HusbandryEntityScan.Result result = HusbandryEntityScan.scan( + List.of(new HusbandryEntityScan.WorldDir("world", entities)), Set.of(COW)); + + assertFalse(result.complete()); + assertTrue(result.found().isEmpty()); + } + + @Test + void animalsUnderALoggedOutRiderCountAsRidden(@TempDir Path dir) throws IOException { + File entities = dir.resolve("entities").toFile(); + entities.mkdirs(); + File playerdata = dir.resolve("playerdata").toFile(); + playerdata.mkdirs(); + Files.write(new File(playerdata, RIDER + ".dat").toPath(), gzip(player(entity(HORSE, 5.5, 64.0, 5.5)))); + // Paper's backup copy is stale and must not count. + Files.write(new File(playerdata, RIDER + ".dat_old").toPath(), gzip(player(entity(COW, 1.0, 64.0, 1.0)))); + + HusbandryEntityScan.Result result = HusbandryEntityScan.scan( + List.of(new HusbandryEntityScan.WorldDir("TFMC_Map", entities)), playerdata, Set.of(HORSE, COW)); + + assertTrue(result.complete()); + assertEquals(Set.of(HORSE), result.ridden()); + assertTrue(result.found().isEmpty()); + } + + @Test + void unreadablePlayerFilesMakeTheScanIncomplete(@TempDir Path dir) throws IOException { + File playerdata = dir.resolve("playerdata").toFile(); + playerdata.mkdirs(); + Files.write(new File(playerdata, RIDER + ".dat").toPath(), new byte[] {1, 2, 3}); + + HusbandryEntityScan.Result result = HusbandryEntityScan.scan( + List.of(new HusbandryEntityScan.WorldDir("world", dir.resolve("entities").toFile())), + playerdata, Set.of(HORSE)); + + assertFalse(result.complete()); + assertTrue(result.ridden().isEmpty()); + } + + @Test + void missingFolderIsSkipped(@TempDir Path dir) { + HusbandryEntityScan.Result result = HusbandryEntityScan.scan( + List.of(new HusbandryEntityScan.WorldDir("world", dir.resolve("nope").toFile())), Set.of(COW)); + assertTrue(result.complete()); + assertTrue(result.found().isEmpty()); + } + + // ---------------------------------------------------------------------- + // Fixture writers + // ---------------------------------------------------------------------- + + private interface Body { + void write(DataOutputStream out) throws IOException; + } + + private static Body entity(UUID uuid, double x, double y, double z, Body... passengers) { + return out -> { + out.writeByte(8); + out.writeUTF("id"); + out.writeUTF("minecraft:cow"); + out.writeByte(10); + out.writeUTF("Brain"); + out.writeByte(9); + out.writeUTF("memories"); + out.writeByte(0); + out.writeInt(0); + out.writeByte(0); + out.writeByte(9); + out.writeUTF("Pos"); + out.writeByte(6); + out.writeInt(3); + out.writeDouble(x); + out.writeDouble(y); + out.writeDouble(z); + out.writeByte(12); + out.writeUTF("Longs"); + out.writeInt(1); + out.writeLong(7L); + out.writeByte(11); + out.writeUTF("UUID"); + out.writeInt(4); + out.writeInt((int) (uuid.getMostSignificantBits() >> 32)); + out.writeInt((int) uuid.getMostSignificantBits()); + out.writeInt((int) (uuid.getLeastSignificantBits() >> 32)); + out.writeInt((int) uuid.getLeastSignificantBits()); + if (passengers.length > 0) { + out.writeByte(9); + out.writeUTF("Passengers"); + out.writeByte(10); + out.writeInt(passengers.length); + for (Body passenger : passengers) { + passenger.write(out); + } + } + out.writeByte(0); + }; + } + + private static byte[] player(Body vehicle) throws IOException { + ByteArrayOutputStream bytes = new ByteArrayOutputStream(); + DataOutputStream out = new DataOutputStream(bytes); + out.writeByte(10); + out.writeUTF(""); + out.writeByte(8); + out.writeUTF("Dimension"); + out.writeUTF("minecraft:overworld"); + out.writeByte(10); + out.writeUTF("RootVehicle"); + out.writeByte(11); + out.writeUTF("Attach"); + out.writeInt(4); + out.writeInt(1); + out.writeInt(2); + out.writeInt(3); + out.writeInt(4); + out.writeByte(10); + out.writeUTF("Entity"); + vehicle.write(out); + out.writeByte(0); + out.writeByte(0); + return bytes.toByteArray(); + } + + private static byte[] chunk(Body... entities) throws IOException { + ByteArrayOutputStream bytes = new ByteArrayOutputStream(); + DataOutputStream out = new DataOutputStream(bytes); + out.writeByte(10); + out.writeUTF(""); + out.writeByte(3); + out.writeUTF("DataVersion"); + out.writeInt(4556); + out.writeByte(11); + out.writeUTF("Position"); + out.writeInt(2); + out.writeInt(0); + out.writeInt(0); + out.writeByte(9); + out.writeUTF("Entities"); + out.writeByte(10); + out.writeInt(entities.length); + for (Body entity : entities) { + entity.write(out); + } + out.writeByte(0); + return bytes.toByteArray(); + } + + private static byte[] zlib(byte[] raw) throws IOException { + ByteArrayOutputStream bytes = new ByteArrayOutputStream(); + try (DeflaterOutputStream out = new DeflaterOutputStream(bytes)) { + out.write(raw); + } + return bytes.toByteArray(); + } + + private static byte[] gzip(byte[] raw) throws IOException { + ByteArrayOutputStream bytes = new ByteArrayOutputStream(); + try (GZIPOutputStream out = new GZIPOutputStream(bytes)) { + out.write(raw); + } + return bytes.toByteArray(); + } + + /** Payloads default to zlib (2), or gzip (1) when the bytes start with the gzip magic. */ + private static void writeRegion(File file, Map payloads, Map compression) + throws IOException { + ByteArrayOutputStream body = new ByteArrayOutputStream(); + int[] header = new int[1024]; + int sector = 2; + for (int index = 0; index < 1024; index++) { + byte[] payload = payloads.getOrDefault(index, compression.containsKey(index) ? new byte[0] : null); + if (payload == null) { + continue; + } + int type = compression.getOrDefault(index, + payload.length > 1 && (payload[0] & 0xFF) == 0x1F && (payload[1] & 0xFF) == 0x8B ? 1 : 2); + ByteArrayOutputStream chunk = new ByteArrayOutputStream(); + DataOutputStream out = new DataOutputStream(chunk); + out.writeInt(payload.length + 1); + out.writeByte(type); + out.write(payload); + int sectors = (chunk.size() + 4095) / 4096; + chunk.write(new byte[sectors * 4096 - chunk.size()]); + header[index] = (sector << 8) | sectors; + sector += sectors; + body.write(chunk.toByteArray()); + } + ByteArrayOutputStream region = new ByteArrayOutputStream(); + DataOutputStream out = new DataOutputStream(region); + for (int entry : header) { + out.writeInt(entry); + } + out.write(new byte[4096]); + out.write(body.toByteArray()); + Files.write(file.toPath(), region.toByteArray()); + } +} diff --git a/src/test/java/net/tfminecraft/breedingbuddies/husbandry/HusbandryEstateTest.java b/src/test/java/net/tfminecraft/breedingbuddies/husbandry/HusbandryEstateTest.java new file mode 100644 index 0000000..bdeded2 --- /dev/null +++ b/src/test/java/net/tfminecraft/breedingbuddies/husbandry/HusbandryEstateTest.java @@ -0,0 +1,267 @@ +package net.tfminecraft.breedingbuddies.husbandry; + +import static org.junit.jupiter.api.Assertions.*; +import static org.mockito.Mockito.*; +import static org.mockito.ArgumentMatchers.*; +import java.nio.file.Path; +import java.time.Instant; +import java.util.*; +import java.util.concurrent.atomic.AtomicInteger; +import java.util.logging.Logger; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.io.TempDir; +import net.tfminecraft.tlibs.character.CharacterRef; +import net.tfminecraft.tlibs.character.estate.*; +import static net.tfminecraft.breedingbuddies.husbandry.HusbandryOwnershipStore.*; + +class HusbandryEstateTest { + @TempDir Path directory; + final CharacterRef testator = new CharacterRef(UUID.randomUUID(), UUID.randomUUID()); + final CharacterRef heir = new CharacterRef(UUID.randomUUID(), UUID.randomUUID()); + final CharacterRef coowner = new CharacterRef(UUID.randomUUID(), UUID.randomUUID()); + final UUID death = UUID.randomUUID(); + EstateDecision decision(long revision, Recipient property) { + return new EstateDecision(death, testator, Optional.of(Instant.EPOCH), Instant.EPOCH, + Optional.of(heir), Optional.empty(), Optional.empty(), Recipient.FROZEN, property, + Recipient.FROZEN, Recipient.SENDER, revision); + } + HusbandryAnimal own(HusbandryRepository repo, CharacterRef ref, String role) { + var animal = new HusbandryAnimal(UUID.randomUUID(), "COW", "Daisy"); + animal.setCare(61); animal.setGenetics(871); animal.setHungrySince(9L); animal.setState(HusbandryAnimalState.OWNED); + repo.saveAnimalAndOwner(animal, new HusbandryOwner(animal.uuid(), ref.ownerId(), ref.characterId(), role)); + return animal; + } + HusbandryEstate estate(HusbandryRepository repo) { + var estate = new HusbandryEstate(repo, Logger.getAnonymousLogger()); estate.guard = decision -> true; return estate; + } + @Test void slotTransferPreservesCoownersRoleCollisionsAnimalPayloadAndCumulativeReceipts() throws Exception { + try (var fixture = new HusbandryOwnershipMigrationTest.Repository(directory.resolve("farm.db"))) { + var repo = fixture.value; var animal = own(repo, testator, "coowner"); + repo.upsertOwner(new HusbandryOwner(animal.uuid(), coowner.ownerId(), coowner.characterId(), "coowner")); + repo.upsertOwner(new HusbandryOwner(animal.uuid(), heir.ownerId(), heir.characterId(), "owner")); + repo.upsertSnapshots(Map.of(animal.uuid(), new byte[]{1,7,8}), 9); + var estate = estate(repo); var decision = decision(1, Recipient.character(heir)); + assertEquals(Map.of("animals", 1), estate.describe(testator.characterId()).counts()); + assertEquals(Holdings.NONE, estate.describe(UUID.randomUUID())); + var answer = (Outcome.Done)estate.settle(decision); + assertEquals(List.of(animal.uuid().toString()), answer.movedIds()); + assertEquals(2, repo.listOwners(animal.uuid()).size()); + assertEquals("owner", repo.listOwners(animal.uuid()).stream().filter(o -> o.ref().equals(heir)).findFirst().orElseThrow().role()); + assertTrue(repo.listOwners(animal.uuid()).stream().anyMatch(o -> o.ref().equals(coowner))); + assertFalse(repo.listOwners(animal.uuid()).stream().anyMatch(o -> o.ref().equals(testator))); + assertEquals(61, repo.getAnimal(animal.uuid()).orElseThrow().care()); assertArrayEquals(new byte[]{1,7,8}, repo.getSnapshot(animal.uuid()).orElseThrow().data()); + assertEquals(answer, estate.settle(decision)); + assertEquals(answer, estate.settle(decision(7, Recipient.FROZEN))); + assertEquals(answer, estate.settle(decision(8, Recipient.WAITING))); + var late = own(repo, testator, "owner"); + assertEquals(Outcome.Kind.DONE, estate.settle(decision(2, Recipient.character(heir))).kind()); + assertEquals(2, ((Outcome.Done)estate.settle(decision(2, Recipient.character(heir)))).movedIds().size()); + assertEquals(Holdings.NONE, estate.describe(testator.characterId())); + assertEquals(2, repo.countForPlayer(heir.ownerId())); + } + try (var fixture = new HusbandryOwnershipMigrationTest.Repository(directory.resolve("farm.db"))) { + assertEquals(2, ((Outcome.Done)estate(fixture.value).settle(decision(2, Recipient.character(heir)))).movedIds().size()); + } + } + @Test void frozenWaitingStaleGuardRevivalAndDeathIdentityCannotPublish() { + try (var fixture = new HusbandryOwnershipMigrationTest.Repository(directory.resolve("farm.db"))) { + var repo = fixture.value; var animal = own(repo, testator, "owner"); var estate = estate(repo); + assertEquals(Outcome.done(), estate.settle(decision(1, Recipient.FROZEN))); + assertEquals(Outcome.done(), estate.settle(decision(2, Recipient.WAITING))); + estate.guard = d -> false; + assertEquals(Outcome.Kind.RETRY, estate.settle(decision(3, Recipient.character(heir))).kind()); + assertEquals(testator, repo.listOwners(animal.uuid()).getFirst().ref()); + estate.guard = new java.util.function.Predicate<>() { int checks; public boolean test(EstateDecision d) { return ++checks < 3; } }; + assertEquals(Outcome.Kind.RETRY, estate.settle(decision(3, Recipient.character(heir))).kind()); + assertEquals(testator, repo.listOwners(animal.uuid()).getFirst().ref()); + estate.guard = d -> true; assertEquals(Outcome.Kind.DONE, estate.settle(decision(4, Recipient.character(testator))).kind()); + assertEquals(testator, repo.listOwners(animal.uuid()).getFirst().ref()); + } + } + @Test void restoredTransferredSlotBlocksEveryRevisionAndRestartWithoutChangingRowsOrReceipts() throws Exception { + UUID animalId; + try (var fixture = new HusbandryOwnershipMigrationTest.Repository(directory.resolve("farm.db"))) { + var repo = fixture.value; var animal = own(repo, testator, "owner"); animalId = animal.uuid(); + repo.upsertOwner(new HusbandryOwner(animalId, coowner.ownerId(), coowner.characterId(), "coowner")); + var estate = estate(repo); assertEquals(Outcome.Kind.DONE, estate.settle(decision(1, Recipient.character(heir))).kind()); + repo.upsertOwner(new HusbandryOwner(animalId, testator.ownerId(), testator.characterId(), "owner")); + var connection = repo.ownershipDatabase().getConnection(); + var owners = rows(connection, "SELECT * FROM character_owners ORDER BY animal_uuid, character_uuid").stream().map(Arrays::asList).toList(); + var intents = rows(connection, "SELECT * FROM husbandry_estate_intents").stream().map(Arrays::asList).toList(); + var slots = rows(connection, "SELECT * FROM husbandry_estate_slots").stream().map(Arrays::asList).toList(); + for (var request : List.of(decision(1, Recipient.character(heir)), decision(2, Recipient.character(coowner)), + decision(3, Recipient.WAITING), decision(4, Recipient.FROZEN))) { + assertEquals(Outcome.Kind.BLOCKED, estate.settle(request).kind()); + assertEquals(owners, rows(connection, "SELECT * FROM character_owners ORDER BY animal_uuid, character_uuid").stream().map(Arrays::asList).toList()); + assertEquals(intents, rows(connection, "SELECT * FROM husbandry_estate_intents").stream().map(Arrays::asList).toList()); + assertEquals(slots, rows(connection, "SELECT * FROM husbandry_estate_slots").stream().map(Arrays::asList).toList()); + assertEquals(61, repo.getAnimal(animalId).orElseThrow().care()); + } + } + try (var fixture = new HusbandryOwnershipMigrationTest.Repository(directory.resolve("farm.db"))) { + assertEquals(Outcome.Kind.BLOCKED, estate(fixture.value).settle(decision(5, Recipient.character(heir))).kind()); + assertEquals(3, fixture.value.listOwners(animalId).size()); + } + } + @Test void completedEmptyReceiptRequiresNewRevisionForLateUntouchedHoldings() { + try (var fixture = new HusbandryOwnershipMigrationTest.Repository(directory.resolve("farm.db"))) { + var estate = estate(fixture.value); var request = decision(1, Recipient.character(heir)); + assertEquals(Outcome.done(), estate.settle(request)); + var late = own(fixture.value, testator, "owner"); + assertEquals(Outcome.Kind.BLOCKED, estate.settle(request).kind()); + assertEquals(testator, fixture.value.listOwners(late.uuid()).getFirst().ref()); + assertEquals(List.of(late.uuid().toString()), ((Outcome.Done)estate.settle(decision(2, Recipient.character(heir)))).movedIds()); + } + } + @Test void completedReceiptsValidateFullRefsAndSupportedDecisionEnvelope() throws Exception { + try (var fixture = new HusbandryOwnershipMigrationTest.Repository(directory.resolve("farm.db"))) { + var repo = fixture.value; var animal = own(repo, testator, "owner"); var estate = estate(repo); + var request = decision(1, Recipient.character(heir)); assertEquals(Outcome.Kind.DONE, estate.settle(request).kind()); + var connection = repo.ownershipDatabase().getConnection(); + Map original = Map.of("from_account",testator.ownerId().toString(),"from_character",testator.characterId().toString(), + "to_account",heir.ownerId().toString(),"to_character",heir.characterId().toString(),"revision","1","decision",request.toString()); + for (String field : List.of("from_account","from_character","to_account","to_character","revision","decision")) { + List invalid = field.equals("revision") ? List.of("invalid", "-1") : field.equals("decision") ? List.of("", "wrong") : List.of(UUID.randomUUID().toString()); + for (String value : invalid) { + update(connection,"UPDATE husbandry_estate_intents SET " + field + " = ?",value); + assertEquals(Outcome.Kind.BLOCKED,estate.settle(request).kind(),field+"="+value); + assertEquals(heir,repo.listOwners(animal.uuid()).getFirst().ref()); + update(connection,"UPDATE husbandry_estate_intents SET " + field + " = ?",original.get(field)); + } + } + update(connection,"UPDATE husbandry_estate_intents SET to_account = 'invalid'"); + assertEquals(Outcome.Kind.BLOCKED,estate.settle(decision(2,Recipient.FROZEN)).kind()); + } + } + @Test void historicalEnvelopeCorruptionBlocksNewWaitingAndCharacterRevisionsWithoutChangingState() throws Exception { + try (var fixture = new HusbandryOwnershipMigrationTest.Repository(directory.resolve("farm.db"))) { + var repo = fixture.value; var animal = own(repo,testator,"owner");var estate=estate(repo); + var request=decision(1,Recipient.character(heir));assertEquals(Outcome.Kind.DONE,estate.settle(request).kind()); + var connection=repo.ownershipDatabase().getConnection(); + Map original=Map.of("to_account",heir.ownerId().toString(),"to_character",heir.characterId().toString(), + "decision",request.toString(),"revision","1"); + for(String field:List.of("to_account","to_character","decision","revision")) { + String value=field.equals("decision")?"wrong":field.equals("revision")?"2":UUID.randomUUID().toString(); + update(connection,"UPDATE husbandry_estate_intents SET "+field+" = ?",value); + var intents=rows(connection,"SELECT * FROM husbandry_estate_intents").stream().map(Arrays::asList).toList(); + var slots=rows(connection,"SELECT * FROM husbandry_estate_slots").stream().map(Arrays::asList).toList(); + var archive=rows(connection,"SELECT * FROM husbandry_estate_owner_archive").stream().map(Arrays::asList).toList(); + for(var next:List.of(decision(3,Recipient.WAITING),decision(4,Recipient.character(coowner)))) { + assertEquals(Outcome.Kind.BLOCKED,estate.settle(next).kind(),field); + assertEquals(heir,repo.listOwners(animal.uuid()).getFirst().ref()); + assertEquals(intents,rows(connection,"SELECT * FROM husbandry_estate_intents").stream().map(Arrays::asList).toList()); + assertEquals(slots,rows(connection,"SELECT * FROM husbandry_estate_slots").stream().map(Arrays::asList).toList()); + assertEquals(archive,rows(connection,"SELECT * FROM husbandry_estate_owner_archive").stream().map(Arrays::asList).toList()); + } + update(connection,"UPDATE husbandry_estate_intents SET "+field+" = ?",original.get(field)); + } + } + } + @Test void missingCompletedIdsCannotHideRestoredSourcesFromNewRevisions() throws Exception { + try(var fixture=new HusbandryOwnershipMigrationTest.Repository(directory.resolve("farm.db"))) { + var repo=fixture.value;var animal=own(repo,testator,"owner");var estate=estate(repo); + assertEquals(Outcome.Kind.DONE,estate.settle(decision(1,Recipient.character(heir))).kind()); + var connection=repo.ownershipDatabase().getConnection();update(connection,"DELETE FROM husbandry_estate_slots"); + repo.upsertOwner(new HusbandryOwner(animal.uuid(),testator.ownerId(),testator.characterId(),"owner")); + var before=rows(connection,"SELECT * FROM character_owners ORDER BY character_uuid").stream().map(Arrays::asList).toList(); + assertEquals(Outcome.Kind.BLOCKED,estate.settle(decision(2,Recipient.character(coowner))).kind()); + assertEquals(before,rows(connection,"SELECT * FROM character_owners ORDER BY character_uuid").stream().map(Arrays::asList).toList()); + assertEquals(1,scalar(connection,"SELECT COUNT(*) FROM husbandry_estate_intents")); + assertEquals(0,scalar(connection,"SELECT COUNT(*) FROM husbandry_estate_slots")); + assertEquals(1,scalar(connection,"SELECT COUNT(*) FROM husbandry_estate_owner_archive")); + } + } + @Test void completedReceiptIdsAndArchivedRefsStayCoherentAcrossEveryRevision() throws Exception { + try(var fixture=new HusbandryOwnershipMigrationTest.Repository(directory.resolve("farm.db"))) { + var repo=fixture.value;var animal=own(repo,testator,"owner");var estate=estate(repo); + assertEquals(Outcome.Kind.DONE,estate.settle(decision(1,Recipient.character(heir))).kind()); + var connection=repo.ownershipDatabase().getConnection(); + for(String field:List.of("account_uuid","character_uuid")) { + update(connection,"UPDATE husbandry_estate_owner_archive SET "+field+" = ?",UUID.randomUUID().toString()); + assertEquals(Outcome.Kind.BLOCKED,estate.settle(decision(2,Recipient.FROZEN)).kind()); + update(connection,"UPDATE husbandry_estate_owner_archive SET "+field+" = ?",field.equals("account_uuid")?testator.ownerId().toString():testator.characterId().toString()); + } + update(connection,"INSERT INTO husbandry_estate_owner_archive SELECT * FROM husbandry_estate_owner_archive"); + assertEquals(Outcome.Kind.BLOCKED,estate.settle(decision(2,Recipient.FROZEN)).kind()); + update(connection,"DELETE FROM husbandry_estate_owner_archive WHERE rowid=(SELECT MAX(rowid) FROM husbandry_estate_owner_archive)"); + update(connection,"UPDATE husbandry_estate_slots SET animal_uuid = ?",UUID.randomUUID().toString()); + assertEquals(Outcome.Kind.BLOCKED,estate.settle(decision(2,Recipient.FROZEN)).kind()); + assertEquals(heir,repo.listOwners(animal.uuid()).getFirst().ref()); + } + } + @Test void faultAfterFirstSlotRollsBackWholeEstateThenRetryMovesExactlyOnce() throws Exception { + try (var fixture = new HusbandryOwnershipMigrationTest.Repository(directory.resolve("farm.db"))) { + var repo = fixture.value; var first = own(repo, testator, "owner"); var second = own(repo, testator, "owner"); + var connection = repo.ownershipDatabase().getConnection(); var estate = estate(repo); + update(connection, "CREATE TRIGGER fail_move BEFORE DELETE ON character_owners WHEN OLD.animal_uuid = '" + second.uuid() + "' BEGIN SELECT RAISE(ABORT,'fault'); END"); + assertEquals(Outcome.Kind.RETRY, estate.settle(decision(1, Recipient.character(heir))).kind()); + assertEquals(testator, repo.listOwners(first.uuid()).getFirst().ref()); assertEquals(testator, repo.listOwners(second.uuid()).getFirst().ref()); + assertEquals(0, scalar(connection, "SELECT COUNT(*) FROM husbandry_estate_intents")); + assertEquals(0, scalar(connection, "SELECT COUNT(*) FROM husbandry_estate_slots")); + update(connection, "DROP TRIGGER fail_move"); + assertEquals(2, ((Outcome.Done)estate.settle(decision(1, Recipient.character(heir)))).movedIds().size()); + } + } + @Test void unknownRolesAccountsAndIncompleteOrConflictingIntentsAreBlocked() throws Exception { + try (var fixture = new HusbandryOwnershipMigrationTest.Repository(directory.resolve("farm.db"))) { + var repo = fixture.value; var animal = own(repo, testator, "owner"); var estate = estate(repo); var connection = repo.ownershipDatabase().getConnection(); + update(connection, "UPDATE character_owners SET role = 'unknown'"); + assertEquals(Outcome.Kind.BLOCKED, estate.settle(decision(1, Recipient.character(heir))).kind()); + update(connection, "UPDATE character_owners SET role = 'owner', account_uuid = ?", coowner.ownerId().toString()); + assertEquals(Outcome.Kind.BLOCKED, estate.settle(decision(1, Recipient.character(heir))).kind()); + update(connection, "UPDATE character_owners SET account_uuid = ?", testator.ownerId().toString()); + update(connection, "INSERT INTO character_owners VALUES (?, ?, ?, 'future')", animal.uuid().toString(), heir.ownerId().toString(), heir.characterId().toString()); + assertEquals(Outcome.Kind.BLOCKED, estate.settle(decision(1, Recipient.character(heir))).kind()); + update(connection, "UPDATE character_owners SET role = 'owner', account_uuid = ? WHERE character_uuid = ?", coowner.ownerId().toString(), heir.characterId().toString()); + assertEquals(Outcome.Kind.BLOCKED, estate.settle(decision(1, Recipient.character(heir))).kind()); + update(connection, "DELETE FROM character_owners WHERE character_uuid = ?", heir.characterId().toString()); + assertEquals(Outcome.Kind.DONE, estate.settle(decision(1, Recipient.character(heir))).kind()); + assertEquals(Outcome.Kind.BLOCKED, estate.settle(decision(1, Recipient.character(coowner))).kind()); + assertEquals(Outcome.Kind.BLOCKED, estate.settle(decision(1, Recipient.FROZEN)).kind()); + update(connection, "UPDATE husbandry_estate_intents SET status = 'prepared'"); + assertEquals(Outcome.Kind.BLOCKED, estate.settle(decision(2, Recipient.character(heir))).kind()); + assertEquals(Outcome.Kind.BLOCKED, estate.settle(decision(3, Recipient.FROZEN)).kind()); + } + } + @Test void finalGuardFailureRollsBackEmptyPlanAndReconcileReportsPerRevisionAndContinuesFailures() { + try (var fixture = new HusbandryOwnershipMigrationTest.Repository(directory.resolve("farm.db"))) { + var estate = estate(fixture.value); var decision = decision(1, Recipient.character(heir)); + AtomicInteger checks = new AtomicInteger(); estate.guard = d -> checks.incrementAndGet() == 1; + assertEquals(Outcome.Kind.RETRY, estate.settle(decision).kind()); + estate.guard = d -> true; estate.decided = () -> List.of(decision); + AtomicInteger reports = new AtomicInteger(); estate.reporter = (death,id,revision,outcome) -> { reports.incrementAndGet(); return true; }; + estate.reconcile(); estate.reconcile(); assertEquals(1, reports.get()); + estate.decided = () -> List.of(decision(2, Recipient.FROZEN)); estate.reconcile(); assertEquals(2, reports.get()); + estate.decided = () -> List.of(decision(3, Recipient.FROZEN)); estate.reporter = (death,id,revision,outcome) -> false; estate.reconcile(); + estate.guard = d -> false; estate.reconcile(); + estate.guard = d -> { throw new IllegalStateException("Unavailable identity"); }; assertDoesNotThrow(estate::reconcile); + } + } + @Test void unreadableHoldingsSchemaIsReportedRatherThanAssumedEmpty() throws Exception { + try (var fixture = new HusbandryOwnershipMigrationTest.Repository(directory.resolve("farm.db"))) { + var estate = estate(fixture.value); + update(fixture.value.ownershipDatabase().getConnection(), "DROP TABLE character_owners"); + assertThrows(net.tfminecraft.tlibs.database.SqliteDatabaseException.class, () -> estate.describe(testator.characterId())); + } + } + @Test void unknownNullableAndBlobSlotMetadataSurvivesTransferAndLaterSchemaExpansion() throws Exception { + try (var fixture = new HusbandryOwnershipMigrationTest.Repository(directory.resolve("farm.db"))) { + var repo = fixture.value; var animal = own(repo, testator, "owner"); var connection = repo.ownershipDatabase().getConnection(); + update(connection,"ALTER TABLE character_owners ADD COLUMN unknown TEXT"); + update(connection,"ALTER TABLE character_owners ADD COLUMN payload BLOB"); + update(connection,"UPDATE character_owners SET payload = ?", new byte[]{0,3,-1}); + var estate=estate(repo); assertEquals(Outcome.Kind.DONE,estate.settle(decision(1,Recipient.character(heir))).kind()); + assertNull(rows(connection,"SELECT unknown FROM husbandry_estate_owner_archive").getFirst()[0]); + try(var statement=connection.prepareStatement("SELECT payload FROM husbandry_estate_owner_archive");var result=statement.executeQuery()) { + result.next(); assertArrayEquals(new byte[]{0,3,-1},result.getBytes(1)); + } + update(connection,"ALTER TABLE character_owners ADD COLUMN next_column TEXT DEFAULT 'kept'"); + var late=own(repo,testator,"owner"); + assertEquals(Outcome.Kind.DONE,estate.settle(decision(2,Recipient.character(heir))).kind()); + assertEquals("kept",rows(connection,"SELECT next_column FROM husbandry_estate_owner_archive WHERE animal_uuid=?",late.uuid().toString()).getFirst()[0]); + update(connection,"ALTER TABLE character_owners ADD COLUMN estate_death_id TEXT"); + own(repo,testator,"owner"); + assertEquals(Outcome.Kind.BLOCKED,estate.settle(decision(3,Recipient.character(heir))).kind()); + } + } +} diff --git a/src/test/java/net/tfminecraft/breedingbuddies/husbandry/HusbandryExpBracketTest.java b/src/test/java/net/tfminecraft/breedingbuddies/husbandry/HusbandryExpBracketTest.java new file mode 100644 index 0000000..f66b1ab --- /dev/null +++ b/src/test/java/net/tfminecraft/breedingbuddies/husbandry/HusbandryExpBracketTest.java @@ -0,0 +1,47 @@ +package net.tfminecraft.breedingbuddies.husbandry; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertTrue; + +import org.junit.jupiter.api.Test; + +class HusbandryExpBracketTest { + + @Test + void rollStaysInsideAnInclusiveBracket() { + HusbandryExpBracket reversed = HusbandryExpBracket.of(8, 6); + assertEquals(6, reversed.min()); + assertEquals(8, reversed.max()); + for (int i = 0; i < 40; i++) { + int roll = reversed.roll(); + assertTrue(roll >= 6 && roll <= 8); + } + assertEquals(6, HusbandryExpBracket.of(6, 6).roll()); + } + + @Test + void speciesOverrideIsSeparateFromTheDefaultBracket() { + HusbandryConfig.setProfessionExp("farming", HusbandryExpBracket.of(6, 8)); + HusbandrySpecies cow = new HusbandrySpecies( + null, + false, + "", + HusbandryDropTable.empty(), + HusbandryDropTable.empty(), + HusbandryDropTable.empty(), + "", + 0, + 0, + 0, + HusbandryExpBracket.of(5, 10)); + assertEquals(5, cow.exp().min()); + assertEquals(10, cow.exp().max()); + + HusbandryExpBracket fallback = HusbandryConfig.expFor(null); + assertEquals(6, fallback.min()); + assertEquals(8, fallback.max()); + + HusbandryConfig.setProfessionExp(" ", HusbandryExpBracket.of(6, 8)); + assertTrue(HusbandryConfig.professionId().isBlank()); + } +} diff --git a/src/test/java/net/tfminecraft/breedingbuddies/husbandry/HusbandryFeedQualityTest.java b/src/test/java/net/tfminecraft/breedingbuddies/husbandry/HusbandryFeedQualityTest.java new file mode 100644 index 0000000..2140a3a --- /dev/null +++ b/src/test/java/net/tfminecraft/breedingbuddies/husbandry/HusbandryFeedQualityTest.java @@ -0,0 +1,67 @@ +package net.tfminecraft.breedingbuddies.husbandry; + +import static org.junit.jupiter.api.Assertions.assertEquals; + +import java.util.UUID; + +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; + +class HusbandryFeedQualityTest { + + @BeforeEach + void resetFeeds() { + HusbandryFeedQuality.clear(); + } + + @Test + void starsScaleLinearlyToAFullBonus() { + assertEquals(0.2, HusbandryFeedQuality.scaleForStars(1), 1e-9); + assertEquals(1.0, HusbandryFeedQuality.scaleForStars(5), 1e-9); + assertEquals(1.0, HusbandryFeedQuality.scaleForStars(9), 1e-9); + } + + @Test + void missingFeedsKeepTheFullBonus() { + assertEquals(1.0, HusbandryFeedQuality.combine(null, null), 1e-9); + assertEquals(0.2, HusbandryFeedQuality.combine(0.2, null), 1e-9); + } + + @Test + void rememberedFeedsAverageUntilTheyExpire() { + UUID mother = UUID.randomUUID(); + UUID father = UUID.randomUUID(); + long now = 5_000L; + HusbandryFeedQuality.remember(mother, HusbandryFeedQuality.scaleForStars(1), now); + HusbandryFeedQuality.remember(father, HusbandryFeedQuality.scaleForStars(5), now); + assertEquals(0.6, HusbandryFeedQuality.takePair(mother, father, now), 1e-9); + assertEquals(1.0, HusbandryFeedQuality.takePair(mother, father, now), 1e-9); + } + + @Test + void offerCommitsOnlyInsideTheClickWindow() { + UUID animal = UUID.randomUUID(); + HusbandryFeedQuality.offer(animal, 0.4, 1_000L); + HusbandryFeedQuality.commitOffer(animal, 1_000L); + assertEquals(0.4, HusbandryFeedQuality.takePair(animal, null, 1_000L), 1e-9); + + HusbandryFeedQuality.offer(animal, 0.4, 1_000L); + HusbandryFeedQuality.commitOffer(animal, 4_000L); + assertEquals(1.0, HusbandryFeedQuality.takePair(animal, null, 4_000L), 1e-9); + } + + @Test + void expiredLoveFeedIsIgnored() { + UUID animal = UUID.randomUUID(); + HusbandryFeedQuality.remember(animal, 0.2, 1_000L); + assertEquals(1.0, HusbandryFeedQuality.takePair(animal, null, 1_000L + 45_001L), 1e-9); + } + + @Test + void clearDropsRememberedFeeds() { + UUID animal = UUID.randomUUID(); + HusbandryFeedQuality.remember(animal, 0.2, 1_000L); + HusbandryFeedQuality.clear(); + assertEquals(1.0, HusbandryFeedQuality.takePair(animal, null, 1_000L), 1e-9); + } +} diff --git a/src/test/java/net/tfminecraft/breedingbuddies/husbandry/HusbandryGeneticsTest.java b/src/test/java/net/tfminecraft/breedingbuddies/husbandry/HusbandryGeneticsTest.java new file mode 100644 index 0000000..b93b58e --- /dev/null +++ b/src/test/java/net/tfminecraft/breedingbuddies/husbandry/HusbandryGeneticsTest.java @@ -0,0 +1,93 @@ +package net.tfminecraft.breedingbuddies.husbandry; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertTrue; + +import java.util.Random; + +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; + +class HusbandryGeneticsTest { + + @BeforeEach + void breedingDefaults() { + HusbandryConfig.setBreeding(0.4, 0.4, 0.02); + } + + @Test + void lowGeneMaxCareChildStaysFarBelowCap() { + int child = HusbandryGenetics.roll(10, 10, 200, 200, maxBonus()); + assertTrue(child < 100, "child " + child); + assertEquals(68, child); + } + + @Test + void zeroCareStaysAtParentAverage() { + Random random = new Random(1L); + for (int i = 0; i < 50; i++) { + assertEquals(10, HusbandryGenetics.roll(10, 10, 0, 0, random)); + assertEquals(15, HusbandryGenetics.roll(10, 21, 0, 0, random)); + } + } + + @Test + void maxCareBeatsZeroCareWhenBonusIsNonZero() { + int zeroCare = HusbandryGenetics.roll(10, 10, 0, 0, maxBonus()); + int maxCare = HusbandryGenetics.roll(10, 10, 200, 200, maxBonus()); + assertEquals(10, zeroCare); + assertTrue(maxCare > zeroCare, "maxCare " + maxCare); + } + + @Test + void resultIsClampedToMaxGenetics() { + int child = HusbandryGenetics.roll(1000, 1000, 200, 200, maxBonus()); + assertEquals(1000, child); + } + + @Test + void resultIsClampedToZero() { + int child = HusbandryGenetics.roll(-50, -50, 0, 0, maxBonus()); + assertEquals(0, child); + } + + @Test + void fiveStarFeedMatchesTheUnscaledRoll() { + int full = HusbandryGenetics.roll(10, 10, 200, 200, maxBonus()); + int fiveStars = HusbandryGenetics.roll( + 10, 10, 200, 200, maxBonus(), HusbandryFeedQuality.scaleForStars(5)); + assertEquals(68, full); + assertEquals(full, fiveStars); + } + + @Test + void oneStarFeedKeepsOneFifthOfTheBoost() { + int child = HusbandryGenetics.roll( + 10, 10, 200, 200, maxBonus(), HusbandryFeedQuality.scaleForStars(1)); + assertEquals(21, child); + } + + @Test + void mixedOneAndFiveStarFeedsUseTheAverage() { + double scale = HusbandryFeedQuality.combine( + HusbandryFeedQuality.scaleForStars(1), + HusbandryFeedQuality.scaleForStars(5)); + int child = HusbandryGenetics.roll(10, 10, 200, 200, maxBonus(), scale); + assertEquals(44, child); + } + + @Test + void zeroCareIgnoresFeedQuality() { + assertEquals(10, HusbandryGenetics.roll( + 10, 10, 0, 0, maxBonus(), HusbandryFeedQuality.scaleForStars(1))); + } + + private static Random maxBonus() { + return new Random() { + @Override + public int nextInt(int bound) { + return Math.max(0, bound - 1); + } + }; + } +} diff --git a/src/test/java/net/tfminecraft/breedingbuddies/husbandry/HusbandryGrowthTest.java b/src/test/java/net/tfminecraft/breedingbuddies/husbandry/HusbandryGrowthTest.java new file mode 100644 index 0000000..9981658 --- /dev/null +++ b/src/test/java/net/tfminecraft/breedingbuddies/husbandry/HusbandryGrowthTest.java @@ -0,0 +1,31 @@ +package net.tfminecraft.breedingbuddies.husbandry; + +import static org.junit.jupiter.api.Assertions.assertEquals; + +import java.util.UUID; + +import org.junit.jupiter.api.Test; + +class HusbandryGrowthTest { + + @Test + void immatureRecordKeepsTheBabyModel() { + HusbandryAnimal animal = new HusbandryAnimal(UUID.randomUUID(), "COW", "Calf"); + long now = 1_000_000L; + animal.setMatureAt(now + 60_000L); + assertEquals(HusbandryGrowth.ModelAge.BABY, HusbandryGrowth.modelAge(animal, now)); + } + + @Test + void dueAndStoredAdultsUseTheAdultModel() { + long now = 5_000L; + HusbandryAnimal due = new HusbandryAnimal(UUID.randomUUID(), "CHICKEN", "Chick"); + due.setMatureAt(now); + assertEquals(HusbandryGrowth.ModelAge.ADULT, HusbandryGrowth.modelAge(due, now)); + + HusbandryAnimal adult = new HusbandryAnimal(UUID.randomUUID(), "COW", "Cow"); + adult.setMatureAt(null); + assertEquals(HusbandryGrowth.ModelAge.ADULT, HusbandryGrowth.modelAge(adult, now)); + assertEquals(HusbandryGrowth.ModelAge.ADULT, HusbandryGrowth.modelAge(null, now)); + } +} diff --git a/src/test/java/net/tfminecraft/breedingbuddies/husbandry/HusbandryInspectionCoverageTest.java b/src/test/java/net/tfminecraft/breedingbuddies/husbandry/HusbandryInspectionCoverageTest.java new file mode 100644 index 0000000..e1c1dad --- /dev/null +++ b/src/test/java/net/tfminecraft/breedingbuddies/husbandry/HusbandryInspectionCoverageTest.java @@ -0,0 +1,189 @@ +package net.tfminecraft.breedingbuddies.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("Cow", plain(f.player.getOpenInventory().title())); assertEquals("Cow", name(f.player.getOpenInventory().getTopInventory(), HusbandryInspectGui.NAME_SLOT)); + } + + @Test void inspectionShowsTheBreedingBuddiesRowsOfTrustAndGenetics() { + 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(27, inventory.getSize()); assertEquals("Happy", name(inventory, HusbandryInspectGui.STATUS_SLOT)); assertEquals("Not neutered", name(inventory, HusbandryInspectGui.NEUTERED_SLOT)); + assertEquals(List.of("owner: Farmer"), lore(inventory, HusbandryInspectGui.OWNERS_SLOT)); assertTrue(lore(inventory, HusbandryInspectGui.PRODUCTS_SLOT).contains("Yield 24%")); assertTrue(lore(inventory, HusbandryInspectGui.NAME_SLOT).contains("Grown up")); + assertEquals(Material.WRITABLE_BOOK, inventory.getItem(26).getType()); + for (int i = 0; i < 5; i++) assertEquals(i < 2 ? Material.RED_DYE : Material.GRAY_DYE, inventory.getItem(HusbandryInspectGui.TRUST_START + i).getType()); + assertEquals("Trust ❤❤❤❤❤", name(inventory, HusbandryInspectGui.TRUST_START)); assertEquals("40/100", lore(inventory, HusbandryInspectGui.TRUST_START).getFirst()); + for (int i = 0; i < 5; i++) assertEquals(i < 3 ? Material.SLIME_SPAWN_EGG : Material.VEX_SPAWN_EGG, inventory.getItem(HusbandryInspectGui.GENETICS_START + i).getType()); + assertTrue(name(inventory, HusbandryInspectGui.GENETICS_START).startsWith("Genetics ★★★★★")); assertEquals("600/1000", lore(inventory, HusbandryInspectGui.GENETICS_START).getFirst()); + 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(26)); assertEquals("Neutered", name(inventory, HusbandryInspectGui.NEUTERED_SLOT)); + assertTrue(lore(inventory, HusbandryInspectGui.STATUS_SLOT).contains("Hungry: feed it Universal Feed.")); assertTrue(lore(inventory, HusbandryInspectGui.STATUS_SLOT).stream().anyMatch(l -> l.startsWith("It starts losing trust in "))); assertTrue(lore(inventory, HusbandryInspectGui.NAME_SLOT).get(1).startsWith("Grows up in ")); + } + + @Test void inspectionDisplaysMountStatGenesAndAllAfflictionStatuses() throws Exception { + Horse horse = f.spawn(Horse.class); HusbandryAnimal animal = f.record(horse); HusbandryMountGenes.set(animal, 300, 600, 900); HusbandryMounts.applyStats(horse, animal); + HusbandryInspectGui.open(f.player, horse, animal); Inventory inventory = f.player.getOpenInventory().getTopInventory(); + assertEquals(36, inventory.getSize()); assertTrue(name(inventory, HusbandryInspectGui.HEALTH_SLOT).matches("Health \\d+\\.\\d hearts")); assertTrue(name(inventory, HusbandryInspectGui.SPEED_SLOT).endsWith(" blocks/s")); assertTrue(name(inventory, HusbandryInspectGui.JUMP_SLOT).endsWith(" blocks")); + assertEquals(List.of("Gene 300/1000"), lore(inventory, HusbandryInspectGui.HEALTH_SLOT)); assertEquals(List.of("Gene 900/1000"), lore(inventory, HusbandryInspectGui.JUMP_SLOT)); + assertNull(inventory.getItem(HusbandryInspectGui.PRODUCTS_SLOT)); assertEquals(List.of("Nobody yet: tame it with a named token."), lore(inventory, HusbandryInspectGui.OWNERS_SLOT)); assertTrue(lore(inventory, HusbandryInspectGui.STATUS_SLOT).getFirst().contains("ride")); + animal.setDirtySince(1L); HusbandryInspectGui.open(f.player, horse, animal); inventory = f.player.getOpenInventory().getTopInventory(); assertTrue(lore(inventory, HusbandryInspectGui.STATUS_SLOT).contains("Dirty: brush it with a Glove.")); assertTrue(lore(inventory, HusbandryInspectGui.STATUS_SLOT).get(1).startsWith("Losing trust: -1 every ")); + 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, HusbandryInspectGui.STATUS_SLOT).contains("Hungry: feed it Universal Feed.")); assertTrue(lore(inventory, HusbandryInspectGui.STATUS_SLOT).get(2).startsWith("Losing trust: -0.5 every ")); + animal.setDirtySince(null); HusbandryInspectGui.open(f.player, horse, animal); assertTrue(lore(f.player.getOpenInventory().getTopInventory(), HusbandryInspectGui.STATUS_SLOT).get(1).startsWith("Losing trust")); + Pig pig = f.spawn(Pig.class); HusbandryAnimal piglet = f.record(pig); HusbandryInspectGui.open(f.player, pig, piglet); assertNull(f.player.getOpenInventory().getTopInventory().getItem(HusbandryInspectGui.PRODUCTS_SLOT)); assertTrue(lore(f.player.getOpenInventory().getTopInventory(), HusbandryInspectGui.STATUS_SLOT).getFirst().contains("pet")); + 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, 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("coowner: " + owner), lore(f.player.getOpenInventory().getTopInventory(), HusbandryInspectGui.OWNERS_SLOT)); + 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)); + listener.onClick(click(26)); assertTrue(HusbandryOwnershipService.isOwner(f.player, animal.uuid()), "The first click only asks to confirm"); + assertEquals("Click again to give it up", name(f.player.getOpenInventory().getTopInventory(), 26)); + when(f.plugin.getHusbandryRepository()).thenReturn(null); listener.onClick(click(26)); when(f.plugin.getHusbandryRepository()).thenReturn(f.repository); assertTrue(HusbandryOwnershipService.isOwner(f.player, animal.uuid())); + listener.onClick(click(26)); 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.deleteCharacterOwner(animal.uuid(), f.player.getUniqueId()); listener.onClick(click(26)); listener.onClick(click(26)); 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(26)); listener.onClick(click(26)); 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((net.tfminecraft.tlibs.character.CharacterRef) null, a.uuid())); assertFalse(HusbandryOwnershipService.isOwner(new net.tfminecraft.tlibs.character.CharacterRef(f.player.getUniqueId(), 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"); f.characters.activate(stranger, stranger.getUniqueId()); 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("cooking", "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); assertTrue(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(26)).thenReturn(top); when(view.getPlayer()).thenReturn(viewer); + InventoryClickEvent nonPlayer = new InventoryClickEvent(view, InventoryType.SlotType.CONTAINER, 26, ClickType.LEFT, InventoryAction.PICKUP_ALL); listener.onClick(nonPlayer); assertTrue(nonPlayer.isCancelled()); assertTrue(HusbandryOwnershipService.isOwner(f.player, animal.uuid())); + top.setItem(26, new ItemStack(Material.STONE)); listener.onClick(click(26)); top.setItem(26, null); listener.onClick(click(26)); 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().lore().stream().map(HusbandryInspectionCoverageTest::plain).toList(); } + private static String plain(net.kyori.adventure.text.Component component) { return net.kyori.adventure.text.serializer.plain.PlainTextComponentSerializer.plainText().serialize(component); } + + 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 plain(inventory.getItem(slot).getItemMeta().displayName()); } +} diff --git a/src/test/java/net/tfminecraft/breedingbuddies/husbandry/HusbandryListenersCoverageTest.java b/src/test/java/net/tfminecraft/breedingbuddies/husbandry/HusbandryListenersCoverageTest.java new file mode 100644 index 0000000..a3f45a1 --- /dev/null +++ b/src/test/java/net/tfminecraft/breedingbuddies/husbandry/HusbandryListenersCoverageTest.java @@ -0,0 +1,348 @@ +package net.tfminecraft.breedingbuddies.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.breedingbuddies.BreedingBuddies; +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 net.tfminecraft.breedingbuddies.testsupport.TestCharacters characters; + private WorldMock world; + private PlayerMock player, target; + private BreedingBuddies 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 = BreedingBuddies.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)); + BreedingBuddies.plugin = mock(BreedingBuddies.class); when(BreedingBuddies.plugin.getName()).thenReturn("BreedingBuddies"); when(BreedingBuddies.plugin.namespace()).thenReturn("breedingbuddies"); when(BreedingBuddies.plugin.isEnabled()).thenReturn(true); + characters = new net.tfminecraft.breedingbuddies.testsupport.TestCharacters(BreedingBuddies.plugin); + characters.activate(player, player.getUniqueId()); characters.activate(target, target.getUniqueId()); + repository = HusbandryRepository.open(directory.resolve("husbandry.db").toFile()); when(BreedingBuddies.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); + mocks.add(new CookingApiStub().api); + 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(); + characters.close(); MockBukkit.unmock(); for (int i = mocks.size() - 1; i >= 0; i--) mocks.get(i).close(); + BreedingBuddies.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(CookingApiStub.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(BreedingBuddies.plugin.getHusbandryRepository()).thenReturn(null); assertBlockedLove(cow, "Could not breed"); + when(BreedingBuddies.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(BreedingBuddies.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(BreedingBuddies.plugin.getHusbandryRepository()).thenReturn(null); var noRepo = interact(cow); harvest.onMilk(noRepo); assertTrue(noRepo.isCancelled()); + when(BreedingBuddies.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(BreedingBuddies.plugin.getHusbandryRepository()).thenReturn(null); harvest.onShearsInteract(interact(goat)); when(BreedingBuddies.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(BreedingBuddies.plugin.getHusbandryRepository()).thenReturn(null); harvest.onShear(shear(goat)); when(BreedingBuddies.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(BreedingBuddies.plugin.getHusbandryRepository()).thenReturn(null); var unavailable = new EntityDropItemEvent(chicken, egg); harvest.onDropEgg(unavailable); assertFalse(unavailable.isCancelled()); when(BreedingBuddies.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(BreedingBuddies.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(BreedingBuddies.plugin.getHusbandryRepository()).thenReturn(null); taming.onInteractEntity(interact(cow)); when(BreedingBuddies.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(BreedingBuddies.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.deleteCharacterOwner(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.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/breedingbuddies/husbandry/HusbandryLivingProductsTest.java b/src/test/java/net/tfminecraft/breedingbuddies/husbandry/HusbandryLivingProductsTest.java new file mode 100644 index 0000000..a08f016 --- /dev/null +++ b/src/test/java/net/tfminecraft/breedingbuddies/husbandry/HusbandryLivingProductsTest.java @@ -0,0 +1,77 @@ +package net.tfminecraft.breedingbuddies.husbandry; + +import static org.junit.jupiter.api.Assertions.*; + +import java.nio.file.Path; +import java.util.List; +import java.util.Map; + +import org.bukkit.Material; +import org.bukkit.entity.Chicken; +import org.bukkit.entity.Cow; +import org.bukkit.entity.EntityType; +import org.bukkit.entity.Item; +import org.bukkit.entity.Pig; +import org.bukkit.entity.Sheep; +import org.bukkit.event.player.PlayerInteractEntityEvent; +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 net.tfminecraft.breedingbuddies.husbandry.HusbandryHarvest.ShearResult; +import net.tfminecraft.breedingbuddies.husbandry.HusbandryRuntimeCoverageTest.Farm; + +class HusbandryLivingProductsTest { + @TempDir Path directory; + private Farm f; + + @BeforeEach void open() throws Exception { + f = new Farm(directory); + for (String species : List.of("PIG", "CHICKEN", "SHEEP")) { + f.config.set("species." + species + ".shed.drops.common", List.of(Map.of("path", "v.stick", "amount", 1, "weight", 1))); + } + f.config.set("species.SHEEP.shear.drops.common", List.of(Map.of("path", "v.white_wool", "amount", 1, "weight", 1))); + f.config.set("shed-chance", 1.0); + f.reload(); + } + + @AfterEach void close() throws Exception { f.close(); } + + @Test void pigsRootAndChickensMoultWhenTheyShed() { + Pig pig = f.spawn(Pig.class); HusbandryAnimal rooting = f.record(pig); + Chicken chicken = f.spawn(Chicken.class); HusbandryAnimal moulting = f.record(chicken); + Sheep sheep = f.spawn(Sheep.class); HusbandryAnimal shedding = f.record(sheep); + long now = System.currentTimeMillis(); + HusbandryShed.tryShed(pig, rooting, now); + HusbandryShed.tryShed(chicken, moulting, now); + HusbandryShed.tryShed(sheep, shedding, now); + assertTrue(rooting.shedReadyAt() > now); + assertTrue(moulting.shedReadyAt() > now); + assertTrue(shedding.shedReadyAt() > now); + } + + @Test void woolExtrasWaitForAHappySheep() { + Sheep sheep = f.spawn(Sheep.class); HusbandryAnimal animal = f.record(sheep); animal.setDirtySince(1L); + HusbandrySpecies species = HusbandryConfig.species(EntityType.SHEEP); + assertEquals(ShearResult.UNWELL, HusbandryHarvest.shearReadiness(animal, species, 0)); + assertEquals(ShearResult.UNWELL, HusbandryHarvest.tryShear(f.player, sheep, animal, species, f.repository, 0)); + assertEquals("§cThis animal needs care before it gives wool.", f.player.nextMessage()); + assertEquals(ShearResult.UNWELL, HusbandryHarvest.tryShear(null, sheep, animal, species, f.repository, 0)); + assertEquals(ShearResult.UNWELL, HusbandryHarvest.trySheepBonusShear(f.player, animal, species, f.repository, 0)); + assertNull(f.player.nextMessage(), "Vanilla wool still comes off; only the extras wait"); + } + + @Test void milkWaitsForAHappyCow() { + Cow cow = f.spawn(Cow.class); HusbandryAnimal animal = f.record(cow); animal.setHungrySince(1L); + f.player.getInventory().setItemInMainHand(new ItemStack(Material.BUCKET)); + PlayerInteractEntityEvent milking = new PlayerInteractEntityEvent(f.player, cow); + new HusbandryHarvestListener().onMilk(milking); + assertTrue(milking.isCancelled()); + assertEquals("§cThis animal needs care before it gives milk.", f.player.nextMessage()); + assertEquals(Material.BUCKET, f.player.getInventory().getItemInMainHand().getType()); + assertNull(animal.lastMilkAt()); + assertTrue(f.world.getEntitiesByClass(Item.class).isEmpty()); + } +} diff --git a/src/test/java/net/tfminecraft/breedingbuddies/husbandry/HusbandryLoaderCoverageTest.java b/src/test/java/net/tfminecraft/breedingbuddies/husbandry/HusbandryLoaderCoverageTest.java new file mode 100644 index 0000000..f2c6d31 --- /dev/null +++ b/src/test/java/net/tfminecraft/breedingbuddies/husbandry/HusbandryLoaderCoverageTest.java @@ -0,0 +1,216 @@ +package net.tfminecraft.breedingbuddies.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; + +public 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 + trust: {feed: 7, glove: 8, pet: 9, pet-cooldown: 2h} + care: + down: {interval: 30s, amount: 0.75} + decay-grace: 1h + 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(7, HusbandryConfig.trustFor(HusbandryTrust.Gesture.FEED)); assertEquals(8, HusbandryConfig.trustFor(HusbandryTrust.Gesture.GLOVE)); assertEquals(9, HusbandryConfig.trustFor(HusbandryTrust.Gesture.PET)); assertEquals(7200, HusbandryConfig.petCooldownSeconds()); + assertEquals(30, HusbandryConfig.careDownIntervalSeconds()); assertEquals(0.75, HusbandryConfig.careDownAmount()); + assertEquals(3600, HusbandryConfig.decayGraceSeconds()); 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.mountMinPct()); assertEquals(0.2, HusbandryConfig.mountGeneticsPct()); assertEquals(0.3, HusbandryConfig.mountCarePct()); + 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 mountSharesApplyToEveryStatAndTheOldSpeedNameStillLoads() throws Exception { + load("mounts:\n shares: {min-pct: 0.2, genetics-pct: 0.6, care-pct: 0.2}\n speed: {min-pct: 0.9}\n"); + assertEquals(0.2, HusbandryConfig.mountMinPct()); assertEquals(0.6, HusbandryConfig.mountGeneticsPct()); assertEquals(0.2, HusbandryConfig.mountCarePct()); + assertTrue(HusbandryConfig.mounts().isEmpty(), "Neither section is a mount type"); + load("mounts:\n speed: {min-pct: 0.3}\n"); + assertEquals(0.3, HusbandryConfig.mountMinPct()); assertEquals(0.3, HusbandryConfig.mountGeneticsPct()); assertEquals(0.3, HusbandryConfig.mountCarePct()); + } + + @Test void legacyDurationsAndEmptySectionsRemainSupported() throws Exception { + load(""" + care-down-per-hour: 3 + decay-grace-hours: 2.5 + 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.careDownIntervalSeconds()); assertEquals(3, HusbandryConfig.careDownAmount()); + assertEquals(9000, HusbandryConfig.decayGraceSeconds()); 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()); + assertEquals(10, HusbandryConfig.trustFor(HusbandryTrust.Gesture.FEED)); assertEquals(5, HusbandryConfig.trustFor(HusbandryTrust.Gesture.PET)); assertEquals(72000, HusbandryConfig.petCooldownSeconds()); + } + + @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, -1, 3, -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.careDownIntervalSeconds()); assertEquals(4, HusbandryConfig.afflictionMaxHours()); assertEquals(1, HusbandryConfig.maxGenetics()); + assertEquals(0, HusbandryConfig.decayGraceSeconds()); 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.setTrust(-1, -2, -3, -4); assertEquals(0, HusbandryConfig.trustFor(HusbandryTrust.Gesture.FEED)); assertEquals(0, HusbandryConfig.trustFor(HusbandryTrust.Gesture.GLOVE)); assertEquals(0, HusbandryConfig.trustFor(HusbandryTrust.Gesture.PET)); assertEquals(0, HusbandryConfig.petCooldownSeconds()); + HusbandryConfig.setMountShares(-1, -2, -3); assertEquals(0, HusbandryConfig.mountMinPct()); assertEquals(0, HusbandryConfig.mountGeneticsPct()); assertEquals(0, HusbandryConfig.mountCarePct()); + 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(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. */ + public static final class ConfigSnapshot implements AutoCloseable { + private final Map values = new LinkedHashMap<>(); + public 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/breedingbuddies/husbandry/HusbandryLocatorTest.java b/src/test/java/net/tfminecraft/breedingbuddies/husbandry/HusbandryLocatorTest.java new file mode 100644 index 0000000..e69c6d9 --- /dev/null +++ b/src/test/java/net/tfminecraft/breedingbuddies/husbandry/HusbandryLocatorTest.java @@ -0,0 +1,69 @@ +package net.tfminecraft.breedingbuddies.husbandry; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertTrue; + +import java.io.File; +import java.nio.file.Path; +import java.util.List; +import java.util.UUID; + +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.io.TempDir; + +class HusbandryLocatorTest { + + @Test + void onlyACompleteScanOfTheAnimalsWorldConfirmsAGhost() { + assertTrue(HusbandryLocator.isConfirmedGhost(true, "TFMC_Map", List.of("TFMC_Map", "TFMC_Map_nether"))); + assertTrue(HusbandryLocator.isConfirmedGhost(true, null, List.of("TFMC_Map"))); + assertTrue(HusbandryLocator.isConfirmedGhost(true, " ", List.of("TFMC_Map"))); + assertFalse(HusbandryLocator.isConfirmedGhost(false, "TFMC_Map", List.of("TFMC_Map"))); + assertFalse(HusbandryLocator.isConfirmedGhost(true, "other", List.of("TFMC_Map"))); + assertFalse(HusbandryLocator.isConfirmedGhost(true, "TFMC_Map", null)); + } + + @Test + void droppingAGhostRemovesTheRowAndOwners(@TempDir Path tempDir) { + File dbFile = tempDir.resolve("husbandry.db").toFile(); + HusbandryRepository repository = HusbandryRepository.open(dbFile); + try { + UUID animalId = UUID.randomUUID(); + UUID ownerId = UUID.randomUUID(); + UUID coOwnerId = UUID.randomUUID(); + HusbandryAnimal animal = new HusbandryAnimal(animalId, "COW", "Bess"); + animal.setLastLocation("TFMC_Map", 4369, 167, 1950); + animal.setUnloadedAt(50L); + repository.upsertAnimal(animal); + repository.upsertOwner(new HusbandryOwner(animalId, ownerId, ownerId, "owner")); + repository.upsertOwner(new HusbandryOwner(animalId, coOwnerId, coOwnerId, "coowner")); + HusbandryEntities.putLoaded(animal); + + String line = HusbandryLocator.deleteGhost(repository, animal); + + assertFalse(repository.exists(animalId)); + assertTrue(repository.listOwners(animalId).isEmpty()); + assertEquals(0, repository.countForPlayer(ownerId)); + assertEquals(0, repository.countForPlayer(coOwnerId)); + assertTrue(HusbandryEntities.getLoaded(animalId).isEmpty()); + assertFalse(HusbandryLocator.isMissing(animalId)); + String owners = List.of( + new HusbandryOwner(animalId, ownerId, ownerId, "owner"), + new HusbandryOwner(animalId, coOwnerId, coOwnerId, "coowner")) + .stream() + .sorted((left, right) -> left.playerUuid().compareTo(right.playerUuid())) + .map(owner -> owner.playerUuid() + " (" + owner.role() + ")") + .reduce((left, right) -> left + ", " + right) + .orElseThrow(); + assertEquals( + "[BreedingBuddies] Dropped ghost animal Bess (COW) " + animalId + + " owners=" + owners + + " last seen TFMC_Map 4369, 167, 1950", + line); + } finally { + repository.close(); + HusbandryEntities.evict(null); + } + } +} diff --git a/src/test/java/net/tfminecraft/breedingbuddies/husbandry/HusbandryMaintenanceRegressionTest.java b/src/test/java/net/tfminecraft/breedingbuddies/husbandry/HusbandryMaintenanceRegressionTest.java new file mode 100644 index 0000000..e0389eb --- /dev/null +++ b/src/test/java/net/tfminecraft/breedingbuddies/husbandry/HusbandryMaintenanceRegressionTest.java @@ -0,0 +1,194 @@ +package net.tfminecraft.breedingbuddies.husbandry; + +import static org.junit.jupiter.api.Assertions.*; +import static org.mockito.ArgumentMatchers.*; +import static org.mockito.Mockito.*; +import static net.tfminecraft.breedingbuddies.husbandry.HusbandryOwnershipStore.*; + +import java.nio.file.Path; +import java.sql.Connection; +import java.util.*; +import java.util.concurrent.atomic.AtomicBoolean; +import java.util.logging.Handler; +import java.util.logging.LogRecord; +import java.util.logging.Logger; +import net.tfminecraft.tlibs.character.CharacterRef; +import net.tfminecraft.tlibs.character.Characters; +import org.bukkit.Bukkit; +import org.bukkit.Server; +import org.bukkit.UnsafeValues; +import org.bukkit.entity.Entity; +import org.bukkit.plugin.Plugin; +import org.bukkit.scheduler.BukkitScheduler; +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.EnumSource; +import org.mockito.MockedStatic; + +class HusbandryMaintenanceRegressionTest { + @TempDir Path directory; + + enum SnapshotFailure { EXCEPTION, NULL_ENTITY, WRONG_UUID } + + @ParameterizedTest + @EnumSource(SnapshotFailure.class) + void unrestorableSnapshotsKeepAnimalOwnersAndRawBackup(SnapshotFailure failure) throws Exception { + try (var farm = new HusbandryRuntimeCoverageTest.Farm(directory)) { + var repo = farm.repository; + HusbandryAnimal animal = farm.ownedOffline("Recoverable animal"); + repo.upsertOwner(new HusbandryOwner(animal.uuid(), farm.neighbour.getUniqueId(), + farm.neighbour.getUniqueId(), "coowner")); + byte[] snapshot = {0, 7, -1}; + repo.upsertSnapshots(Map.of(animal.uuid(), snapshot), 73); + var connection = repo.ownershipDatabase().getConnection(); + UUID death = UUID.randomUUID(); + var before = heldState(connection, animal.uuid(), death); + UnsafeValues unsafe = mock(UnsafeValues.class); + Entity wrong = mock(Entity.class); + when(wrong.getUniqueId()).thenReturn(UUID.randomUUID()); + when(unsafe.deserializeEntity(any(byte[].class), eq(farm.world), eq(true))).thenAnswer(call -> { + return switch (failure) { + case EXCEPTION -> throw new IllegalStateException("Cannot deserialize snapshot"); + case NULL_ENTITY -> null; + case WRONG_UUID -> wrong; + }; + }); + try (var scan = new HusbandryRuntimeCoverageTest.Scan( + new HusbandryEntityScan.Result(Map.of(), Set.of(), true))) { + scan.bukkit.when(Bukkit::getUnsafe).thenReturn(unsafe); + HusbandryLocator.scanUnloaded(); scan.background(); scan.apply(); + } + + assertEquals(before, heldState(connection, animal.uuid(), death)); + assertArrayEquals(snapshot, repo.getSnapshot(animal.uuid()).orElseThrow().data()); + assertTrue(HusbandryLocator.isMissing(animal.uuid())); + assertEquals(2, repo.listOwners(animal.uuid()).size()); + verify(repo, never()).deleteAnimal(animal.uuid()); + verify(repo, never()).deleteSnapshot(animal.uuid()); + } + } + + @Test void heldGhostKeepsEverySqliteRowAndScanContinuesWithAccurateCounters() throws Exception { + try (var farm = new HusbandryRuntimeCoverageTest.Farm(directory)) { + var repo = farm.repository; + HusbandryAnimal held = farm.ownedOffline("Held"), normal = farm.offline("COW", "Normal"); + farm.own(normal, farm.neighbour); + var connection = repo.ownershipDatabase().getConnection(); + UUID death = UUID.randomUUID(), recipient = UUID.randomUUID(); + update(connection, "INSERT INTO owners VALUES (?, ?, 'owner')", held.uuid().toString(), farm.player.getUniqueId().toString()); + update(connection, "ALTER TABLE character_owners ADD COLUMN future BLOB"); + update(connection, "UPDATE character_owners SET future=? WHERE animal_uuid=?", new byte[]{0, 7, -1}, held.uuid().toString()); + repo.upsertSnapshots(Map.of(held.uuid(), new byte[]{0, 1, -1}, normal.uuid(), new byte[]{8, 9}), 73); + update(connection, "INSERT INTO husbandry_estate_intents VALUES (?, 1, 'captured opaque decision', ?, ?, ?, ?, 'prepared')", + death.toString(), farm.player.getUniqueId().toString(), farm.player.getUniqueId().toString(), recipient.toString(), recipient.toString()); + update(connection, "INSERT INTO husbandry_estate_slots VALUES (?, 1, ?, 'owner', NULL, 'owner')", death.toString(), held.uuid().toString()); + var before = heldState(connection, held.uuid(), death); + assertTrue(repo.isEstateHeld(held.uuid())); + assertNull(HusbandryLocator.deleteGhost(repo, held)); + assertEquals(before, heldState(connection, held.uuid(), death)); + assertTrue(HusbandryLocator.isMissing(held.uuid())); + assertEquals(1, repo.countForPlayer(farm.player.getUniqueId())); + assertEquals(1, repo.countForPlayer(farm.neighbour.getUniqueId())); + + List logs = new ArrayList<>(); + Handler handler = new Handler() { + @Override public void publish(LogRecord record) { logs.add(record.getMessage()); } + @Override public void flush() {} + @Override public void close() {} + }; + Logger logger = Bukkit.getLogger(); logger.addHandler(handler); + try (MockedStatic snapshots = mockStatic(HusbandrySnapshots.class); + var scan = new HusbandryRuntimeCoverageTest.Scan(new HusbandryEntityScan.Result(Map.of(), Set.of(), true))) { + snapshots.when(() -> HusbandrySnapshots.restore(any(), any(), anyList())) + .thenReturn(HusbandrySnapshots.RestoreResult.SKIPPED); + HusbandryLocator.scanUnloaded(); scan.background(); assertDoesNotThrow(scan::apply); + } finally { logger.removeHandler(handler); } + assertEquals(before, heldState(connection, held.uuid(), death)); + assertArrayEquals(new byte[]{0, 1, -1}, repo.getSnapshot(held.uuid()).orElseThrow().data()); + assertTrue(repo.exists(held.uuid())); + assertEquals(1, repo.countForPlayer(farm.player.getUniqueId())); + assertEquals(0, repo.countForPlayer(farm.neighbour.getUniqueId())); + assertFalse(repo.exists(normal.uuid())); + assertTrue(repo.listOwners(normal.uuid()).isEmpty()); + assertTrue(repo.getSnapshot(normal.uuid()).isEmpty()); + assertTrue(logs.stream().anyMatch(line -> line.contains("dropped 1 ghost animals") && line.contains("held 1 missing animal records"))); + assertFalse(logs.stream().anyMatch(line -> line.startsWith("[BreedingBuddies] Dropped ghost animal") && line.contains(held.uuid().toString()))); + } + } + + @Test void pendingReadinessPublishesOnNextMinuteThenReportsHeldPlansOnlyHourlyAndSurvivesRestart() throws Exception { + Plugin plugin = mock(Plugin.class); + Server server = mock(Server.class); BukkitScheduler scheduler = mock(BukkitScheduler.class); + Logger logger = mock(Logger.class); when(plugin.getServer()).thenReturn(server); + when(server.getScheduler()).thenReturn(scheduler); when(plugin.getLogger()).thenReturn(logger); + List tasks = new ArrayList<>(); + when(scheduler.runTaskTimer(eq(plugin), any(Runnable.class), eq(1L), eq(20L * 60))).thenAnswer(call -> { tasks.add(call.getArgument(1)); return null; }); + UUID account = UUID.randomUUID(), unknown = UUID.randomUUID(), character = UUID.randomUUID(); + var heir = new CharacterRef(account, character); + AtomicBoolean ready = new AtomicBoolean(false); + Path database = directory.resolve("retry.db"); + try (MockedStatic characters = mockStatic(Characters.class)) { + characters.when(Characters::legacyHeirsReady).thenAnswer(call -> ready.get()); + characters.when(() -> Characters.legacyHeir(any())).thenAnswer(call -> account.equals(call.getArgument(0)) ? Optional.of(heir) : Optional.empty()); + try (var fixture = new HusbandryOwnershipMigrationTest.Repository(database)) { + var repo = spy(fixture.value); var connection = repo.ownershipDatabase().getConnection(); + var animal = new HusbandryAnimal(UUID.randomUUID(), "COW", "Late heir"); repo.upsertAnimal(animal); + update(connection, "INSERT INTO owners VALUES (?, ?, 'owner')", animal.uuid().toString(), account.toString()); + update(connection, "INSERT INTO owners VALUES (?, ?, 'coowner')", animal.uuid().toString(), unknown.toString()); + HusbandryMigration.start(plugin, repo); Runnable task = tasks.getFirst(); + task.run(); task.run(); task.run(); + assertTrue(repo.listOwners(animal.uuid()).isEmpty()); assertEquals(1, repo.countForPlayer(account)); + characters.verify(() -> Characters.legacyHeir(any()), never()); + verify(logger, times(1)).warning(contains("waits for reviewed heirs")); + ready.set(true); task.run(); + assertEquals(heir, repo.listOwners(animal.uuid()).getFirst().ref()); assertEquals(1, repo.heldOwners()); + assertEquals(1, repo.countForPlayer(unknown)); + var original = Arrays.deepToString(rows(connection, "SELECT * FROM husbandry_owner_archive ORDER BY player_uuid").toArray()); + clearInvocations(repo, logger); characters.clearInvocations(); + for (int minute = 0; minute < 59; minute++) task.run(); + verify(repo, never()).migrateOwners(any(), any()); verify(repo, never()).heldOwners(); + characters.verifyNoInteractions(); verifyNoInteractions(logger); + task.run(); + verify(repo, times(1)).migrateOwners(any(), any()); verify(repo, times(1)).heldOwners(); + verify(logger, times(1)).warning(contains("holds 1 legacy slots")); + assertEquals(original, Arrays.deepToString(rows(connection, "SELECT * FROM husbandry_owner_archive ORDER BY player_uuid").toArray())); + } + ready.set(false); characters.clearInvocations(); clearInvocations(logger); + try (var fixture = new HusbandryOwnershipMigrationTest.Repository(database)) { + var repo = spy(fixture.value); HusbandryMigration.start(plugin, repo); tasks.getLast().run(); + assertEquals(1, repo.heldOwners()); + characters.verifyNoInteractions(); + verify(logger, times(1)).warning(contains("holds 1 legacy slots")); + assertEquals(2, scalar(repo.ownershipDatabase().getConnection(), "SELECT COUNT(*) FROM husbandry_owner_archive")); + assertEquals(1, scalar(repo.ownershipDatabase().getConnection(), "SELECT COUNT(*) FROM character_owners")); + } + } + } + + @Test void pendingFailuresRetryWithoutRepeatingIdenticalWarnings() { + Plugin plugin = mock(Plugin.class); Server server = mock(Server.class); + BukkitScheduler scheduler = mock(BukkitScheduler.class); Logger logger = mock(Logger.class); + when(plugin.getServer()).thenReturn(server); when(server.getScheduler()).thenReturn(scheduler); when(plugin.getLogger()).thenReturn(logger); + List tasks = new ArrayList<>(); + when(scheduler.runTaskTimer(eq(plugin), any(Runnable.class), eq(1L), eq(20L * 60))).thenAnswer(call -> { tasks.add(call.getArgument(1)); return null; }); + HusbandryRepository repo = mock(HusbandryRepository.class); + when(repo.migrateOwners(any(), any())).thenThrow(new IllegalStateException("disk unavailable"), new IllegalStateException("disk unavailable")).thenReturn(true); + HusbandryMigration.start(plugin, repo); Runnable task = tasks.getFirst(); + task.run(); task.run(); task.run(); + verify(repo, times(3)).migrateOwners(any(), any()); + verify(logger, times(1)).warning(contains("disk unavailable")); + verify(repo).heldOwners(); + } + + private Map heldState(Connection connection, UUID animal, UUID death) throws Exception { + Map state = new TreeMap<>(); + for (String table : List.of("animals", "owners", "character_owners", "snapshots", "husbandry_estate_intents", "husbandry_estate_slots")) { + String key = table.equals("animals") ? "uuid" : table.equals("husbandry_estate_intents") ? "death_id" : "animal_uuid"; + var columns = rows(connection, "PRAGMA table_info(" + table + ")").stream().map(row -> "quote(\"" + row[1] + "\")").toList(); + state.put(table, Arrays.deepToString(rows(connection, "SELECT " + String.join(",", columns) + " FROM " + table + " WHERE " + key + "=? ORDER BY rowid", + (table.equals("husbandry_estate_intents") ? death : animal).toString()).toArray())); + } + return state; + } +} diff --git a/src/test/java/net/tfminecraft/breedingbuddies/husbandry/HusbandryMilkTimerTest.java b/src/test/java/net/tfminecraft/breedingbuddies/husbandry/HusbandryMilkTimerTest.java new file mode 100644 index 0000000..6ffdc4d --- /dev/null +++ b/src/test/java/net/tfminecraft/breedingbuddies/husbandry/HusbandryMilkTimerTest.java @@ -0,0 +1,15 @@ +package net.tfminecraft.breedingbuddies.husbandry; + +import static org.junit.jupiter.api.Assertions.assertEquals; + +import org.junit.jupiter.api.Test; + +class HusbandryMilkTimerTest { + + @Test + void speciesOverrideBeatsDefault() { + assertEquals(2400, HusbandryConfig.resolveMilkTimerSeconds(2400, 1200)); + assertEquals(1200, HusbandryConfig.resolveMilkTimerSeconds(0, 1200)); + assertEquals(1200, HusbandryConfig.resolveMilkTimerSeconds(-1, 1200)); + } +} diff --git a/src/test/java/net/tfminecraft/breedingbuddies/husbandry/HusbandryMountEnrollTest.java b/src/test/java/net/tfminecraft/breedingbuddies/husbandry/HusbandryMountEnrollTest.java new file mode 100644 index 0000000..2cd33be --- /dev/null +++ b/src/test/java/net/tfminecraft/breedingbuddies/husbandry/HusbandryMountEnrollTest.java @@ -0,0 +1,41 @@ +package net.tfminecraft.breedingbuddies.husbandry; + +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertTrue; + +import org.junit.jupiter.api.Test; + +class HusbandryMountEnrollTest { + + @Test + void wildHorseWithoutRowIsWiped() { + assertTrue(HusbandryMounts.shouldWipeUnowned(true, false, false, true)); + } + + @Test + void enrolledUnownedHorseIsKept() { + assertFalse(HusbandryMounts.shouldWipeUnowned(true, false, true, true)); + } + + @Test + void ownedEntityIsKeptEvenIfNotConfiguredMount() { + assertFalse(HusbandryMounts.shouldWipeUnowned(true, true, false, false)); + } + + @Test + void unownedCowWithRowIsWiped() { + assertTrue(HusbandryMounts.shouldWipeUnowned(true, false, true, false)); + } + + @Test + void llamaWithRowIsWiped() { + assertTrue(HusbandryMounts.shouldWipeUnowned(true, false, true, false)); + } + + @Test + void enrollRowOnlyWhenConfiguredAndMissing() { + assertTrue(HusbandryMounts.shouldCreateEnrollRow(true, false)); + assertFalse(HusbandryMounts.shouldCreateEnrollRow(true, true)); + assertFalse(HusbandryMounts.shouldCreateEnrollRow(false, false)); + } +} diff --git a/src/test/java/net/tfminecraft/breedingbuddies/husbandry/HusbandryMountGenesTest.java b/src/test/java/net/tfminecraft/breedingbuddies/husbandry/HusbandryMountGenesTest.java new file mode 100644 index 0000000..8181c4f --- /dev/null +++ b/src/test/java/net/tfminecraft/breedingbuddies/husbandry/HusbandryMountGenesTest.java @@ -0,0 +1,123 @@ +package net.tfminecraft.breedingbuddies.husbandry; + +import static org.junit.jupiter.api.Assertions.*; + +import java.nio.file.Path; +import java.util.Random; +import java.util.UUID; + +import org.bukkit.entity.Horse; +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 net.tfminecraft.breedingbuddies.husbandry.HusbandryMountGenes.Stat; +import net.tfminecraft.breedingbuddies.husbandry.HusbandryRuntimeCoverageTest.Farm; + +class HusbandryMountGenesTest { + @TempDir Path directory; + private Farm f; + + @BeforeEach void open() throws Exception { + f = new Farm(directory); + f.config.set("max-genetics", 1000); f.config.set("care-max", 200); f.config.set("initial-genetic-max", 20); + f.config.set("mounts.HORSE", java.util.Map.of("min-health", 10, "max-health", 30, "min-speed", 0.1, "max-speed", 0.3, "min-jump", 0.4, "max-jump", 1.0)); + f.config.set("mounts.shares.min-pct", 0.4); f.config.set("mounts.shares.genetics-pct", 0.5); f.config.set("mounts.shares.care-pct", 0.1); + f.reload(); + } + + @AfterEach void close() throws Exception { f.close(); } + + @Test void recordsWithoutGenesUseTheirGeneticsAndSettingGenesAveragesThem() { + HusbandryAnimal old = animal(300, 0); + for (Stat stat : Stat.values()) assertEquals(300, HusbandryMountGenes.gene(old, stat)); + HusbandryMountGenes.set(old, 900, 600, -5); + assertEquals(900, HusbandryMountGenes.gene(old, Stat.HEALTH)); + assertEquals(600, HusbandryMountGenes.gene(old, Stat.SPEED)); + assertEquals(0, HusbandryMountGenes.gene(old, Stat.JUMP)); + assertEquals(500, old.genetics()); + HusbandryMountGenes.set(old, 5000, 5000, 5000); + assertEquals(1000, old.genetics()); + } + + @Test void wildMountsRollEachStatWithinTheWildRange() throws Exception { + HusbandryAnimal wild = animal(0, 0); + HusbandryMountGenes.rollWild(wild, new Random(7)); + for (Stat stat : Stat.values()) assertTrue(HusbandryMountGenes.gene(wild, stat) <= 20); + f.config.set("initial-genetic-max", 0); f.reload(); + HusbandryMountGenes.rollWild(wild, new Random(7)); + assertEquals(0, wild.genetics()); + } + + @Test void withoutRoomAFoalNeverBeatsTheBetterParentAndWithRoomItCan() { + Random random = new Random(3); + int best = 0, ceiling = 0; + for (int i = 0; i < 2000; i++) { + ceiling = Math.max(ceiling, HusbandryMountGenes.inheritStat(600, 600, 0, random)); + best = Math.max(best, HusbandryMountGenes.inheritStat(600, 600, 1, random)); + } + assertTrue(ceiling <= 600, "No trust or feed means no climb"); + assertTrue(best > 600, "Trusting, well-fed parents can produce a better foal"); + assertTrue(best <= 600 + (1000 - 600) / 4 + 1); + int floor = 1000; + for (int i = 0; i < 2000; i++) floor = Math.min(floor, HusbandryMountGenes.inheritStat(600, 600, 1, random)); + assertTrue(floor < 600 && floor >= 600 - (1000 - 600) / 5.5 - 1); + } + + @Test void veryDifferentParentsLowerTheCeiling() { + Random random = new Random(5); + int best = 0; + for (int i = 0; i < 2000; i++) best = Math.max(best, HusbandryMountGenes.inheritStat(0, 1000, 1, random)); + assertTrue(best < 1000, "A foal of a weak and a perfect parent cannot reach the perfect one"); + assertEquals(0, HusbandryMountGenes.inheritStat(0, 0, 0, new Random(1)), "Clamped at zero"); + } + + @Test void foalsInheritEveryStatFromTheirOwnParentsGenes() throws Exception { + HusbandryAnimal mother = animal(0, 200), father = animal(0, 200), foal = animal(0, 0); + HusbandryMountGenes.set(mother, 1000, 0, 500); + HusbandryMountGenes.set(father, 1000, 0, 500); + HusbandryMountGenes.inherit(foal, mother, father, 1, new Random(11)); + assertTrue(HusbandryMountGenes.gene(foal, Stat.HEALTH) > 900); + assertTrue(HusbandryMountGenes.gene(foal, Stat.SPEED) < 100); + assertTrue(Math.abs(HusbandryMountGenes.gene(foal, Stat.JUMP) - 500) < 150); + f.config.set("care-max", 0); f.reload(); + HusbandryMountGenes.inherit(foal, mother, father, 1, new Random(11)); + assertTrue(HusbandryMountGenes.gene(foal, Stat.HEALTH) <= 1000); + } + + @Test void eachStatFollowsItsOwnGeneAndTrustAddsItsShare() { + Horse horse = f.spawn(Horse.class); HusbandryAnimal animal = f.record(horse); + HusbandryMountGenes.set(animal, 1000, 0, 500); animal.setCare(0); + HusbandryMounts.applyStats(horse, animal); + assertEquals(27, HusbandryMounts.maxHealth(horse), 0.001); + assertEquals(0.12, HusbandryMounts.speed(horse), 0.001); + assertEquals(0.65, HusbandryMounts.jump(horse), 0.001); + animal.setCare(200); + HusbandryMounts.applyStats(horse, animal); + assertEquals(30, HusbandryMounts.maxHealth(horse), 0.001, "Full genes and full trust reach the max"); + } + + @Test void mountRecordsGetGenesAndKeepThemAcrossSavesUntilAStatsReset() { + Horse spawned = f.spawn(Horse.class); + HusbandryAnimal staff = HusbandrySpawner.createRecord(spawned, 700, 0); + for (Stat stat : Stat.values()) assertEquals(700, HusbandryMountGenes.gene(staff, stat)); + HusbandryAnimal wild = HusbandrySpawner.createWildRecord(f.spawn(Horse.class)); + assertNotNull(wild.geneHealth()); assertTrue(wild.genetics() <= 20); + + HusbandryMountGenes.set(staff, 100, 200, 300); f.repository.upsertAnimal(staff); + HusbandryAnimal read = f.repository.getAnimal(staff.uuid()).orElseThrow(); + assertEquals(100, read.geneHealth()); assertEquals(200, read.geneSpeed()); assertEquals(300, read.geneJump()); + + f.repository.resetStaleStats("next-revision", new Random(1)); + HusbandryAnimal reset = f.repository.getAnimal(staff.uuid()).orElseThrow(); + assertNull(reset.geneHealth()); assertNull(reset.geneSpeed()); assertNull(reset.geneJump()); + } + + private static HusbandryAnimal animal(int genetics, int care) { + HusbandryAnimal animal = new HusbandryAnimal(UUID.randomUUID(), "HORSE", "Horse"); + animal.setGenetics(genetics); + animal.setCare(care); + return animal; + } +} diff --git a/src/test/java/net/tfminecraft/breedingbuddies/husbandry/HusbandryMountSpeedTest.java b/src/test/java/net/tfminecraft/breedingbuddies/husbandry/HusbandryMountSpeedTest.java new file mode 100644 index 0000000..6d9b831 --- /dev/null +++ b/src/test/java/net/tfminecraft/breedingbuddies/husbandry/HusbandryMountSpeedTest.java @@ -0,0 +1,46 @@ +package net.tfminecraft.breedingbuddies.husbandry; + +import static org.junit.jupiter.api.Assertions.assertEquals; + +import org.junit.jupiter.api.Test; + +class HusbandryMountSpeedTest { + + @Test + void zeroGeneticsZeroCareIsFortyPercent() { + double speed = HusbandryMounts.speedFor(0.3375, 0, 0, 1000, 200, 0.40, 0.30, 0.20); + assertEquals(0.3375 * 0.40, speed, 0.0000001); + } + + @Test + void maxGeneticsZeroCareIsSeventyPercent() { + double speed = HusbandryMounts.speedFor(0.3375, 1000, 0, 1000, 200, 0.40, 0.30, 0.20); + assertEquals(0.3375 * 0.70, speed, 0.0000001); + } + + @Test + void maxGeneticsMaxCareIsMinPlusGeneticsPlusCare() { + double speed = HusbandryMounts.speedFor(0.3375, 1000, 200, 1000, 200, 0.40, 0.30, 0.20); + assertEquals(0.3375 * 0.90, speed, 0.0000001); + } + + @Test + void muleUsesOwnMaxNotHorseMax() { + double horse = HusbandryMounts.speedFor(0.3375, 1000, 0, 1000, 200, 0.40, 0.30, 0.20); + double mule = HusbandryMounts.speedFor(0.270, 1000, 0, 1000, 200, 0.40, 0.30, 0.20); + assertEquals(0.3375 * 0.70, horse, 0.0000001); + assertEquals(0.270 * 0.70, mule, 0.0000001); + } + + @Test + void healthAtFortyPercentIsFlooredAtMin() { + double fraction = HusbandryMounts.mountFraction(0, 0, 1000, 200, 0.40, 0.30, 0.20); + assertEquals(15, HusbandryMounts.scaledStat(30, 15, fraction), 0.0000001); + } + + @Test + void jumpAtFortyPercentHitsConfiguredMin() { + double fraction = HusbandryMounts.mountFraction(0, 0, 1000, 200, 0.40, 0.30, 0.20); + assertEquals(0.4, HusbandryMounts.scaledStat(1.0, 0.4, fraction), 0.0000001); + } +} diff --git a/src/test/java/net/tfminecraft/breedingbuddies/husbandry/HusbandryOwnedMountTest.java b/src/test/java/net/tfminecraft/breedingbuddies/husbandry/HusbandryOwnedMountTest.java new file mode 100644 index 0000000..c9a675f --- /dev/null +++ b/src/test/java/net/tfminecraft/breedingbuddies/husbandry/HusbandryOwnedMountTest.java @@ -0,0 +1,283 @@ +package net.tfminecraft.breedingbuddies.husbandry; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertTrue; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.Mockito.*; + +import java.util.List; +import java.util.Optional; +import java.util.UUID; + +import net.tfminecraft.breedingbuddies.BreedingBuddies; +import net.kyori.adventure.text.Component; +import org.bukkit.Material; +import org.bukkit.entity.Cow; +import org.bukkit.entity.EntityType; +import org.bukkit.entity.Horse; +import org.bukkit.entity.Llama; +import org.bukkit.entity.LivingEntity; +import org.bukkit.entity.Player; +import org.bukkit.event.entity.EntityTameEvent; +import org.bukkit.inventory.HorseInventory; +import org.bukkit.inventory.ItemStack; +import org.bukkit.persistence.PersistentDataContainer; +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 BreedingBuddies previousPlugin; + + @BeforeEach + void providePluginForStateDisplayKeys() { + previousPlugin = BreedingBuddies.plugin; + BreedingBuddies.plugin = mock(BreedingBuddies.class); + when(BreedingBuddies.plugin.getName()).thenReturn("BreedingBuddies"); + when(BreedingBuddies.plugin.namespace()).thenReturn("breedingbuddies"); + } + + @AfterEach + void restorePlugin() { + BreedingBuddies.plugin = previousPlugin; + } + + @Test + void tamedNamedSaddledHorseWithoutCookingOwnerIsRemoved() { + Horse horse = keptLookingHorse(); + checkLoad(horse, false, false, false, false); + } + + @Test + void tamedNamedSaddledHorseWithoutOwnerButWithStatsRowIsStillEnrolledWild() { + checkLoad(keptLookingHorse(), true, true, false, true); + } + + @Test + void cookingOwnedHorseIsKept() { + checkLoad(keptLookingHorse(), true, true, true, true); + } + + @Test + void cookingOwnedHorseWithoutMountStatsIsKept() { + checkLoad(keptLookingHorse(), true, false, true, true); + } + + @Test + void wildHorseWithoutRecordIsRemoved() { + checkLoad(horse(), false, false, false, false); + } + + @Test + void hintExplainsHowToClaimAndTheLimit() { + List lines = HusbandryClaimHint.lines("Horse", true, true, false, 3, 15, false); + assertEquals(3, lines.size()); + assertTrue(lines.get(0).contains("This horse is not claimed")); + assertTrue(lines.get(1).contains("Tame it, then right-click it with an Ownership Token")); + assertTrue(lines.get(2).contains("3/15")); + } + + @Test + void hintAtCapSaysItCannotBeClaimed() { + List lines = HusbandryClaimHint.lines("Donkey", true, false, false, 15, 15, false); + assertTrue(lines.get(1).contains("15/15 animals, so you cannot claim it")); + assertTrue(lines.get(2).contains("shift-right-click it with an empty hand, then click Remove ownership")); + assertTrue(lines.get(2).contains("/animals")); + } + + @Test + void unclaimableAnimalsAreNotSentToTheToken() { + List lines = HusbandryClaimHint.lines("Wolf", false, false, false, 0, 15, false); + assertEquals(2, lines.size()); + assertTrue(lines.get(0).contains("wander off before anyone next comes by")); + assertTrue(lines.get(1).contains("cannot be claimed")); + assertFalse(String.join(" ", lines).contains("Ownership Token")); + } + + @Test + void onlyAnimalsTheCleanupWouldRemoveNeedAClaim() { + Horse enrolled = horse(); + Llama llama = mock(Llama.class); + Horse owned = horse(); + Cow cow = mock(Cow.class); + UUID enrolledId = UUID.randomUUID(); + UUID llamaId = UUID.randomUUID(); + UUID ownedId = UUID.randomUUID(); + when(enrolled.getUniqueId()).thenReturn(enrolledId); + when(enrolled.getType()).thenReturn(EntityType.HORSE); + when(llama.getUniqueId()).thenReturn(llamaId); + when(llama.getType()).thenReturn(EntityType.LLAMA); + when(owned.getUniqueId()).thenReturn(ownedId); + when(owned.getType()).thenReturn(EntityType.HORSE); + when(cow.getType()).thenReturn(EntityType.COW); + HusbandryRepository repository = mock(HusbandryRepository.class); + when(repository.exists(enrolledId)).thenReturn(true); + try (MockedStatic entities = mockStatic(HusbandryEntities.class); + MockedStatic config = mockStatic(HusbandryConfig.class); + MockedStatic ownership = mockStatic(HusbandryOwnershipService.class)) { + entities.when(HusbandryEntities::repository).thenReturn(repository); + entities.when(() -> HusbandryEntities.getLoaded(any())).thenReturn(Optional.empty()); + config.when(() -> HusbandryConfig.isRemoveUnowned(EntityType.HORSE)).thenReturn(true); + config.when(() -> HusbandryConfig.isRemoveUnowned(EntityType.LLAMA)).thenReturn(true); + config.when(() -> HusbandryConfig.mountStats(EntityType.HORSE)) + .thenReturn(mock(HusbandryMountStats.class)); + ownership.when(() -> HusbandryOwnershipService.hasAnyOwner(ownedId)).thenReturn(true); + + // Riding enrolls a horse, and an enrolled horse survives the cleanup without an owner. + assertFalse(HusbandryClaimHint.needsClaim(enrolled)); + // Llamas have no mount stats, so they are never enrolled and are removed. + assertTrue(HusbandryClaimHint.needsClaim(llama)); + assertFalse(HusbandryClaimHint.needsClaim(owned)); + assertFalse(HusbandryClaimHint.needsClaim(cow)); + assertFalse(HusbandryClaimHint.needsClaim(null)); + } + } + + @Test + void tamingHintDoesNotSayToTameAnAnimalThatWasJustTamed() { + Player player = mock(Player.class); + UUID playerId = UUID.randomUUID(); + when(player.getUniqueId()).thenReturn(playerId); + Llama llama = mock(Llama.class); + UUID llamaId = UUID.randomUUID(); + when(llama.getUniqueId()).thenReturn(llamaId); + when(llama.getType()).thenReturn(EntityType.LLAMA); + // Paper fires EntityTameEvent before taming, so the entity still reports untamed. + when(llama.isTamed()).thenReturn(false); + EntityTameEvent event = mock(EntityTameEvent.class); + when(event.getOwner()).thenReturn(player); + when(event.getEntity()).thenReturn(llama); + HusbandryRepository repository = mock(HusbandryRepository.class); + when(repository.countForPlayer(playerId)).thenReturn(2); + try (MockedStatic entities = mockStatic(HusbandryEntities.class); + MockedStatic config = mockStatic(HusbandryConfig.class); + MockedStatic ownership = mockStatic(HusbandryOwnershipService.class)) { + entities.when(HusbandryEntities::repository).thenReturn(repository); + entities.when(() -> HusbandryEntities.getLoaded(llamaId)).thenReturn(Optional.empty()); + entities.when(() -> HusbandryEntities.displayName(EntityType.LLAMA)).thenReturn("Llama"); + config.when(() -> HusbandryConfig.isRemoveUnowned(EntityType.LLAMA)).thenReturn(true); + config.when(() -> HusbandryConfig.species(EntityType.LLAMA)).thenReturn(mock(HusbandrySpecies.class)); + config.when(HusbandryConfig::maxAnimals).thenReturn(15); + + new HusbandryMountListener().onTame(event); + + ArgumentCaptor sent = ArgumentCaptor.forClass(String.class); + verify(player, times(3)).sendMessage(sent.capture()); + assertTrue(sent.getAllValues().get(1).startsWith("§7Right-click it with an Ownership Token")); + assertTrue(sent.getAllValues().get(2).contains("2/15")); + } + } + + @Test + void riddenUnownedHorseStillGetsTheClaimHint() { + Player player = mock(Player.class); + UUID playerId = UUID.randomUUID(); + when(player.getUniqueId()).thenReturn(playerId); + Horse horse = horse(); + UUID horseId = UUID.randomUUID(); + when(horse.getUniqueId()).thenReturn(horseId); + when(horse.getType()).thenReturn(EntityType.HORSE); + HusbandryRepository repository = mock(HusbandryRepository.class); + // Mounting already enrolled it, so the cleanup keeps it as a wild horse. + when(repository.exists(horseId)).thenReturn(true); + when(repository.countForPlayer(playerId)).thenReturn(4); + try (MockedStatic entities = mockStatic(HusbandryEntities.class); + MockedStatic config = mockStatic(HusbandryConfig.class); + MockedStatic ownership = mockStatic(HusbandryOwnershipService.class)) { + entities.when(HusbandryEntities::repository).thenReturn(repository); + entities.when(() -> HusbandryEntities.getLoaded(horseId)).thenReturn(Optional.empty()); + entities.when(() -> HusbandryEntities.displayName(EntityType.HORSE)).thenReturn("Horse"); + config.when(() -> HusbandryConfig.isRemoveUnowned(EntityType.HORSE)).thenReturn(true); + config.when(() -> HusbandryConfig.mountStats(EntityType.HORSE)) + .thenReturn(mock(HusbandryMountStats.class)); + config.when(() -> HusbandryConfig.species(EntityType.HORSE)).thenReturn(mock(HusbandrySpecies.class)); + config.when(HusbandryConfig::maxAnimals).thenReturn(15); + + HusbandryClaimHint.send(player, horse, true); + + ArgumentCaptor sent = ArgumentCaptor.forClass(String.class); + verify(player, times(3)).sendMessage(sent.capture()); + assertTrue(sent.getAllValues().get(0).contains("Anyone can ride it or claim it")); + assertTrue(sent.getAllValues().get(1).startsWith("§7Tame it, then right-click it")); + assertTrue(sent.getAllValues().get(2).contains("4/15")); + } + } + + @Test + void ownedHorseGetsNoHint() { + Player player = mock(Player.class); + Horse horse = horse(); + UUID horseId = UUID.randomUUID(); + when(horse.getUniqueId()).thenReturn(horseId); + when(horse.getType()).thenReturn(EntityType.HORSE); + try (MockedStatic config = mockStatic(HusbandryConfig.class); + MockedStatic ownership = mockStatic(HusbandryOwnershipService.class)) { + config.when(() -> HusbandryConfig.isRemoveUnowned(EntityType.HORSE)).thenReturn(true); + ownership.when(() -> HusbandryOwnershipService.hasAnyOwner(horseId)).thenReturn(true); + + HusbandryClaimHint.send(player, horse, false); + + verify(player, never()).sendMessage(anyString()); + } + } + + @Test + void tamedHintSkipsTheTamingStep() { + List lines = HusbandryClaimHint.lines("Horse", true, false, false, 0, 15, false); + assertTrue(lines.get(1).startsWith("§7Right-click it with an Ownership Token")); + } + + @Test + void staffHintHasNoLimit() { + List lines = HusbandryClaimHint.lines("Horse", true, false, false, 40, 15, true); + assertEquals(2, lines.size()); + assertTrue(lines.get(1).contains("no animal limit")); + } + + private static Horse keptLookingHorse() { + Horse horse = horse(); + when(horse.isTamed()).thenReturn(true); + when(horse.customName()).thenReturn(Component.text("Hazel's horse")); + ItemStack saddle = mock(ItemStack.class); + when(saddle.getType()).thenReturn(Material.SADDLE); + when(horse.getInventory().getSaddle()).thenReturn(saddle); + return horse; + } + + private static Horse horse() { + Horse horse = mock(Horse.class); + when(horse.getInventory()).thenReturn(mock(HorseInventory.class)); + when(horse.getPersistentDataContainer()).thenReturn(mock(PersistentDataContainer.class)); + return horse; + } + + private static void checkLoad( + LivingEntity entity, boolean hasRow, boolean configuredMount, boolean owned, boolean kept) { + UUID uuid = UUID.randomUUID(); + 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)) { + entities.when(HusbandryEntities::repository).thenReturn(repository); + config.when(() -> HusbandryConfig.isRemoveUnowned(entity.getType())).thenReturn(true); + ownership.when(() -> HusbandryOwnershipService.hasAnyOwner(uuid)).thenReturn(owned); + if (configuredMount) { + config.when(() -> HusbandryConfig.mountStats(entity.getType())) + .thenReturn(mock(HusbandryMountStats.class)); + } + + HusbandryLifecycleListener.handleLoad(entity); + + verify(entity, kept ? never() : times(1)).remove(); + verify(repository, !kept && hasRow ? times(1) : never()).deleteAnimal(uuid); + } + } +} diff --git a/src/test/java/net/tfminecraft/breedingbuddies/husbandry/HusbandryOwnershipMigrationTest.java b/src/test/java/net/tfminecraft/breedingbuddies/husbandry/HusbandryOwnershipMigrationTest.java new file mode 100644 index 0000000..a65fc7b --- /dev/null +++ b/src/test/java/net/tfminecraft/breedingbuddies/husbandry/HusbandryOwnershipMigrationTest.java @@ -0,0 +1,203 @@ +package net.tfminecraft.breedingbuddies.husbandry; + +import static org.junit.jupiter.api.Assertions.*; +import java.nio.file.Path; +import java.sql.Connection; +import java.util.List; +import java.util.Optional; +import java.util.UUID; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.io.TempDir; +import net.tfminecraft.tlibs.character.CharacterRef; +import net.tfminecraft.tlibs.database.SqliteDatabaseException; +import static net.tfminecraft.breedingbuddies.husbandry.HusbandryOwnershipStore.*; + +class HusbandryOwnershipMigrationTest { + @TempDir Path directory; + final UUID account = UUID.randomUUID(), otherAccount = UUID.randomUUID(), character = UUID.randomUUID(); + + HusbandryAnimal animal(HusbandryRepository repo) { + HusbandryAnimal animal = new HusbandryAnimal(UUID.randomUUID(), "COW", "Daisy"); + animal.setCare(47); animal.setGenetics(901); animal.setHungrySince(23L); + repo.upsertAnimal(animal); return animal; + } + Connection connection(HusbandryRepository repo) { return repo.ownershipDatabase().getConnection(); } + CharacterRef heir() { return new CharacterRef(account, character); } + void legacy(HusbandryRepository repo, UUID animal, UUID owner, String role) throws Exception { + update(connection(repo), "INSERT INTO owners VALUES (?, ?, ?)", animal.toString(), owner.toString(), role); + } + + @Test void migrationWaitsForReviewedHeirsAndPublishesOnceWithImmutableIndependentArchive() throws Exception { + try (var repo = new Repository(directory.resolve("farm.db"))) { + var r = repo.value; var animal = animal(r); legacy(r, animal.uuid(), account, "owner"); + update(connection(r), "ALTER TABLE owners ADD COLUMN future TEXT"); + update(connection(r), "UPDATE owners SET future = 'preserved'"); + update(connection(r), "ALTER TABLE animals ADD COLUMN future TEXT DEFAULT 'animal-progress'"); + r.upsertSnapshots(java.util.Map.of(animal.uuid(), new byte[]{1,2,3}), 71); + assertFalse(r.migrateOwners(() -> false, id -> { fail("No heir lookup before readiness"); return Optional.empty(); })); + assertTrue(r.hasAnyOwner(animal.uuid())); assertTrue(r.hasHeldOwner(animal.uuid())); + assertEquals(1, r.countForPlayer(account)); assertTrue(r.listOwners(animal.uuid()).isEmpty()); + assertTrue(r.migrateOwners(() -> true, id -> Optional.of(heir()))); + assertEquals(heir(), r.listOwners(animal.uuid()).getFirst().ref()); assertEquals(0, r.heldOwners()); + assertFalse(r.hasHeldOwner(animal.uuid())); assertEquals(1, r.listForCharacter(heir()).size()); + assertTrue(r.listForCharacter(new CharacterRef(account, UUID.randomUUID())).isEmpty()); + assertTrue(r.migrateOwners(() -> { fail("Completed mapping is immutable"); return false; }, id -> Optional.empty())); + animal.setCare(59); r.upsertAnimal(animal); + assertEquals("preserved", rows(connection(r), "SELECT future FROM husbandry_owner_archive").getFirst()[0]); + assertEquals("animal-progress", rows(connection(r), "SELECT future FROM animals").getFirst()[0]); + assertArrayEquals(new byte[]{1,2,3}, r.getSnapshot(animal.uuid()).orElseThrow().data()); + assertThrows(Exception.class, () -> update(connection(r), "DELETE FROM husbandry_owner_archive")); + assertThrows(Exception.class, () -> update(connection(r), "UPDATE husbandry_owner_archive SET future = ''")); + assertThrows(Exception.class, () -> update(connection(r), "INSERT INTO husbandry_owner_archive SELECT * FROM owners")); + r.deleteAnimal(animal.uuid()); + assertEquals(1, scalar(connection(r), "SELECT COUNT(*) FROM husbandry_owner_archive")); + assertEquals(0, scalar(connection(r), "SELECT COUNT(*) FROM owners")); + } + try (var repo = new Repository(directory.resolve("farm.db"))) { + assertTrue(repo.value.migrateOwners(() -> false, id -> Optional.empty())); + assertEquals(1, scalar(connection(repo.value), "SELECT COUNT(*) FROM husbandry_owner_archive")); + } + } + + @Test void heldCoownerPreservesOtherSlotAndAccountCapacityDeduplicatesAnimals() throws Exception { + try (var repo = new Repository(directory.resolve("farm.db"))) { + var r = repo.value; var animal = animal(r); + legacy(r, animal.uuid(), account, "owner"); legacy(r, animal.uuid(), otherAccount, "coowner"); + r.migrateOwners(() -> true, id -> id.equals(account) ? Optional.of(heir()) : Optional.empty()); + assertEquals(1, r.listOwners(animal.uuid()).size()); assertTrue(r.hasHeldOwner(animal.uuid())); + assertEquals(1, r.heldOwners()); assertEquals(1, r.countForPlayer(otherAccount)); + assertTrue(r.accountHasAnimal(otherAccount, animal.uuid())); + r.upsertOwner(new HusbandryOwner(animal.uuid(), account, UUID.randomUUID(), "coowner")); + assertEquals(1, r.countForPlayer(account)); assertEquals(1, r.listForPlayer(account).size()); + for (var owner : r.listOwners(animal.uuid())) r.deleteCharacterOwner(animal.uuid(), owner.characterUuid()); + assertTrue(r.hasAnyOwner(animal.uuid())); assertTrue(r.listOwners(animal.uuid()).isEmpty()); + assertFalse(r.accountHasAnimal(null, animal.uuid())); assertFalse(r.accountHasAnimal(account, null)); + assertFalse(r.hasAnyOwner(null)); assertFalse(r.hasHeldOwner(null)); assertTrue(r.listForCharacter(null).isEmpty()); + } + } + + @Test void faultsBeforePublicationRollBackArchivePlanAndSlotsThenReplayConservesPayload() throws Exception { + try (var repo = new Repository(directory.resolve("farm.db"))) { + var r = repo.value; var animal = animal(r); legacy(r, animal.uuid(), account, "owner"); + update(connection(r), "CREATE TRIGGER fail_publish BEFORE INSERT ON character_owners BEGIN SELECT RAISE(ABORT, 'fault'); END"); + assertThrows(SqliteDatabaseException.class, () -> r.migrateOwners(() -> true, id -> Optional.of(heir()))); + assertEquals(0, scalar(connection(r), "SELECT COUNT(*) FROM sqlite_master WHERE name = 'husbandry_owner_archive'")); + assertEquals(0, scalar(connection(r), "SELECT COUNT(*) FROM husbandry_owner_plan")); + assertEquals(0, scalar(connection(r), "SELECT COUNT(*) FROM character_owners")); + assertEquals(47, r.getAnimal(animal.uuid()).orElseThrow().care()); + update(connection(r), "DROP TRIGGER fail_publish"); r.migrateOwners(() -> true, id -> Optional.of(heir())); + assertEquals(1, r.listOwners(animal.uuid()).size()); + update(connection(r), "CREATE TRIGGER fail_owner BEFORE INSERT ON character_owners BEGIN SELECT RAISE(ABORT, 'fault'); END"); + var next = new HusbandryAnimal(UUID.randomUUID(), "SHEEP", "Not published"); + assertThrows(SqliteDatabaseException.class, () -> r.saveAnimalAndOwner(next, + new HusbandryOwner(next.uuid(), account, character, "owner"))); + assertFalse(r.exists(next.uuid())); + assertThrows(SqliteDatabaseException.class, () -> r.upsertOwner(new HusbandryOwner(animal.uuid(), account, UUID.randomUUID(), "owner"))); + } + } + + @Test void invalidIdentitiesRolesAndSlotCollisionsRemainHeldWithoutNormalization() throws Exception { + try (var repo = new Repository(directory.resolve("farm.db"))) { + var r = repo.value; var unknown = animal(r); legacy(r, unknown.uuid(), account, "future-owner"); + var conflicting = animal(r); legacy(r, conflicting.uuid(), account, "owner"); + r.upsertOwner(new HusbandryOwner(conflicting.uuid(), otherAccount, character, "owner")); + update(connection(r), "PRAGMA foreign_keys = OFF"); + update(connection(r), "INSERT INTO owners VALUES ('broken-animal', 'broken-account', 'owner')"); + UUID missing = UUID.randomUUID(); legacy(r, missing, account, "owner"); + update(connection(r), "PRAGMA foreign_keys = ON"); + r.migrateOwners(() -> true, id -> Optional.of(heir())); + assertEquals(4, r.heldOwners()); assertTrue(r.hasAnyOwner(unknown.uuid())); + assertEquals("future-owner", rows(connection(r), "SELECT role FROM husbandry_owner_archive WHERE animal_uuid = ?", unknown.uuid().toString()).getFirst()[0]); + assertEquals(1, r.listOwners(conflicting.uuid()).size()); + assertThrows(IllegalArgumentException.class, () -> r.upsertOwner(new HusbandryOwner(unknown.uuid(), account, character, "strange"))); + assertThrows(IllegalArgumentException.class, () -> r.upsertOwner(new HusbandryOwner(conflicting.uuid(), account, character, "owner"))); + assertThrows(IllegalArgumentException.class, () -> r.saveAnimalAndOwner(conflicting, new HusbandryOwner(unknown.uuid(), account, character, "owner"))); + assertThrows(IllegalArgumentException.class, () -> r.saveAnimalAndOwner(conflicting, new HusbandryOwner(conflicting.uuid(), account, character, "owner"))); + update(connection(r), "INSERT INTO character_owners VALUES (?, 'bad', 'bad', 'owner')", unknown.uuid().toString()); + update(connection(r), "INSERT INTO character_owners VALUES (?, ?, ?, 'future')", unknown.uuid().toString(), account.toString(), character.toString()); + assertTrue(r.listOwners(unknown.uuid()).isEmpty()); + assertThrows(IllegalArgumentException.class, () -> r.upsertOwner(new HusbandryOwner(unknown.uuid(), account, character, "owner"))); + } + } + + @Test void recognizedCollisionKeepsStrongerRoleAndWrongAccountHeirRemainsHeld() throws Exception { + try (var repo = new Repository(directory.resolve("farm.db"))) { + var r = repo.value; var first = animal(r); legacy(r, first.uuid(), account, "coowner"); + r.upsertOwner(new HusbandryOwner(first.uuid(), account, character, "owner")); + var second = animal(r); legacy(r, second.uuid(), otherAccount, "owner"); + r.migrateOwners(() -> true, id -> Optional.of(heir())); + assertEquals("owner", r.listOwners(first.uuid()).getFirst().role()); + assertEquals(1, r.heldOwners()); assertTrue(r.hasHeldOwner(second.uuid())); + assertEquals("coowner", stronger("coowner", "coowner")); + assertTrue(r.accountHasAnimal(account, first.uuid())); assertFalse(r.accountHasAnimal(account, second.uuid())); + } + } + + @Test void corruptedSchemaFailsClosedAndBindingFailureClosesStatement() throws Exception { + try (var repo = new Repository(directory.resolve("farm.db"))) { + var r = repo.value; var animal = animal(r); + update(connection(r), "DROP TABLE character_owners"); + assertThrows(SqliteDatabaseException.class, () -> r.listOwners(animal.uuid())); + assertThrows(SqliteDatabaseException.class, () -> r.countForPlayer(account)); + } + var db = org.mockito.Mockito.mock(net.tfminecraft.tlibs.database.SqliteDatabase.class); + var connection = org.mockito.Mockito.mock(Connection.class); + var statement = org.mockito.Mockito.mock(java.sql.PreparedStatement.class); + org.mockito.Mockito.when(db.getConnection()).thenReturn(connection); + org.mockito.Mockito.when(connection.prepareStatement(org.mockito.ArgumentMatchers.anyString())).thenReturn(statement); + org.mockito.Mockito.doThrow(new java.sql.SQLException("Binding interrupted")).when(statement).setObject(1, account.toString()); + var store = new HusbandryOwnershipStore(db); + assertThrows(SqliteDatabaseException.class, () -> store.countAccount(account)); + org.mockito.Mockito.verify(statement).close(); + } + + @Test void archiveVerificationFailureRollsBackAndNeverPublishesRecipients() throws Exception { + try (var repo = new Repository(directory.resolve("farm.db"))) { + var real = repo.value.ownershipDatabase(); + var proxy = org.mockito.Mockito.spy(real.getConnection()); + var broken = org.mockito.Mockito.mock(java.sql.PreparedStatement.class); + var count = org.mockito.Mockito.mock(java.sql.ResultSet.class); + org.mockito.Mockito.when(count.getInt(1)).thenReturn(-1); + org.mockito.Mockito.when(broken.executeQuery()).thenReturn(count); + org.mockito.Mockito.doReturn(broken).when(proxy).prepareStatement("SELECT COUNT(*) FROM husbandry_owner_archive"); + var db = org.mockito.Mockito.spy(real); org.mockito.Mockito.doReturn(proxy).when(db).getConnection(); + var store = new HusbandryOwnershipStore(db); + assertThrows(SqliteDatabaseException.class, () -> store.migrate(() -> true, id -> Optional.empty())); + assertEquals(0, scalar(real.getConnection(), "SELECT COUNT(*) FROM sqlite_master WHERE name = 'husbandry_owner_archive'")); + } + } + + @Test void compatibilityAccountApisRemainPresentButCannotGrantOrDeleteCharacterRights() { + try (var fixture = new Repository(directory.resolve("farm.db"))) { + var repo = fixture.value; var animal = animal(repo); + var legacy = new HusbandryOwner(animal.uuid(), account, "owner"); + assertNull(legacy.characterUuid()); assertEquals(account, legacy.playerUuid()); + assertThrows(IllegalArgumentException.class, () -> repo.upsertOwner(legacy)); + repo.upsertOwner(new HusbandryOwner(animal.uuid(), account, character, "owner")); + assertFalse(HusbandryOwnershipService.isOwner(account, animal.uuid())); + assertThrows(IllegalArgumentException.class, () -> repo.deleteOwner(animal.uuid(), account)); + assertEquals(1, repo.listOwners(animal.uuid()).size()); + } + } + + @Test void finalResolvedSlotReleaseDoesNotLetRetainedLegacyRowsKeepAnimalsOwned() throws Exception { + try(var fixture=new Repository(directory.resolve("farm.db"))) { + var repo=fixture.value;var animal=animal(repo);legacy(repo,animal.uuid(),account,"owner"); + repo.migrateOwners(()->true,id->Optional.of(heir())); + assertEquals(1,repo.listOwnedAnimals().size()); + repo.deleteCharacterOwner(animal.uuid(),character); + assertFalse(repo.hasAnyOwner(animal.uuid()));assertTrue(repo.listOwnedAnimals().isEmpty()); + assertEquals(1,scalar(connection(repo),"SELECT COUNT(*) FROM owners")); + var held=animal(repo);legacy(repo,held.uuid(),otherAccount,"coowner"); + repo.upsertOwner(new HusbandryOwner(held.uuid(),account,character,"owner")); + repo.deleteCharacterOwner(held.uuid(),character); + assertTrue(repo.hasAnyOwner(held.uuid()));assertEquals(1,repo.listOwnedAnimals().size()); + } + } + + static class Repository implements AutoCloseable { + final HusbandryRepository value; + Repository(Path path) { value = HusbandryRepository.open(path.toFile()); } + @Override public void close() { value.close(); } + } +} diff --git a/src/test/java/net/tfminecraft/breedingbuddies/husbandry/HusbandryProductsTest.java b/src/test/java/net/tfminecraft/breedingbuddies/husbandry/HusbandryProductsTest.java new file mode 100644 index 0000000..c121d9e --- /dev/null +++ b/src/test/java/net/tfminecraft/breedingbuddies/husbandry/HusbandryProductsTest.java @@ -0,0 +1,91 @@ +package net.tfminecraft.breedingbuddies.husbandry; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertTrue; + +import java.util.List; + +import org.junit.jupiter.api.Test; + +class HusbandryProductsTest { + + @Test + void emptySpeciesHasNoProducts() { + assertFalse(HusbandryProducts.hasProducts(null)); + assertFalse(HusbandryProducts.hasProducts(species(false, "", empty(), empty(), empty(), ""))); + } + + @Test + void slaughterShowsProducts() { + HusbandrySpecies species = species( + false, + "food(type=roast;origin=PORK)", + empty(), + empty(), + empty(), + ""); + assertTrue(HusbandryProducts.hasProducts(species)); + assertEquals(List.of("On slaughter"), HusbandryProducts.modeLines(species)); + } + + @Test + void chickenListsSlaughterShedAndEggs() { + HusbandrySpecies species = species( + false, + "food(type=roast;origin=CHICKEN)", + empty(), + empty(), + countedTable(), + "vanilla"); + assertTrue(HusbandryProducts.hasProducts(species)); + assertEquals(List.of("On slaughter", "Shed", "Eggs"), HusbandryProducts.modeLines(species)); + } + + @Test + void capabilityFlagsFollowConfigSections() { + HusbandrySpecies shearOnly = species(false, "", empty(), countedTable(), empty(), ""); + assertFalse(shearOnly.canSlaughter()); + assertTrue(shearOnly.canShear()); + assertFalse(shearOnly.canMilk()); + + HusbandrySpecies milkOnly = species(true, "", empty(), empty(), empty(), ""); + assertTrue(milkOnly.canMilk()); + assertFalse(milkOnly.canSlaughter()); + } + + @Test + void cowListsSlaughterAndMilk() { + HusbandrySpecies species = species( + true, + "food(type=roast;origin=BEEF)", + countedTable(), + empty(), + empty(), + ""); + assertEquals(List.of("On slaughter", "Milk"), HusbandryProducts.modeLines(species)); + } + + private static HusbandrySpecies species( + boolean milk, + String slaughterMeat, + HusbandryDropTable slaughterDrops, + HusbandryDropTable shearDrops, + HusbandryDropTable shedDrops, + String egg) { + return new HusbandrySpecies(null, milk, slaughterMeat, slaughterDrops, shearDrops, shedDrops, egg, 0, 0, 0); + } + + private static HusbandryDropTable empty() { + return HusbandryDropTable.empty(); + } + + private static HusbandryDropTable countedTable() { + return new HusbandryDropTable( + List.of(new HusbandryDropEntry("v.feather", 1, 100)), + List.of(), + List.of(), + List.of(), + true); + } +} diff --git a/src/test/java/net/tfminecraft/breedingbuddies/husbandry/HusbandryQualityRangeTest.java b/src/test/java/net/tfminecraft/breedingbuddies/husbandry/HusbandryQualityRangeTest.java new file mode 100644 index 0000000..0b392c2 --- /dev/null +++ b/src/test/java/net/tfminecraft/breedingbuddies/husbandry/HusbandryQualityRangeTest.java @@ -0,0 +1,50 @@ +package net.tfminecraft.breedingbuddies.husbandry; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertTrue; + +import java.util.List; +import java.util.Random; + +import org.junit.jupiter.api.Test; + +class HusbandryQualityRangeTest { + + private static final List BANDS = List.of( + new HusbandryQualityBand(0, 1), + new HusbandryQualityBand(200, 2), + new HusbandryQualityBand(400, 3), + new HusbandryQualityBand(600, 4), + new HusbandryQualityBand(800, 5)); + + @Test + void highGeneticsZeroCareIsOneToFive() { + HusbandryQualityRange.Bounds range = HusbandryQualityRange.of(1000, 0, 200, BANDS); + assertEquals(1, range.min()); + assertEquals(5, range.max()); + } + + @Test + void highGeneticsFullCareIsFiveToFive() { + HusbandryQualityRange.Bounds range = HusbandryQualityRange.of(1000, 200, 200, BANDS); + assertEquals(5, range.min()); + assertEquals(5, range.max()); + } + + @Test + void twoHundredGeneticsZeroCareIsOneToTwo() { + HusbandryQualityRange.Bounds range = HusbandryQualityRange.of(200, 0, 200, BANDS); + assertEquals(1, range.min()); + assertEquals(2, range.max()); + } + + @Test + void rollStaysInsideRange() { + HusbandryQualityRange.Bounds range = new HusbandryQualityRange.Bounds(2, 4); + Random random = new Random(1L); + for (int i = 0; i < 50; i++) { + int rolled = HusbandryQualityRange.roll(range, random); + assertTrue(rolled >= 2 && rolled <= 4, "rolled " + rolled); + } + } +} diff --git a/src/test/java/net/tfminecraft/breedingbuddies/husbandry/HusbandryRemainingCoverageTest.java b/src/test/java/net/tfminecraft/breedingbuddies/husbandry/HusbandryRemainingCoverageTest.java new file mode 100644 index 0000000..4ec1e46 --- /dev/null +++ b/src/test/java/net/tfminecraft/breedingbuddies/husbandry/HusbandryRemainingCoverageTest.java @@ -0,0 +1,444 @@ +package net.tfminecraft.breedingbuddies.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.breedingbuddies.BreedingBuddies; +import net.tfminecraft.breedingbuddies.husbandry.HusbandryRuntimeCoverageTest.Farm; +import net.tfminecraft.cooking.api.CookingFoodApi; +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.deleteCharacterOwner(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); + BreedingBuddies.plugin = null; assertTrue(command.onCommand(f.player, null, "animals", new String[0])); assertTrue(f.player.nextMessage().contains("not available")); BreedingBuddies.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()); assertEquals("Your character's animals (1)", ChatColor.stripColor(f.player.nextMessage())); assertEquals("Account animal places: 1/2", ChatColor.stripColor(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(CookingApiStub.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 harvestFoodAsksCookingForTheConfiguredRoastPortion() { + HusbandryAnimal animal = f.record(f.spawn(Cow.class)); animal.setGenetics(HusbandryConfig.maxGenetics()); animal.setCare(HusbandryConfig.careMax()); + assertNull(HusbandryHarvest.buildFood(null, "roast")); f.cooking.api.verifyNoInteractions(); + int cuts = HusbandryConfig.roastCutsFor(HusbandryConfig.effectiveGenetics(animal)); ItemStack roast = new ItemStack(Material.COOKED_BEEF); + f.cooking.api.when(() -> CookingFoodApi.buildRoast(eq("roast"), anyInt(), eq(cuts))).thenReturn(roast); + assertSame(roast, HusbandryHarvest.buildFood(animal, "roast")); + f.cooking.api.verify(() -> CookingFoodApi.buildRoast(eq("roast"), intThat(stars -> stars >= 1 && stars <= 5), eq(cuts))); + assertNull(HusbandryHarvest.buildMilk(null, EntityType.GOAT)); assertEquals(Material.MILK_BUCKET, HusbandryHarvest.buildMilk(animal, EntityType.GOAT).getType()); + f.cooking.api.verify(() -> CookingFoodApi.buildMilk(eq(EntityType.GOAT), intThat(stars -> stars >= 1 && stars <= 5))); + } + + @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, null)); repository.deleteCharacterOwner(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); + assertEquals(HusbandrySnapshots.RestoreResult.SCHEDULED, 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); + assertEquals(HusbandrySnapshots.RestoreResult.SCHEDULED, 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/breedingbuddies/husbandry/HusbandryRepositoryTest.java b/src/test/java/net/tfminecraft/breedingbuddies/husbandry/HusbandryRepositoryTest.java new file mode 100644 index 0000000..7592506 --- /dev/null +++ b/src/test/java/net/tfminecraft/breedingbuddies/husbandry/HusbandryRepositoryTest.java @@ -0,0 +1,309 @@ +package net.tfminecraft.breedingbuddies.husbandry; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertNull; +import static org.junit.jupiter.api.Assertions.assertTrue; + +import java.io.File; +import java.nio.file.Path; +import java.sql.Connection; +import java.sql.DriverManager; +import java.sql.PreparedStatement; +import java.util.Arrays; +import java.util.List; +import java.util.Random; +import java.util.UUID; + +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.io.TempDir; + +class HusbandryRepositoryTest { + + @Test + void batchUpsertInsertsAndUpdates(@TempDir Path tempDir) { + File dbFile = tempDir.resolve("husbandry.db").toFile(); + HusbandryRepository repository = HusbandryRepository.open(dbFile); + try { + HusbandryAnimal first = animal(UUID.randomUUID(), "COW", "Bess", 12); + HusbandryAnimal second = animal(UUID.randomUUID(), "SHEEP", "Wool", 4); + HusbandryAnimal third = animal(UUID.randomUUID(), "PIG", "Ham", 8); + repository.upsertAnimals(List.of(first, second, third)); + + assertEquals(12, repository.getAnimal(first.uuid()).orElseThrow().care()); + assertEquals("Wool", repository.getAnimal(second.uuid()).orElseThrow().name()); + assertEquals("PIG", repository.getAnimal(third.uuid()).orElseThrow().type()); + + first.setCare(40); + first.setGenetics(250); + first.setHungrySince(1_700_000_000_000L); + first.setCareUpRemainderSeconds(123); + first.setCareDownRemainderSeconds(45); + repository.upsertAnimals(Arrays.asList(first, null)); + + HusbandryAnimal reloaded = repository.getAnimal(first.uuid()).orElseThrow(); + assertEquals(40, reloaded.care()); + assertEquals(250, reloaded.genetics()); + assertEquals(1_700_000_000_000L, reloaded.hungrySince()); + assertEquals(123, reloaded.careUpRemainderSeconds()); + assertEquals(45, reloaded.careDownRemainderSeconds()); + assertEquals(4, repository.getAnimal(second.uuid()).orElseThrow().care()); + assertTrue(repository.exists(third.uuid())); + } finally { + repository.close(); + } + } + + @Test + void missingRevisionResetsToWildAndStamps(@TempDir Path tempDir) { + File dbFile = tempDir.resolve("husbandry.db").toFile(); + HusbandryRepository repository = HusbandryRepository.open(dbFile); + try { + HusbandryAnimal animal = animal(UUID.randomUUID(), "SHEEP", "Bess", 40); + animal.setGenetics(900); + animal.setNeutered(true); + animal.setMatureAt(12_345L); + animal.setCareUpRemainderSeconds(9); + animal.setCareDownRemainderSeconds(3); + repository.upsertAnimal(animal); + + Random random = new Random(1L); + int expectedGenes = new Random(1L).nextInt(HusbandryConfig.initialGeneticMax() + 1); + int reset = repository.resetStaleStats("1", random); + assertEquals(1, reset); + + HusbandryAnimal reloaded = repository.getAnimal(animal.uuid()).orElseThrow(); + assertEquals(expectedGenes, reloaded.genetics()); + assertEquals(0, reloaded.care()); + assertEquals(0, reloaded.careUpRemainderSeconds()); + assertEquals(0, reloaded.careDownRemainderSeconds()); + assertEquals("1", reloaded.statsRevision()); + assertEquals("Bess", reloaded.name()); + assertTrue(reloaded.neutered()); + assertEquals(12_345L, reloaded.matureAt()); + } finally { + repository.close(); + } + } + + @Test + void matchingRevisionIsUnchanged(@TempDir Path tempDir) { + File dbFile = tempDir.resolve("husbandry.db").toFile(); + HusbandryRepository repository = HusbandryRepository.open(dbFile); + try { + HusbandryAnimal animal = animal(UUID.randomUUID(), "COW", "Bess", 40); + animal.setGenetics(250); + animal.setStatsRevision("1"); + repository.upsertAnimal(animal); + + int reset = repository.resetStaleStats("1", new Random(1L)); + assertEquals(0, reset); + + HusbandryAnimal reloaded = repository.getAnimal(animal.uuid()).orElseThrow(); + assertEquals(250, reloaded.genetics()); + assertEquals(40, reloaded.care()); + assertEquals("1", reloaded.statsRevision()); + } finally { + repository.close(); + } + } + + @Test + void bumpedRevisionResetsMatchingRows(@TempDir Path tempDir) { + File dbFile = tempDir.resolve("husbandry.db").toFile(); + HusbandryRepository repository = HusbandryRepository.open(dbFile); + try { + HusbandryAnimal animal = animal(UUID.randomUUID(), "PIG", "Ham", 80); + animal.setGenetics(700); + animal.setStatsRevision("1"); + repository.upsertAnimal(animal); + + Random random = new Random(2L); + int expectedGenes = new Random(2L).nextInt(HusbandryConfig.initialGeneticMax() + 1); + int reset = repository.resetStaleStats("2", random); + assertEquals(1, reset); + + HusbandryAnimal reloaded = repository.getAnimal(animal.uuid()).orElseThrow(); + assertEquals(expectedGenes, reloaded.genetics()); + assertEquals(0, reloaded.care()); + assertEquals("2", reloaded.statsRevision()); + assertEquals("Ham", reloaded.name()); + } finally { + repository.close(); + } + } + + @Test + void locationRoundTripsAndListsOnlyThatPlayersAnimals(@TempDir Path tempDir) { + File dbFile = tempDir.resolve("husbandry.db").toFile(); + HusbandryRepository repository = HusbandryRepository.open(dbFile); + try { + UUID player = UUID.randomUUID(); + UUID other = UUID.randomUUID(); + HusbandryAnimal cow = animal(UUID.randomUUID(), "COW", "Bess", 10); + cow.setLastLocation("world", 120, 64, -340); + HusbandryAnimal sheep = animal(UUID.randomUUID(), "SHEEP", "Woolly", 4); + HusbandryAnimal pig = animal(UUID.randomUUID(), "PIG", "Ham", 1); + repository.upsertAnimals(List.of(cow, sheep, pig)); + repository.upsertOwner(new HusbandryOwner(cow.uuid(), player, player, "owner")); + repository.upsertOwner(new HusbandryOwner(sheep.uuid(), player, player, "coowner")); + repository.upsertOwner(new HusbandryOwner(sheep.uuid(), other, other, "owner")); + repository.upsertOwner(new HusbandryOwner(pig.uuid(), other, other, "owner")); + + HusbandryAnimal reloaded = repository.getAnimal(cow.uuid()).orElseThrow(); + assertEquals("world", reloaded.world()); + assertEquals(120, reloaded.x()); + assertEquals(64, reloaded.y()); + assertEquals(-340, reloaded.z()); + assertFalse(repository.getAnimal(sheep.uuid()).orElseThrow().hasLocation()); + + List mine = repository.listForPlayer(player); + assertEquals(2, mine.size()); + assertEquals("owner", roleOf(mine, cow.uuid())); + assertEquals("coowner", roleOf(mine, sheep.uuid())); + assertEquals(1, repository.listForPlayer(other).stream() + .filter(row -> row.animal().uuid().equals(pig.uuid())) + .count()); + assertTrue(repository.listForPlayer(UUID.randomUUID()).isEmpty()); + + HusbandryAnimal wild = animal(UUID.randomUUID(), "COW", "", 0); + repository.upsertAnimal(wild); + assertEquals( + java.util.Set.of(cow.uuid(), sheep.uuid(), pig.uuid()), + repository.listOwnedAnimals().stream() + .map(HusbandryAnimal::uuid) + .collect(java.util.stream.Collectors.toSet())); + } finally { + repository.close(); + } + } + + @Test + void versionSevenGainsLocationColumnsWithoutLosingRows(@TempDir Path tempDir) throws Exception { + File dbFile = tempDir.resolve("legacy.db").toFile(); + UUID uuid = UUID.randomUUID(); + try (Connection connection = DriverManager.getConnection("jdbc:sqlite:" + dbFile.getAbsolutePath())) { + connection.createStatement().execute(""" + CREATE TABLE animals ( + uuid TEXT PRIMARY KEY, + type TEXT NOT NULL, + name TEXT NOT NULL DEFAULT '', + state TEXT NOT NULL DEFAULT 'untamed', + genetics INTEGER NOT NULL DEFAULT 0, + care INTEGER NOT NULL DEFAULT 0, + hungry_since INTEGER, + dirty_since INTEGER, + last_processed_at INTEGER NOT NULL, + unloaded_at INTEGER, + affliction_elapsed REAL NOT NULL DEFAULT 0, + affliction_at REAL NOT NULL DEFAULT 0, + last_milk_at INTEGER, + wool_ready_at INTEGER, + neutered INTEGER NOT NULL DEFAULT 0, + loaded_visit_start INTEGER, + mature_at INTEGER, + shed_ready_at INTEGER, + egg_ready_at INTEGER, + care_up_remainder INTEGER NOT NULL DEFAULT 0, + care_down_remainder INTEGER NOT NULL DEFAULT 0, + stats_revision TEXT + ) + """); + connection.createStatement().execute("PRAGMA user_version = 7"); + try (PreparedStatement insert = connection.prepareStatement( + "INSERT INTO animals (uuid, type, name, state, genetics, care, last_processed_at, stats_revision) VALUES (?, ?, ?, ?, ?, ?, ?, ?)")) { + insert.setString(1, uuid.toString()); + insert.setString(2, "GOAT"); + insert.setString(3, "Nanny"); + insert.setString(4, "owned"); + insert.setInt(5, 440); + insert.setInt(6, 80); + insert.setLong(7, 50L); + insert.setString(8, "1"); + insert.executeUpdate(); + } + } + + HusbandryRepository repository = HusbandryRepository.open(dbFile); + try { + HusbandryAnimal loaded = repository.getAnimal(uuid).orElseThrow(); + assertEquals("Nanny", loaded.name()); + assertEquals(440, loaded.genetics()); + assertEquals(80, loaded.care()); + assertFalse(loaded.hasLocation()); + assertNull(loaded.world()); + loaded.setLastLocation("tfmc_world", 3, 70, 9); + repository.upsertAnimal(loaded); + } finally { + repository.close(); + } + + try (Connection connection = DriverManager.getConnection("jdbc:sqlite:" + dbFile.getAbsolutePath())) { + assertEquals(8, connection.createStatement().executeQuery("PRAGMA user_version").getInt(1)); + } + HusbandryRepository again = HusbandryRepository.open(dbFile); + try { + HusbandryAnimal loaded = again.getAnimal(uuid).orElseThrow(); + assertEquals("tfmc_world", loaded.world()); + assertEquals(3, loaded.x()); + assertEquals(70, loaded.y()); + assertEquals(9, loaded.z()); + assertEquals(440, loaded.genetics()); + } finally { + again.close(); + } + } + + private static String roleOf(List rows, UUID animalUuid) { + return rows.stream() + .filter(row -> animalUuid.equals(row.animal().uuid())) + .findFirst() + .orElseThrow() + .role(); + } + + @Test + void snapshotsRoundTripAndGoWithTheirAnimal(@TempDir Path tempDir) { + File dbFile = tempDir.resolve("husbandry.db").toFile(); + HusbandryRepository repository = HusbandryRepository.open(dbFile); + try { + HusbandryAnimal kept = animal(UUID.randomUUID(), "HORSE", "Drake Maye", 200); + HusbandryAnimal dropped = animal(UUID.randomUUID(), "HORSE", "gregor", 200); + UUID noRow = UUID.randomUUID(); + repository.upsertAnimals(List.of(kept, dropped)); + + repository.upsertSnapshots( + java.util.Map.of(kept.uuid(), new byte[] {1, 2, 3}, dropped.uuid(), new byte[] {4}, noRow, + new byte[] {5}), + 100L); + HusbandrySnapshot stored = repository.getSnapshot(kept.uuid()).orElseThrow(); + assertTrue(Arrays.equals(new byte[] {1, 2, 3}, stored.data())); + assertEquals(100L, stored.savedAt()); + assertTrue(repository.getSnapshot(noRow).isEmpty()); + + repository.upsertSnapshots(java.util.Map.of(kept.uuid(), new byte[] {9}), 200L); + stored = repository.getSnapshot(kept.uuid()).orElseThrow(); + assertTrue(Arrays.equals(new byte[] {9}, stored.data())); + assertEquals(200L, stored.savedAt()); + + repository.deleteAnimal(dropped.uuid()); + assertTrue(repository.getSnapshot(dropped.uuid()).isEmpty()); + + repository.deleteSnapshot(kept.uuid()); + assertTrue(repository.getSnapshot(kept.uuid()).isEmpty()); + assertTrue(repository.exists(kept.uuid())); + assertTrue(repository.getSnapshot(null).isEmpty()); + } finally { + repository.close(); + } + } + + private static HusbandryAnimal animal(UUID uuid, String type, String name, int care) { + HusbandryAnimal animal = new HusbandryAnimal(uuid, type, name); + animal.setCare(care); + animal.setGenetics(100); + animal.setLastProcessedAt(1_234L); + animal.setLoadedVisitStart(2_000L); + return animal; + } +} diff --git a/src/test/java/net/tfminecraft/breedingbuddies/husbandry/HusbandryRosterTest.java b/src/test/java/net/tfminecraft/breedingbuddies/husbandry/HusbandryRosterTest.java new file mode 100644 index 0000000..64560bc --- /dev/null +++ b/src/test/java/net/tfminecraft/breedingbuddies/husbandry/HusbandryRosterTest.java @@ -0,0 +1,116 @@ +package net.tfminecraft.breedingbuddies.husbandry; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertNull; +import static org.junit.jupiter.api.Assertions.assertTrue; + +import java.util.List; +import java.util.UUID; + +import org.junit.jupiter.api.Test; + +import net.kyori.adventure.text.Component; +import net.kyori.adventure.text.event.ClickEvent; +import net.kyori.adventure.text.serializer.plain.PlainTextComponentSerializer; + +class HusbandryRosterTest { + + private static final PlainTextComponentSerializer PLAIN = PlainTextComponentSerializer.plainText(); + + @Test + void emptyRosterTellsTheOwnerHowToClaim() { + List lines = text(HusbandryRoster.render("Ada", true, List.of(), 15, 1_000L)); + assertEquals(List.of( + "Your animals (0/15)", + "You do not own any animals yet.", + "Claim one with a named Ownership Token."), lines); + } + + @Test + void listsNameSpeciesStatusAndCopyableCoordinates() { + HusbandryAnimal bess = animal("COW", "Bess"); + bess.setLastLocation("world", 120, 64, -340); + HusbandryAnimal woolly = animal("SHEEP", "§cWoolly"); + woolly.setHungrySince(1L); + woolly.setDirtySince(2L); + woolly.setLastLocation("world", 118, 65, -338); + HusbandryAnimal calf = animal("COW", ""); + calf.setMatureAt(5_000L); + calf.setDirtySince(1L); + + List lines = HusbandryRoster.render("Ada", true, List.of( + new HusbandryOwned(woolly, "owner"), + new HusbandryOwned(calf, "coowner"), + new HusbandryOwned(bess, "owner")), 15, 1_000L); + + assertEquals("Your animals (3/15)", PLAIN.serialize(lines.get(0))); + assertEquals("• Bess · Cow · Happy · world 120, 64, -340", PLAIN.serialize(lines.get(1))); + assertEquals("• Cow · Co-owner · Growing · Dirty · location not recorded yet", PLAIN.serialize(lines.get(2))); + assertEquals("• Woolly · Sheep · Hungry, Dirty · world 118, 65, -338", PLAIN.serialize(lines.get(3))); + + ClickEvent copy = findClick(lines.get(1)); + assertEquals(ClickEvent.Action.COPY_TO_CLIPBOARD, copy.action()); + assertEquals("world 120, 64, -340", copy.value()); + assertNull(findClick(lines.get(2))); + } + + @Test + void missingAnimalsAreFlaggedWithTheirLastKnownSpot() { + HusbandryAnimal bess = animal("COW", "Bess"); + bess.setLastLocation("TFMC_Map", 4369, 167, 1950); + HusbandryAnimal ham = animal("PIG", "Ham"); + HusbandryAnimal woolly = animal("SHEEP", "Woolly"); + woolly.setLastLocation("TFMC_Map", 10, 64, 10); + + List lines = text(HusbandryRoster.render("Ada", true, List.of( + new HusbandryOwned(bess, "owner"), + new HusbandryOwned(ham, "owner"), + new HusbandryOwned(woolly, "owner")), 15, 1_000L, + uuid -> !uuid.equals(woolly.uuid()))); + + assertEquals(List.of( + "Your animals (3/15)", + "• Bess · Cow · Happy · Missing · last seen TFMC_Map 4369, 167, 1950", + "• Ham · Pig · Happy · Missing", + "• Woolly · Sheep · Happy · TFMC_Map 10, 64, 10"), lines); + } + + @Test + void otherPlayersEmptyListUsesTheirName() { + List lines = text(HusbandryRoster.render("Ada", false, List.of(), 15, 1_000L)); + assertEquals(List.of( + "Ada's animals (0/15)", + "Ada does not own any animals."), lines); + } + + private static List text(List lines) { + return lines.stream().map(PLAIN::serialize).toList(); + } + + private static ClickEvent findClick(Component component) { + if (component.clickEvent() != null) { + return component.clickEvent(); + } + for (Component child : component.children()) { + ClickEvent found = findClick(child); + if (found != null) { + return found; + } + } + return null; + } + + private static HusbandryAnimal animal(String type, String name) { + HusbandryAnimal animal = new HusbandryAnimal(UUID.randomUUID(), type, name); + animal.setLastProcessedAt(1L); + return animal; + } + + @Test + void speciesLabelsAreReadable() { + assertEquals("Cow", HusbandryRoster.speciesLabel("COW")); + assertEquals("Mushroom Cow", HusbandryRoster.speciesLabel("MUSHROOM_COW")); + assertEquals("Animal", HusbandryRoster.speciesLabel(" ")); + assertTrue(HusbandryRoster.plain("§aBess\n").equals("Bess")); + } +} diff --git a/src/test/java/net/tfminecraft/breedingbuddies/husbandry/HusbandryRuntimeCoverageTest.java b/src/test/java/net/tfminecraft/breedingbuddies/husbandry/HusbandryRuntimeCoverageTest.java new file mode 100644 index 0000000..193cb00 --- /dev/null +++ b/src/test/java/net/tfminecraft/breedingbuddies/husbandry/HusbandryRuntimeCoverageTest.java @@ -0,0 +1,384 @@ +package net.tfminecraft.breedingbuddies.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.breedingbuddies.BreedingBuddies; +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(HusbandrySnapshots.RestoreResult.SCHEDULED); + 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(); BreedingBuddies.plugin = null; scan.background(); assertTrue(scan.sync.isEmpty()); BreedingBuddies.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 longAbsencesApplyEachMissingAfflictionWithoutGrantingTrust() throws Exception { + 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(0, 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(0, 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()); + 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); animal.setCare(7); + 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(7, f.repository.getAnimal(animal.uuid()).orElseThrow().care(), "The tick saves loaded animals"); 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(); + BreedingBuddies.plugin = null; HusbandryTickTask.start(); BreedingBuddies.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()); BreedingBuddies.plugin = null; assertNull(HusbandryEntities.repository()); assertTrue(HusbandryEntities.lookup(UUID.randomUUID()).isEmpty()); BreedingBuddies.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 BreedingBuddies plugin, previous; + final HusbandryRepository repository; + final YamlConfiguration config = new YamlConfiguration(); + final HusbandryLoaderCoverageTest.ConfigSnapshot snapshot; + final MockedStatic tlibs; + final CookingApiStub cooking; + final net.tfminecraft.breedingbuddies.testsupport.TestCharacters characters; + Farm(Path directory) throws Exception { + this.directory = directory; snapshot = new HusbandryLoaderCoverageTest.ConfigSnapshot(); previous = BreedingBuddies.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(BreedingBuddies.class); when(plugin.getName()).thenReturn("BreedingBuddies"); when(plugin.namespace()).thenReturn("breedingbuddies"); when(plugin.isEnabled()).thenReturn(true); BreedingBuddies.plugin = plugin; + characters = new net.tfminecraft.breedingbuddies.testsupport.TestCharacters(plugin); + characters.activate(player, player.getUniqueId()); characters.activate(neighbour, neighbour.getUniqueId()); + when(characters.provider.info(any())).thenAnswer(call -> { + UUID id = call.getArgument(0); Player online = server.getPlayer(id); + return online == null ? Optional.empty() : Optional.of(new net.tfminecraft.tlibs.character.CharacterInfo( + new net.tfminecraft.tlibs.character.CharacterRef(id, id), online.getName(), + net.tfminecraft.tlibs.character.CharacterStatus.ALIVE, null, Set.of(), 0, null)); + }); + 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); cooking = new CookingApiStub(); + 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.getUniqueId(), "owner")); } + @Override public void close() throws Exception { + for (HusbandryAnimal animal : repository.listOwnedAnimals()) HusbandryLocator.markFound(animal.uuid()); + HusbandryTickTask.stop(); HusbandryEntities.clearLoaded(); repository.close(); characters.close(); MockBukkit.unmock(); tlibs.close(); cooking.close(); BreedingBuddies.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/breedingbuddies/husbandry/HusbandrySimulatorTest.java b/src/test/java/net/tfminecraft/breedingbuddies/husbandry/HusbandrySimulatorTest.java new file mode 100644 index 0000000..5ffe318 --- /dev/null +++ b/src/test/java/net/tfminecraft/breedingbuddies/husbandry/HusbandrySimulatorTest.java @@ -0,0 +1,56 @@ +package net.tfminecraft.breedingbuddies.husbandry; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertTrue; + +import java.util.Random; +import java.util.UUID; + +import org.junit.jupiter.api.Test; + +class HusbandrySimulatorTest { + + private static final long MINUTE = 60_000L; + private static final long HOUR = 3_600_000L; + + @Test + void happyLoadedTimeNeverGrantsTrust() { + HusbandryAnimal animal = happyAnimal(40); + long now = animal.lastProcessedAt(); + for (int i = 1; i <= 120; i++) { + HusbandrySimulator.tickLoaded(animal, now + i * MINUTE, new Random(1)); + } + assertEquals(40, animal.care()); + assertTrue(HusbandrySimulator.isHappy(animal)); + } + + @Test + void happyUnloadedTimeNeverGrantsTrust() { + HusbandryAnimal animal = happyAnimal(40); + animal.setUnloadedAt(animal.lastProcessedAt()); + HusbandrySimulator.catchUp(animal, animal.lastProcessedAt() + 2 * HOUR, new Random(1)); + assertEquals(40, animal.care()); + } + + @Test + void loadedTimeStillBringsAfflictionsWithoutChangingTrust() { + HusbandryAnimal animal = happyAnimal(40); + animal.setAfflictionAt(0.001); + animal.setAfflictionElapsed(0); + HusbandrySimulator.tickLoaded(animal, animal.lastProcessedAt() + 2 * HOUR, new Random(1)); + assertEquals(40, animal.care()); + assertFalse(HusbandrySimulator.isHappy(animal)); + assertTrue(animal.hungrySince() != null || animal.dirtySince() != null); + } + + private static HusbandryAnimal happyAnimal(int care) { + HusbandryAnimal animal = new HusbandryAnimal(UUID.randomUUID(), "GOAT", "Test"); + animal.setCare(care); + animal.setLastProcessedAt(1_000_000L); + animal.setLoadedVisitStart(1_000_000L); + animal.setAfflictionAt(24); + animal.setAfflictionElapsed(0); + return animal; + } +} diff --git a/src/test/java/net/tfminecraft/breedingbuddies/husbandry/HusbandrySlaughterDropsTest.java b/src/test/java/net/tfminecraft/breedingbuddies/husbandry/HusbandrySlaughterDropsTest.java new file mode 100644 index 0000000..d385076 --- /dev/null +++ b/src/test/java/net/tfminecraft/breedingbuddies/husbandry/HusbandrySlaughterDropsTest.java @@ -0,0 +1,32 @@ +package net.tfminecraft.breedingbuddies.husbandry; + +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertTrue; + +import org.junit.jupiter.api.Test; + +class HusbandrySlaughterDropsTest { + + @Test + void immatureSlaughterLeavesVanilla() { + assertFalse(HusbandrySlaughterDrops.shouldReplaceVanilla(true, false)); + assertFalse(HusbandrySlaughterDrops.shouldAddRoast(true, false)); + } + + @Test + void matureSlaughterClearsVanillaAndAddsRoast() { + assertTrue(HusbandrySlaughterDrops.shouldReplaceVanilla(true, true)); + assertTrue(HusbandrySlaughterDrops.shouldAddRoast(true, true)); + } + + @Test + void pigAlwaysGetsRoastWhenMature() { + assertTrue(HusbandrySlaughterDrops.shouldAddRoast(true, true)); + } + + @Test + void noSlaughterLeavesVanilla() { + assertFalse(HusbandrySlaughterDrops.shouldReplaceVanilla(false, true)); + assertFalse(HusbandrySlaughterDrops.shouldAddRoast(false, true)); + } +} diff --git a/src/test/java/net/tfminecraft/breedingbuddies/husbandry/HusbandrySnapshotsTest.java b/src/test/java/net/tfminecraft/breedingbuddies/husbandry/HusbandrySnapshotsTest.java new file mode 100644 index 0000000..f9c0a93 --- /dev/null +++ b/src/test/java/net/tfminecraft/breedingbuddies/husbandry/HusbandrySnapshotsTest.java @@ -0,0 +1,262 @@ +package net.tfminecraft.breedingbuddies.husbandry; + +import static org.junit.jupiter.api.Assertions.assertArrayEquals; +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertNull; +import static org.junit.jupiter.api.Assertions.assertTrue; +import static net.tfminecraft.breedingbuddies.husbandry.HusbandrySnapshots.RestoreResult.*; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.anyInt; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.mockStatic; +import static org.mockito.Mockito.never; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +import java.util.List; +import java.util.Optional; +import java.util.UUID; +import java.util.concurrent.CompletableFuture; +import java.util.logging.Logger; + +import org.bukkit.Bukkit; +import org.bukkit.Chunk; +import org.bukkit.Location; +import org.bukkit.UnsafeValues; +import org.bukkit.World; +import org.bukkit.entity.Horse; +import org.bukkit.event.entity.CreatureSpawnEvent; +import org.bukkit.event.entity.EntityRemoveEvent; +import org.bukkit.scheduler.BukkitScheduler; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.mockito.MockedStatic; + +import io.papermc.paper.entity.EntitySerializationFlag; +import net.tfminecraft.breedingbuddies.BreedingBuddies; + +class HusbandrySnapshotsTest { + + private static final UUID HORSE = UUID.fromString("fe3e69ee-6182-4036-bfcb-450779909327"); + private static final UUID OWNER = UUID.fromString("0615a817-8cb4-4aef-95f7-f6c9bf7611b8"); + + private BreedingBuddies previousPlugin; + private BreedingBuddies plugin; + + @BeforeEach + void setUp() { + previousPlugin = BreedingBuddies.plugin; + plugin = mock(BreedingBuddies.class); + when(plugin.isEnabled()).thenReturn(true); + BreedingBuddies.plugin = plugin; + HusbandryConfig.setRestoreLostAnimals(true); + } + + @AfterEach + void tearDown() { + BreedingBuddies.plugin = previousPlugin; + HusbandryConfig.setRestoreLostAnimals(true); + HusbandryLocator.markFound(HORSE); + } + + @Test + void onlyUnloadsAndLoggedOutRidersKeepTheSnapshot() { + for (EntityRemoveEvent.Cause cause : EntityRemoveEvent.Cause.values()) { + boolean keeps = cause == EntityRemoveEvent.Cause.UNLOAD || cause == EntityRemoveEvent.Cause.PLAYER_QUIT; + assertEquals(keeps, HusbandrySnapshots.keepsSnapshot(cause), cause.name()); + } + } + + @Test + void onlyOwnedAnimalsAreCaptured() { + HusbandryAnimal animal = new HusbandryAnimal(HORSE, "HORSE", "Drake Maye"); + animal.setState(HusbandryAnimalState.OWNED); + assertTrue(HusbandrySnapshots.shouldCapture(animal)); + animal.setState(HusbandryAnimalState.UNTAMED); + assertFalse(HusbandrySnapshots.shouldCapture(animal)); + assertFalse(HusbandrySnapshots.shouldCapture(null)); + } + + @Test + void captureForcesOnlyWhenAskedAndSwallowsRefusals() { + Horse horse = mock(Horse.class); + UnsafeValues unsafe = mock(UnsafeValues.class); + when(unsafe.serializeEntity(horse)).thenReturn(new byte[] {1}); + when(unsafe.serializeEntity(horse, EntitySerializationFlag.FORCE)).thenReturn(new byte[] {2}); + try (MockedStatic bukkit = mockStatic(Bukkit.class)) { + bukkit.when(Bukkit::getUnsafe).thenReturn(unsafe); + + assertArrayEquals(new byte[] {1}, HusbandrySnapshots.capture(horse, false)); + assertArrayEquals(new byte[] {2}, HusbandrySnapshots.capture(horse, true)); + assertNull(HusbandrySnapshots.capture(null, false)); + + when(unsafe.serializeEntity(horse)).thenThrow(new IllegalArgumentException("not persistent")); + assertNull(HusbandrySnapshots.capture(horse, false)); + } + } + + @Test + void restoreSpawnsTheSnapshotWhereItWasTaken() { + HusbandryAnimal animal = lostHorse(); + World world = mock(World.class); + when(world.getName()).thenReturn("TFMC_Map"); + when(world.getChunkAtAsync(anyInt(), anyInt())) + .thenReturn(CompletableFuture.completedFuture(mock(Chunk.class))); + Horse restored = mock(Horse.class); + when(restored.getUniqueId()).thenReturn(HORSE); + when(restored.getLocation()).thenReturn(new Location(null, 4353.0, 355.0, 3601.2)); + when(restored.spawnAt(any(Location.class), any(CreatureSpawnEvent.SpawnReason.class))).thenReturn(true); + UnsafeValues unsafe = mock(UnsafeValues.class); + when(unsafe.deserializeEntity(new byte[] {7}, world, true)).thenReturn(restored); + HusbandryRepository repository = mock(HusbandryRepository.class); + when(repository.getSnapshot(HORSE)).thenReturn(Optional.of(new HusbandrySnapshot(new byte[] {7}, 0L))); + + try (MockedStatic bukkit = serverWith(world, unsafe); + MockedStatic lifecycle = mockStatic(HusbandryLifecycleListener.class)) { + assertEquals(SCHEDULED, HusbandrySnapshots.restore(repository, animal, owners())); + + verify(world).getChunkAtAsync(4353 >> 4, 3601 >> 4); + verify(restored).spawnAt(new Location(world, 4353.0, 355.0, 3601.2), + CreatureSpawnEvent.SpawnReason.CUSTOM); + lifecycle.verify(() -> HusbandryLifecycleListener.handleLoad(restored)); + } + } + + @Test + void restoreFallsBackWhenThereIsNothingToSpawn() { + HusbandryAnimal animal = lostHorse(); + World world = mock(World.class); + HusbandryRepository repository = mock(HusbandryRepository.class); + when(repository.getSnapshot(HORSE)).thenReturn(Optional.empty()); + try (MockedStatic bukkit = serverWith(world, mock(UnsafeValues.class))) { + assertEquals(SKIPPED, HusbandrySnapshots.restore(repository, animal, owners())); + + when(repository.getSnapshot(HORSE)).thenReturn(Optional.of(new HusbandrySnapshot(new byte[] {7}, 0L))); + HusbandryConfig.setRestoreLostAnimals(false); + assertEquals(SKIPPED, HusbandrySnapshots.restore(repository, animal, owners())); + + HusbandryConfig.setRestoreLostAnimals(true); + bukkit.when(() -> Bukkit.getWorld("TFMC_Map")).thenReturn(null); + assertEquals(FAILED, HusbandrySnapshots.restore(repository, animal, owners())); + } + verify(world, never()).getChunkAtAsync(anyInt(), anyInt()); + } + + @Test + void restoreRefusesASnapshotOfAnotherEntity() { + HusbandryAnimal animal = lostHorse(); + World world = mock(World.class); + Horse other = mock(Horse.class); + when(other.getUniqueId()).thenReturn(UUID.randomUUID()); + UnsafeValues unsafe = mock(UnsafeValues.class); + when(unsafe.deserializeEntity(new byte[] {7}, world, true)).thenReturn(other); + HusbandryRepository repository = mock(HusbandryRepository.class); + when(repository.getSnapshot(HORSE)).thenReturn(Optional.of(new HusbandrySnapshot(new byte[] {7}, 0L))); + try (MockedStatic bukkit = serverWith(world, unsafe)) { + assertEquals(FAILED, HusbandrySnapshots.restore(repository, animal, owners())); + + when(unsafe.deserializeEntity(new byte[] {7}, world, true)).thenThrow(new IllegalStateException("bad")); + assertEquals(FAILED, HusbandrySnapshots.restore(repository, animal, owners())); + } + verify(world, never()).getChunkAtAsync(anyInt(), anyInt()); + } + + @Test + void missingRuntimeContextCannotAuthorizeDeletion() { + HusbandryRepository repository = mock(HusbandryRepository.class); + HusbandryAnimal animal = lostHorse(); + assertEquals(FAILED, HusbandrySnapshots.restore(null, animal, owners())); + assertEquals(FAILED, HusbandrySnapshots.restore(repository, null, owners())); + BreedingBuddies.plugin = null; + assertEquals(FAILED, HusbandrySnapshots.restore(repository, animal, owners())); + } + + @Test + void anAnimalThatCameBackIsNotDuplicated() { + HusbandryAnimal animal = lostHorse(); + Horse copy = mock(Horse.class); + try (MockedStatic bukkit = mockStatic(Bukkit.class); + MockedStatic lifecycle = mockStatic(HusbandryLifecycleListener.class)) { + bukkit.when(() -> Bukkit.getEntity(HORSE)).thenReturn(mock(Horse.class)); + + HusbandrySnapshots.finish(animal, owners(), copy, new Location(null, 0, 0, 0), 0L); + + verify(copy, never()).spawnAt(any(Location.class), any(CreatureSpawnEvent.SpawnReason.class)); + lifecycle.verifyNoInteractions(); + } + } + + @Test + void aBlockedSpawnLeavesTheAnimalMissing() { + HusbandryAnimal animal = lostHorse(); + Horse copy = mock(Horse.class); + when(copy.spawnAt(any(Location.class), any(CreatureSpawnEvent.SpawnReason.class))).thenReturn(false); + try (MockedStatic bukkit = mockStatic(Bukkit.class); + MockedStatic lifecycle = mockStatic(HusbandryLifecycleListener.class)) { + bukkit.when(Bukkit::getLogger).thenReturn(Logger.getLogger("test")); + + HusbandrySnapshots.finish(animal, owners(), copy, new Location(null, 0, 0, 0), 0L); + + assertTrue(HusbandryLocator.isMissing(HORSE)); + lifecycle.verifyNoInteractions(); + } + } + + @Test + void deliberateRemovalOfATrackedAnimalDropsItsSnapshot() { + HusbandryRepository repository = mock(HusbandryRepository.class); + UUID stranger = UUID.randomUUID(); + try (MockedStatic entities = mockStatic(HusbandryEntities.class)) { + entities.when(HusbandryEntities::repository).thenReturn(repository); + entities.when(() -> HusbandryEntities.getLoaded(HORSE)).thenReturn(Optional.of(lostHorse())); + entities.when(() -> HusbandryEntities.getLoaded(stranger)).thenReturn(Optional.empty()); + + HusbandryLifecycleListener.handleRemove(HORSE, EntityRemoveEvent.Cause.UNLOAD); + HusbandryLifecycleListener.handleRemove(HORSE, EntityRemoveEvent.Cause.PLAYER_QUIT); + verify(repository, never()).deleteSnapshot(HORSE); + + HusbandryLifecycleListener.handleRemove(HORSE, EntityRemoveEvent.Cause.PLUGIN); + verify(repository).deleteSnapshot(HORSE); + + HusbandryLifecycleListener.handleRemove(stranger, EntityRemoveEvent.Cause.DISCARD); + verify(repository, never()).deleteSnapshot(stranger); + } + } + + @Test + void restoreLogNamesTheAnimalOwnersPlaceAndSnapshotTime() { + World world = mock(World.class); + when(world.getName()).thenReturn("TFMC_Map"); + String line = HusbandrySnapshots.restoreLog( + lostHorse(), owners(), new Location(world, 4353.0, 355.0, 3601.2), 1_790_000_000_000L); + assertEquals("[BreedingBuddies] Restored lost animal Drake Maye (HORSE) " + HORSE + " owners=" + OWNER + + " (owner) at TFMC_Map 4353, 355, 3601 from its snapshot of 2026-09-21T14:13:20Z", line); + } + + private MockedStatic serverWith(World world, UnsafeValues unsafe) { + MockedStatic bukkit = mockStatic(Bukkit.class); + BukkitScheduler scheduler = mock(BukkitScheduler.class); + when(scheduler.runTask(any(org.bukkit.plugin.Plugin.class), any(Runnable.class))).thenAnswer(call -> { + call.getArgument(1, Runnable.class).run(); + return null; + }); + bukkit.when(() -> Bukkit.getWorld("TFMC_Map")).thenReturn(world); + bukkit.when(Bukkit::getUnsafe).thenReturn(unsafe); + bukkit.when(Bukkit::getScheduler).thenReturn(scheduler); + bukkit.when(Bukkit::getLogger).thenReturn(Logger.getLogger("test")); + return bukkit; + } + + private static HusbandryAnimal lostHorse() { + HusbandryAnimal animal = new HusbandryAnimal(HORSE, "HORSE", "Drake Maye"); + animal.setState(HusbandryAnimalState.OWNED); + animal.setLastLocation("TFMC_Map", 4352, 355, 3601); + return animal; + } + + private static List owners() { + return List.of(new HusbandryOwner(HORSE, OWNER, OWNER, "owner")); + } +} diff --git a/src/test/java/net/tfminecraft/breedingbuddies/husbandry/HusbandryTrustTest.java b/src/test/java/net/tfminecraft/breedingbuddies/husbandry/HusbandryTrustTest.java new file mode 100644 index 0000000..96e70b5 --- /dev/null +++ b/src/test/java/net/tfminecraft/breedingbuddies/husbandry/HusbandryTrustTest.java @@ -0,0 +1,182 @@ +package net.tfminecraft.breedingbuddies.husbandry; + +import static org.junit.jupiter.api.Assertions.*; + +import java.nio.file.Path; +import java.util.Random; + +import org.bukkit.Material; +import org.bukkit.entity.Cow; +import org.bukkit.entity.Horse; +import org.bukkit.event.EventHandler; +import org.bukkit.event.EventPriority; +import org.bukkit.event.Listener; +import org.bukkit.event.entity.EntityMountEvent; +import org.bukkit.event.player.PlayerInteractEntityEvent; +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 net.kyori.adventure.text.serializer.plain.PlainTextComponentSerializer; +import net.tfminecraft.breedingbuddies.husbandry.HusbandryRuntimeCoverageTest.Farm; +import net.tfminecraft.breedingbuddies.husbandry.HusbandryTrust.Gesture; + +class HusbandryTrustTest { + private static final long HOUR = 3_600_000L; + @TempDir Path directory; + private Farm f; + + @BeforeEach void open() throws Exception { + f = new Farm(directory); + f.config.set("trust.feed", 10); f.config.set("trust.glove", 12); f.config.set("trust.pet", 5); f.config.set("trust.pet-cooldown", "20h"); + f.reload(); + } + + @AfterEach void close() throws Exception { f.close(); } + + @Test void eachGestureAddsItsTrustUpToTheMaximumAndPetsWaitForTheCooldown() { + HusbandryAnimal animal = f.record(f.spawn(Cow.class)); + assertEquals(0, HusbandryTrust.reward(null, Gesture.FEED, 0)); + assertEquals(0, HusbandryTrust.reward(animal, null, 0)); + assertEquals(10, HusbandryTrust.reward(animal, Gesture.FEED, 0)); + assertEquals(12, HusbandryTrust.reward(animal, Gesture.GLOVE, 0)); + assertEquals(5, HusbandryTrust.reward(animal, Gesture.PET, 0)); + assertEquals(0, HusbandryTrust.reward(animal, Gesture.PET, 19 * HOUR), "Still within the pet cooldown"); + assertEquals(5, HusbandryTrust.reward(animal, Gesture.PET, 20 * HOUR)); + assertEquals(32, animal.care()); + animal.setCare(HusbandryConfig.careMax() - 3); + assertEquals(3, HusbandryTrust.reward(animal, Gesture.FEED, 0)); + assertEquals(HusbandryConfig.careMax(), animal.care()); + } + + @Test void heartsRoundTrustToFiveAndShowAnyTrustAsAtLeastOne() { + int max = HusbandryConfig.careMax(); + assertEquals(0, HusbandryTrust.filledHearts(0)); + assertEquals(1, HusbandryTrust.filledHearts(1)); + assertEquals(3, HusbandryTrust.filledHearts(max / 2)); + assertEquals(5, HusbandryTrust.filledHearts(max)); + assertEquals("❤❤❤❤❤", plain(HusbandryTrust.hearts(max / 2))); + } + + @Test void owningPlayersPetWithAnEmptyStandingHandOncePerCooldown() { + Cow cow = f.spawn(Cow.class); HusbandryAnimal animal = f.record(cow); + new HusbandryCareListener().onInteract(new PlayerInteractEntityEvent(f.neighbour, cow)); + assertEquals(0, animal.care(), "Only owners earn trust"); + f.own(animal, f.player); + f.player.setSneaking(true); + new HusbandryCareListener().onInteract(new PlayerInteractEntityEvent(f.player, cow)); + assertEquals(0, animal.care(), "Sneaking opens the menu instead"); + f.player.setSneaking(false); + + new HusbandryCareListener().onInteract(new PlayerInteractEntityEvent(f.player, cow)); + + assertEquals(5, animal.care()); + assertEquals(5, f.repository.getAnimal(animal.uuid()).orElseThrow().care()); + assertNotNull(f.repository.getAnimal(animal.uuid()).orElseThrow().lastPetAt()); + assertTrue(plain(f.player.nextActionBar()).startsWith("Cow ")); + new HusbandryCareListener().onInteract(new PlayerInteractEntityEvent(f.player, cow)); + assertEquals(5, animal.care(), "A second pet the same day adds nothing"); + assertNotNull(f.player.nextActionBar(), "but still shows the hearts"); + } + + @Test void feedingAndBrushingBuildTrustOnlyForOwners() { + Cow cow = f.spawn(Cow.class); HusbandryAnimal animal = f.record(cow); f.own(animal, f.player); + animal.setHungrySince(1L); animal.setDirtySince(1L); + f.player.getInventory().setItemInMainHand(new ItemStack(Material.WHEAT, 2)); + new HusbandryCareListener().onInteract(new PlayerInteractEntityEvent(f.player, cow)); + f.player.getInventory().setItemInMainHand(new ItemStack(Material.LEATHER)); + new HusbandryCareListener().onInteract(new PlayerInteractEntityEvent(f.player, cow)); + assertEquals(22, animal.care()); + assertNull(animal.hungrySince()); assertNull(animal.dirtySince()); + + Cow other = f.spawn(Cow.class); HusbandryAnimal tended = f.record(other); f.own(tended, f.player); + tended.setHungrySince(1L); f.neighbour.setOp(true); + f.neighbour.getInventory().setItemInMainHand(new ItemStack(Material.WHEAT)); + new HusbandryCareListener().onInteract(new PlayerInteractEntityEvent(f.neighbour, other)); + assertNull(tended.hungrySince(), "Staff can still feed it"); + assertEquals(0, tended.care(), "but its trust is for its owners"); + } + + @Test void mountsArePettedByRidingNotByClicking() { + registerMountListener(); + Horse horse = f.spawn(Horse.class); HusbandryAnimal animal = f.record(horse); f.own(animal, f.player); + new HusbandryCareListener().onInteract(new PlayerInteractEntityEvent(f.player, horse)); + assertEquals(0, animal.care()); + + EntityMountEvent ride = new EntityMountEvent(f.player, horse); + f.server.getPluginManager().callEvent(ride); + assertFalse(ride.isCancelled()); + assertEquals(5, animal.care()); + assertEquals(5, f.repository.getAnimal(animal.uuid()).orElseThrow().care()); + + f.server.getPluginManager().callEvent(new EntityMountEvent(f.player, horse)); + assertEquals(5, animal.care(), "One ride a day counts"); + } + + @Test void anAlreadyCancelledRideDoesNotEarnTrustOrConsumeThePetCooldown() { + registerMountListener(); + Horse horse = f.spawn(Horse.class); HusbandryAnimal animal = f.record(horse); f.own(animal, f.player); + EntityMountEvent ride = new EntityMountEvent(f.player, horse); + ride.setCancelled(true); + + f.server.getPluginManager().callEvent(ride); + + assertNoRideReward(animal); + } + + @Test void aRideCancelledAfterOwnershipChecksDoesNotEarnTrustOrConsumeThePetCooldown() { + registerMountListener(); + f.server.getPluginManager().registerEvents(new Listener() { + @EventHandler(priority = EventPriority.HIGHEST) + public void cancelRide(EntityMountEvent event) { event.setCancelled(true); } + }, MockBukkit.createMockPlugin()); + Horse horse = f.spawn(Horse.class); HusbandryAnimal animal = f.record(horse); f.own(animal, f.player); + EntityMountEvent ride = new EntityMountEvent(f.player, horse); + + f.server.getPluginManager().callEvent(ride); + + assertTrue(ride.isCancelled()); + assertNoRideReward(animal); + } + + @Test void onlyOwnersRidingMountsEarnTrust() { + registerMountListener(); + Cow cow = f.spawn(Cow.class); HusbandryAnimal animal = f.record(cow); f.own(animal, f.player); + f.server.getPluginManager().callEvent(new EntityMountEvent(f.player, cow)); + assertNoRideReward(animal); + Horse horse = f.spawn(Horse.class); HusbandryAnimal mount = f.record(horse); + f.server.getPluginManager().callEvent(new EntityMountEvent(f.player, horse)); + assertNoRideReward(mount); + f.own(mount, f.player); + f.neighbour.setOp(true); + f.server.getPluginManager().callEvent(new EntityMountEvent(f.neighbour, horse)); + f.server.getPluginManager().callEvent(new EntityMountEvent(cow, horse)); + assertNoRideReward(mount); + } + + private void registerMountListener() { + f.server.getPluginManager().registerEvents(new HusbandryMountListener(), MockBukkit.createMockPlugin()); + } + + private void assertNoRideReward(HusbandryAnimal animal) { + assertEquals(0, animal.care()); + assertNull(animal.lastPetAt()); + HusbandryAnimal saved = f.repository.getAnimal(animal.uuid()).orElseThrow(); + assertEquals(0, saved.care()); + assertNull(saved.lastPetAt()); + } + + @Test void neglectWithNoTrustLossConfiguredKeepsTrust() throws Exception { + f.config.set("care.down.amount", 0); f.reload(); + HusbandryAnimal animal = HusbandryRuntimeCoverageTest.simulationAnimal(); animal.setCare(9); animal.setHungrySince(0L); + HusbandrySimulator.tickLoaded(animal, 100 * HOUR, new Random(1)); + assertEquals(9, animal.care()); + } + + private static String plain(net.kyori.adventure.text.Component component) { + return PlainTextComponentSerializer.plainText().serialize(component); + } +} diff --git a/src/test/java/net/tfminecraft/breedingbuddies/husbandry/HusbandryWoolTimerTest.java b/src/test/java/net/tfminecraft/breedingbuddies/husbandry/HusbandryWoolTimerTest.java new file mode 100644 index 0000000..ae4e86c --- /dev/null +++ b/src/test/java/net/tfminecraft/breedingbuddies/husbandry/HusbandryWoolTimerTest.java @@ -0,0 +1,68 @@ +package net.tfminecraft.breedingbuddies.husbandry; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertTrue; + +import java.util.List; +import java.util.UUID; + +import org.junit.jupiter.api.Test; + +class HusbandryWoolTimerTest { + + @Test + void speciesOverrideBeatsDefault() { + assertEquals(2400, HusbandryConfig.resolveWoolTimerSeconds(2400, 1200)); + assertEquals(1200, HusbandryConfig.resolveWoolTimerSeconds(0, 1200)); + assertEquals(1200, HusbandryConfig.resolveWoolTimerSeconds(-1, 1200)); + } + + @Test + void goatWoolCooldownBlocksSlaughterWool() { + HusbandryAnimal animal = new HusbandryAnimal(UUID.randomUUID(), "GOAT", "Test"); + animal.setWoolReadyAt(5_000L); + assertTrue(HusbandryDropRoller.woolBlocked(animal.type(), animal, 1_000L)); + assertEquals(0, HusbandryDropRoller.hideCount(animal, 1_000L)); + } + + @Test + void sheepBonusOnCooldownLeavesTimer() { + HusbandryAnimal animal = new HusbandryAnimal(UUID.randomUUID(), "SHEEP", "Wool"); + animal.setWoolReadyAt(5_000L); + HusbandryHarvest.ShearResult result = HusbandryHarvest.trySheepBonusShear( + null, animal, shearableSpecies(), null, 1_000L); + assertEquals(HusbandryHarvest.ShearResult.COOLDOWN, result); + assertEquals(5_000L, animal.woolReadyAt()); + } + + @Test + void sheepBonusWhenReadyStartsWoolTimer() { + HusbandryAnimal animal = new HusbandryAnimal(UUID.randomUUID(), "SHEEP", "Wool"); + animal.setWoolReadyAt(1_000L); + HusbandryHarvest.ShearResult result = HusbandryHarvest.trySheepBonusShear( + null, animal, shearableSpecies(), null, 2_000L); + assertEquals(HusbandryHarvest.ShearResult.DONE, result); + long wait = HusbandryConfig.woolTimerSeconds() * 1000L; + assertEquals(2_000L + wait, animal.woolReadyAt()); + } + + @Test + void goatShearOnCooldownIsGated() { + HusbandryAnimal animal = new HusbandryAnimal(UUID.randomUUID(), "GOAT", "Billy"); + animal.setWoolReadyAt(5_000L); + HusbandryHarvest.ShearResult result = HusbandryHarvest.tryShear( + null, null, animal, shearableSpecies(), null, 1_000L); + assertEquals(HusbandryHarvest.ShearResult.COOLDOWN, result); + assertEquals(5_000L, animal.woolReadyAt()); + } + + private static HusbandrySpecies shearableSpecies() { + HusbandryDropTable drops = new HusbandryDropTable( + List.of(new HusbandryDropEntry("v.white_wool", 1, 100)), + List.of(), + List.of(), + List.of(), + true); + return new HusbandrySpecies(null, false, "", HusbandryDropTable.empty(), drops, HusbandryDropTable.empty(), "", 0, 0, 0); + } +} diff --git a/src/test/java/net/tfminecraft/breedingbuddies/listeners/AnimalCareListenerTest.java b/src/test/java/net/tfminecraft/breedingbuddies/listeners/AnimalCareListenerTest.java deleted file mode 100644 index 4971e39..0000000 --- a/src/test/java/net/tfminecraft/breedingbuddies/listeners/AnimalCareListenerTest.java +++ /dev/null @@ -1,126 +0,0 @@ -package net.tfminecraft.breedingbuddies.listeners; - -import net.tfminecraft.breedingbuddies.FarmAnimal; -import net.tfminecraft.breedingbuddies.PluginTestBase; -import net.tfminecraft.breedingbuddies.configurables.Messages; -import org.bukkit.Material; -import org.bukkit.entity.Cow; -import org.bukkit.event.player.PlayerInteractEntityEvent; -import org.bukkit.inventory.EquipmentSlot; -import org.bukkit.inventory.ItemStack; -import org.junit.jupiter.api.BeforeEach; -import org.junit.jupiter.api.Test; -import org.mockbukkit.mockbukkit.entity.PlayerMock; - -import static org.junit.jupiter.api.Assertions.*; - -class AnimalCareListenerTest extends PluginTestBase { - private AnimalCareListener listener; - private PlayerMock alice; - private Cow cow; - - @BeforeEach - void setUpFarm() { - listener = new AnimalCareListener(); - alice = player(); - cow = spawn(Cow.class); - } - - private PlayerInteractEntityEvent use(PlayerMock player, ItemStack held, EquipmentSlot hand) { - player.getInventory().setItemInMainHand(held); - PlayerInteractEntityEvent event = new PlayerInteractEntityEvent(player, cow, hand); - listener.onInteract(event); - return event; - } - - private static ItemStack stack(String id, int amount) { - ItemStack item = mmoItem(id); - item.setAmount(amount); - return item; - } - - @Test - void feedingOnceADayBuildsFriendshipAndUsesTheFeed() { - FarmAnimal daisy = own(alice, cow); - - PlayerInteractEntityEvent event = use(alice, stack("UNIVERSAL_FEED", 3), EquipmentSlot.HAND); - - assertTrue(event.isCancelled()); - assertTrue(daisy.getFed()); - assertEquals(200, daisy.getFriendshipPoints()); - assertEquals(2, alice.getInventory().getItemInMainHand().getAmount()); - - use(alice, alice.getInventory().getItemInMainHand(), EquipmentSlot.HAND); - assertEquals("§6Daisy has already been fed.", alice.nextMessage()); - assertEquals(200, daisy.getFriendshipPoints()); - assertEquals(2, alice.getInventory().getItemInMainHand().getAmount()); - } - - @Test - void caringOnceADayBuildsFriendshipAndKeepsTheItem() { - FarmAnimal daisy = own(alice, cow); - - PlayerInteractEntityEvent event = use(alice, stack("CARING_ITEM", 1), EquipmentSlot.HAND); - - assertTrue(event.isCancelled()); - assertTrue(daisy.getCared()); - assertEquals(200, daisy.getFriendshipPoints()); - assertEquals(1, alice.getInventory().getItemInMainHand().getAmount()); - - use(alice, stack("CARING_ITEM", 1), EquipmentSlot.HAND); - assertEquals("§6Daisy has already been taken care of.", alice.nextMessage()); - assertEquals(200, daisy.getFriendshipPoints()); - } - - @Test - void careFirstCatchesUpOnMissedDays() throws Exception { - FarmAnimal daisy = own(alice, cow); - daisy.setFriendshipPoints(1000); - daisy.setFed(true); - setLastDayChange(1); - - use(alice, stack("UNIVERSAL_FEED", 1), EquipmentSlot.HAND); - awaitDayChange(); - - assertTrue(daisy.getFed(), "fed for the new day"); - assertEquals(1000 - 100 + 200, daisy.getFriendshipPoints()); - } - - @Test - void otherItemsAndTheOffHandDoNothing() { - FarmAnimal daisy = own(alice, cow); - - PlayerInteractEntityEvent wheat = use(alice, new ItemStack(Material.WHEAT), EquipmentSlot.HAND); - PlayerInteractEntityEvent offHand = use(alice, stack("UNIVERSAL_FEED", 1), EquipmentSlot.OFF_HAND); - - assertFalse(wheat.isCancelled()); - assertFalse(offHand.isCancelled()); - assertFalse(daisy.getFed()); - assertEquals(0, daisy.getFriendshipPoints()); - } - - @Test - void playersCannotHandleSomeoneElsesAnimalButStaffCan() { - FarmAnimal daisy = own(player(), cow); - PlayerMock staff = player(); - staff.addAttachment(plugin, "breedingbuddies.use", true); - - PlayerInteractEntityEvent stranger = use(alice, new ItemStack(Material.LEAD), EquipmentSlot.HAND); - PlayerInteractEntityEvent byStaff = use(staff, new ItemStack(Material.LEAD), EquipmentSlot.HAND); - - assertTrue(stranger.isCancelled()); - assertEquals(Messages.notOwner, alice.nextMessage()); - assertFalse(byStaff.isCancelled()); - assertNull(staff.nextMessage()); - assertFalse(daisy.getFed()); - } - - @Test - void wildAnimalsAreLeftToVanilla() { - PlayerInteractEntityEvent event = use(alice, stack("UNIVERSAL_FEED", 1), EquipmentSlot.HAND); - - assertFalse(event.isCancelled()); - assertNull(alice.nextMessage()); - assertEquals(1, alice.getInventory().getItemInMainHand().getAmount()); - } -} diff --git a/src/test/java/net/tfminecraft/breedingbuddies/listeners/BreedingListenerTest.java b/src/test/java/net/tfminecraft/breedingbuddies/listeners/BreedingListenerTest.java deleted file mode 100644 index e77a4a7..0000000 --- a/src/test/java/net/tfminecraft/breedingbuddies/listeners/BreedingListenerTest.java +++ /dev/null @@ -1,169 +0,0 @@ -package net.tfminecraft.breedingbuddies.listeners; - -import net.tfminecraft.breedingbuddies.AnimalStates; -import net.tfminecraft.breedingbuddies.FarmAnimal; -import net.tfminecraft.breedingbuddies.MountUtils; -import net.tfminecraft.breedingbuddies.PluginTestBase; -import net.tfminecraft.breedingbuddies.UnownedAnimalsManager; -import net.tfminecraft.breedingbuddies.configurables.Messages; -import org.bukkit.attribute.Attribute; -import org.bukkit.entity.Cow; -import org.bukkit.entity.Horse; -import org.bukkit.entity.LivingEntity; -import org.bukkit.event.entity.EntityBreedEvent; -import org.junit.jupiter.api.BeforeEach; -import org.junit.jupiter.api.Test; -import org.mockbukkit.mockbukkit.entity.PlayerMock; - -import static org.junit.jupiter.api.Assertions.*; - -class BreedingListenerTest extends PluginTestBase { - private BreedingListener listener; - private PlayerMock alice; - - @BeforeEach - void createListener() { - listener = new BreedingListener(); - alice = player(); - } - - private EntityBreedEvent breed(LivingEntity child, LivingEntity mother, LivingEntity father, LivingEntity breeder) { - EntityBreedEvent event = new EntityBreedEvent(child, mother, father, breeder, null, 1); - listener.onEntityBreed(event); - return event; - } - - private FarmAnimal ownedCow(PlayerMock owner, int genetics, int friendship) { - FarmAnimal animal = own(owner, spawn(Cow.class)); - animal.setGeneticPoints(genetics); - animal.setFriendshipPoints(friendship); - return animal; - } - - private Horse topHorse() { - Horse horse = spawn(Horse.class); - horse.getAttribute(Attribute.MAX_HEALTH).setBaseValue(30); - horse.getAttribute(Attribute.MOVEMENT_SPEED).setBaseValue(0.3375); - horse.setJumpStrength(1.0); - return horse; - } - - private LivingEntity entityOf(FarmAnimal animal) { - return (LivingEntity) server.getEntity(animal.getUuid()); - } - - @Test - void calvesInheritTheirParentsGeneticsWithAFriendshipBonus() { - FarmAnimal mother = ownedCow(alice, 1000, 4000); - FarmAnimal father = ownedCow(alice, 3000, 6000); - Cow calf = spawn(Cow.class); - - EntityBreedEvent event = breed(calf, entityOf(mother), entityOf(father), alice); - - assertFalse(event.isCancelled()); - FarmAnimal record = UnownedAnimalsManager.getAnimal(calf.getUniqueId()); - assertEquals(AnimalStates.UNOWNED, record.getState()); - // Average 2000, variance below 200 and a bonus of 5% x 0.4 of the average friendship. - assertTrue(record.getGeneticPoints() >= 2100 && record.getGeneticPoints() < 2300, "genetics " + record.getGeneticPoints()); - } - - @Test - void calvesNeverExceedTheGeneticsCap() { - FarmAnimal mother = ownedCow(alice, 10_000, 10_000); - FarmAnimal father = ownedCow(alice, 10_000, 10_000); - Cow calf = spawn(Cow.class); - - breed(calf, entityOf(mother), entityOf(father), alice); - - assertEquals(10_000, UnownedAnimalsManager.getAnimal(calf.getUniqueId()).getGeneticPoints()); - } - - @Test - void foalsInheritMountStatsOnTheNextTickAndAreTamed() { - Horse mother = topHorse(); - Horse father = topHorse(); - own(alice, mother); - own(alice, father); - Horse foal = spawn(Horse.class); - foal.getAttribute(Attribute.MAX_HEALTH).setBaseValue(15); - - breed(foal, mother, father, alice); - - assertEquals(AnimalStates.UNOWNED, UnownedAnimalsManager.getAnimal(foal.getUniqueId()).getState()); - assertFalse(foal.isTamed()); - server.getScheduler().performOneTick(); - assertTrue(foal.isTamed()); - assertEquals(30, MountUtils.getMaxHealth(foal), 1e-9); - assertEquals(0.3375, MountUtils.getSpeed(foal), 1e-9); - assertEquals(1.0, foal.getJumpStrength(), 1e-9); - } - - @Test - void onlyTheOwnerMayBreedOwnedAnimals() { - FarmAnimal mother = ownedCow(alice, 100, 100); - FarmAnimal father = ownedCow(alice, 100, 100); - Cow calf = spawn(Cow.class); - - EntityBreedEvent event = breed(calf, entityOf(mother), entityOf(father), player()); - - assertTrue(event.isCancelled()); - assertFalse(UnownedAnimalsManager.isUnowned(calf.getUniqueId())); - } - - @Test - void wildOrPartlyOwnedParentsBreedAsInVanilla() { - Cow calf = spawn(Cow.class); - FarmAnimal owned = ownedCow(alice, 100, 100); - - EntityBreedEvent wild = breed(calf, spawn(Cow.class), spawn(Cow.class), alice); - EntityBreedEvent mixed = breed(calf, entityOf(owned), spawn(Cow.class), player()); - - assertFalse(wild.isCancelled()); - assertFalse(mixed.isCancelled()); - assertFalse(UnownedAnimalsManager.isUnowned(calf.getUniqueId())); - } - - @Test - void breedingWithoutAPlayerIsIgnored() { - FarmAnimal mother = ownedCow(alice, 100, 100); - FarmAnimal father = ownedCow(alice, 100, 100); - Cow calf = spawn(Cow.class); - - EntityBreedEvent event = breed(calf, entityOf(mother), entityOf(father), null); - - assertFalse(event.isCancelled()); - assertFalse(UnownedAnimalsManager.isUnowned(calf.getUniqueId())); - } - - @Test - void neuteredHorsesCannotBreed() { - Horse mother = topHorse(); - Horse father = topHorse(); - own(alice, mother); - own(alice, father); - MountUtils.setNeuteredFlag(father, true); - mother.setLoveModeTicks(600); - father.setLoveModeTicks(600); - Horse foal = spawn(Horse.class); - - EntityBreedEvent event = breed(foal, mother, father, alice); - - assertTrue(event.isCancelled()); - assertEquals(Messages.neuteredHorse, alice.nextMessage()); - assertEquals(0, mother.getLoveModeTicks()); - assertEquals(0, father.getLoveModeTicks()); - assertFalse(UnownedAnimalsManager.isUnowned(foal.getUniqueId())); - } - - @Test - void aNeuteredFlagOnOtherAnimalsAlsoBlocksBreeding() { - FarmAnimal mother = ownedCow(alice, 100, 100); - FarmAnimal father = ownedCow(alice, 100, 100); - MountUtils.setNeuteredFlag(entityOf(mother), true); - - EntityBreedEvent event = breed(spawn(Cow.class), entityOf(mother), entityOf(father), alice); - - assertTrue(event.isCancelled()); - assertNull(alice.nextMessage()); - } -} diff --git a/src/test/java/net/tfminecraft/breedingbuddies/listeners/BundleListenersTest.java b/src/test/java/net/tfminecraft/breedingbuddies/listeners/BundleListenersTest.java deleted file mode 100644 index 0cb6a92..0000000 --- a/src/test/java/net/tfminecraft/breedingbuddies/listeners/BundleListenersTest.java +++ /dev/null @@ -1,344 +0,0 @@ -package net.tfminecraft.breedingbuddies.listeners; - -import net.tfminecraft.breedingbuddies.AnimalStates; -import net.tfminecraft.breedingbuddies.FarmAnimal; -import net.tfminecraft.breedingbuddies.ItemUtils; -import net.tfminecraft.breedingbuddies.PluginTestBase; -import net.tfminecraft.breedingbuddies.configurables.Messages; -import org.bukkit.Material; -import org.bukkit.Sound; -import org.bukkit.block.BlockFace; -import org.bukkit.configuration.file.YamlConfiguration; -import org.bukkit.entity.Cow; -import org.bukkit.entity.Entity; -import org.bukkit.entity.Horse; -import org.bukkit.entity.Pig; -import org.bukkit.event.block.Action; -import org.bukkit.event.player.PlayerInteractEntityEvent; -import org.bukkit.event.player.PlayerInteractEvent; -import org.bukkit.inventory.EquipmentSlot; -import org.bukkit.inventory.ItemStack; -import org.junit.jupiter.api.BeforeEach; -import org.junit.jupiter.api.Test; -import org.mockbukkit.mockbukkit.entity.PlayerMock; - -import java.time.Duration; -import java.util.Arrays; -import java.util.Date; -import java.util.Map; -import java.util.Objects; - -import static org.hamcrest.MatcherAssert.assertThat; -import static org.mockbukkit.mockbukkit.matcher.sound.SoundReceiverSoundHeardMatcher.hasHeard; -import static org.junit.jupiter.api.Assertions.*; - -/** Collecting bundles from animals and opening them for prizes. */ -class BundleListenersTest extends PluginTestBase { - private BundleCollector collector; - private BundleOpener opener; - private PlayerMock alice; - - @BeforeEach - void createListeners() { - collector = new BundleCollector(plugin); - opener = new BundleOpener(plugin); - alice = player(); - stubMmoItems(Map.of( - "PETS:COMMON_ANIMAL_BUNDLE", mmoItem("COMMON_ANIMAL_BUNDLE"), - "PETS:RARE_ANIMAL_BUNDLE", mmoItem("RARE_ANIMAL_BUNDLE"), - "MATERIALS:LEATHER", new ItemStack(Material.LEATHER))); - } - - private static YamlConfiguration bundles(String yaml) throws Exception { - YamlConfiguration config = new YamlConfiguration(); - config.loadFromString(yaml); - BundleCollector.setBundlesConfig(config); - BundleOpener.setBundlesConfig(config); - FarmAnimal.setBundlesConfig(config); - return config; - } - - private PlayerInteractEntityEvent collect(Entity animal, EquipmentSlot hand) { - alice.getInventory().setItemInMainHand(mmoItem("COLLECTOR_ITEM")); - PlayerInteractEntityEvent event = new PlayerInteractEntityEvent(alice, animal, hand); - collector.onInteract(event); - return event; - } - - private ItemStack[] storage() { - return Arrays.stream(alice.getInventory().getStorageContents()).skip(1).filter(Objects::nonNull).toArray(ItemStack[]::new); - } - - private ItemStack onlyCollected() { - ItemStack[] items = storage(); - assertEquals(1, items.length, Arrays.toString(items)); - return items[0]; - } - - private FarmAnimal ownedCow(int friendship, int genetics) { - FarmAnimal animal = own(alice, spawn(Cow.class)); - animal.setFriendshipPoints(friendship); - animal.setGeneticPoints(genetics); - return animal; - } - - private Entity entityOf(FarmAnimal animal) { - return server.getEntity(animal.getUuid()); - } - - @Test - void aHappyAnimalGivesABundleMarkedWithItsSpecies() { - FarmAnimal daisy = ownedCow(1000, 1000); - - PlayerInteractEntityEvent event = collect(entityOf(daisy), EquipmentSlot.HAND); - - assertTrue(event.isCancelled()); - ItemStack bundle = onlyCollected(); - assertEquals("COMMON_ANIMAL_BUNDLE", ItemUtils.getItemId(bundle)); - assertEquals("COW", ItemUtils.getDataFromItem(bundle, plugin)); - assertNotNull(daisy.getLastRewardCollected()); - assertThat(alice, hasHeard("block.amethyst_block.step")); - assertFalse(daisy.canCollectReward(), "the cooldown has started"); - } - - @Test - void wellBalancedHighStatsEarnTheRareTier() { - FarmAnimal star = ownedCow(8000, 8000); - FarmAnimal lopsided = ownedCow(8000, 2000); - - assertEquals("rare", collector.calculateTier(entityOf(star))); - assertEquals("common", collector.calculateTier(entityOf(lopsided)), "the gap between the stats counts against it"); - - collect(entityOf(star), EquipmentSlot.HAND); - assertEquals("RARE_ANIMAL_BUNDLE", ItemUtils.getItemId(onlyCollected())); - } - - @Test - void unhappyOrRestingAnimalsGiveNothing() { - FarmAnimal sad = ownedCow(1000, 1000); - sad.setState(AnimalStates.SAD); - FarmAnimal resting = ownedCow(1000, 1000); - resting.setLastRewardCollected(new Date(System.currentTimeMillis() - Duration.ofHours(1).toMillis())); - - PlayerInteractEntityEvent event = collect(entityOf(sad), EquipmentSlot.HAND); - collect(entityOf(resting), EquipmentSlot.HAND); - - assertTrue(event.isCancelled()); - assertEquals(0, storage().length); - assertThat(alice, hasHeard("entity.cow.ambient")); - } - - @Test - void aFullInventoryKeepsTheBundleWaiting() { - FarmAnimal daisy = ownedCow(1000, 1000); - for (int slot = 1; slot < 36; slot++) { - alice.getInventory().setItem(slot, new ItemStack(Material.STONE)); - } - - collect(entityOf(daisy), EquipmentSlot.HAND); - - assertEquals(Messages.lacksSpace, alice.nextMessage()); - assertNull(daisy.getLastRewardCollected()); - assertTrue(daisy.canCollectReward()); - } - - @Test - void collectingNeedsTheOwnerTheItemTheMainHandAndASpeciesWithBundles() { - FarmAnimal daisy = ownedCow(1000, 1000); - Horse horse = spawn(Horse.class); - own(alice, horse); - Pig pig = spawn(Pig.class); - own(alice, pig); - Cow theirs = spawn(Cow.class); - own(player(), theirs); - - PlayerInteractEntityEvent offHand = collect(entityOf(daisy), EquipmentSlot.OFF_HAND); - PlayerInteractEntityEvent onHorse = collect(horse, EquipmentSlot.HAND); - PlayerInteractEntityEvent onPig = collect(pig, EquipmentSlot.HAND); - PlayerInteractEntityEvent onTheirs = collect(theirs, EquipmentSlot.HAND); - alice.getInventory().setItemInMainHand(new ItemStack(Material.HOPPER)); - PlayerInteractEntityEvent wrongItem = new PlayerInteractEntityEvent(alice, entityOf(daisy), EquipmentSlot.HAND); - collector.onInteract(wrongItem); - - for (PlayerInteractEntityEvent event : new PlayerInteractEntityEvent[] {offHand, onHorse, onPig, onTheirs, wrongItem}) { - assertFalse(event.isCancelled()); - } - assertEquals(0, storage().length); - assertNull(daisy.getLastRewardCollected()); - } - - @Test - void mountsWithBundlesKeepTheirVanillaMenuClosedWhileCollecting() throws Exception { - bundles(""" - HORSE: - bundlesEnabled: true - bundles: - only: - bundleItem: {type: vanilla, itemID: saddle} - """); - Horse horse = spawn(Horse.class); - own(alice, horse); - - PlayerInteractEntityEvent event = collect(horse, EquipmentSlot.HAND); - - assertTrue(event.isCancelled()); - ItemStack bundle = onlyCollected(); - assertEquals(Material.SADDLE, bundle.getType()); - assertEquals("HORSE", ItemUtils.getDataFromItem(bundle, plugin)); - } - - @Test - void misconfiguredBundleItemsFallBackToAPlaceholder() throws Exception { - bundles(""" - COW: - bundlesEnabled: true - bundles: - only: - bundleItem: {type: vanilla, itemID: not_an_item} - PIG: - bundlesEnabled: true - bundles: - only: - bundleItem: {type: crate} - SHEEP: - bundlesEnabled: true - bundles: - only: - bundleItem: {type: mmoitem, mmoitemType: PETS, mmoitemID: MISSING} - """); - FarmAnimal daisy = ownedCow(0, 0); - Pig pig = spawn(Pig.class); - own(alice, pig); - org.bukkit.entity.Sheep sheep = spawn(org.bukkit.entity.Sheep.class); - own(alice, sheep); - - collect(entityOf(daisy), EquipmentSlot.HAND); - collect(pig, EquipmentSlot.HAND); - collect(sheep, EquipmentSlot.HAND); - - ItemStack[] items = storage(); - assertEquals(3, Arrays.stream(items).mapToInt(ItemStack::getAmount).sum()); - for (ItemStack item : items) { - assertEquals(Material.DIRT, item.getType()); - } - } - - // Opening - - private PlayerInteractEvent open(ItemStack bundle, Action action) { - PlayerInteractEvent event = new PlayerInteractEvent(alice, action, bundle, null, BlockFace.SELF, EquipmentSlot.HAND); - opener.onRightClick(event); - return event; - } - - private ItemStack bundleFor(String species, ItemStack item) { - ItemUtils.storeDataInItem(item, species, plugin); - return item; - } - - private void prizeBundles() throws Exception { - bundles(""" - COW: - bundlesEnabled: true - bundles: - small: - bundleItem: {type: vanilla, itemID: bundle} - prizes: - beef: {type: vanilla, itemID: beef, amount: 3, weight: 1} - nothing: {type: vanilla, itemID: diamond, amount: 64, weight: 0} - big: - bundleItem: {type: mmoitem, mmoitemType: PETS, mmoitemID: RARE_ANIMAL_BUNDLE} - prizes: - leather: {type: mmoitem, mmoitemType: MATERIALS, mmoitemID: LEATHER, amount: 2, weight: 5} - broken: - bundleItem: {type: vanilla, itemID: paper} - prizes: - typo: {type: vanilla, itemID: not_an_item, amount: 1, weight: 1} - missing: - bundleItem: {type: vanilla, itemID: stick} - prizes: - gone: {type: mmoitem, mmoitemType: MATERIALS, mmoitemID: GONE, amount: 1, weight: 1} - odd: - bundleItem: {type: crate} - prizes: - crate: {type: crate, amount: 1, weight: 1} - HORSE: - bundlesEnabled: false - """); - } - - @Test - void openingABundleSpendsItAndGivesItsPrize() throws Exception { - prizeBundles(); - ItemStack bundle = bundleFor("COW", new ItemStack(Material.BUNDLE, 2)); - - for (int i = 0; i < 2; i++) { - open(bundle, i == 0 ? Action.RIGHT_CLICK_AIR : Action.RIGHT_CLICK_BLOCK); - } - - assertEquals(0, bundle.getAmount()); - assertTrue(alice.getInventory().contains(Material.BEEF, 6), "weightless prizes are never drawn"); - assertFalse(alice.getInventory().contains(Material.DIAMOND)); - assertThat(alice, hasHeard(Sound.ENTITY_EXPERIENCE_ORB_PICKUP)); - } - - @Test - void mmoItemBundlesGiveMmoItemPrizes() throws Exception { - prizeBundles(); - ItemStack bundle = bundleFor("COW", mmoItem("RARE_ANIMAL_BUNDLE")); - - open(bundle, Action.RIGHT_CLICK_AIR); - - assertEquals(0, bundle.getAmount()); - assertTrue(alice.getInventory().contains(Material.LEATHER, 2)); - } - - @Test - void prizesThatCannotBeMadeBecomeAPlaceholder() throws Exception { - prizeBundles(); - - open(bundleFor("COW", new ItemStack(Material.PAPER)), Action.RIGHT_CLICK_AIR); - open(bundleFor("COW", new ItemStack(Material.STICK)), Action.RIGHT_CLICK_AIR); - - assertTrue(alice.getInventory().contains(Material.DIRT, 2)); - } - - @Test - void onlyMarkedBundleItemsOfAConfiguredTierOpen() throws Exception { - prizeBundles(); - ItemStack unmarked = new ItemStack(Material.BUNDLE); - ItemStack wrongItem = bundleFor("COW", new ItemStack(Material.APPLE)); - ItemStack noBundles = bundleFor("HORSE", new ItemStack(Material.BUNDLE)); - ItemStack renamedToken = bundleFor("Daisy", mmoItem("TAMING_ITEM")); - ItemStack leftClicked = bundleFor("COW", new ItemStack(Material.BUNDLE)); - - open(unmarked, Action.RIGHT_CLICK_AIR); - open(wrongItem, Action.RIGHT_CLICK_AIR); - open(noBundles, Action.RIGHT_CLICK_AIR); - open(renamedToken, Action.RIGHT_CLICK_AIR); - open(leftClicked, Action.LEFT_CLICK_AIR); - open(null, Action.RIGHT_CLICK_AIR); - - for (ItemStack item : new ItemStack[] {unmarked, wrongItem, noBundles, renamedToken, leftClicked}) { - assertEquals(1, item.getAmount()); - } - assertTrue(alice.getInventory().isEmpty()); - } - - @Test - void collectedBundlesFromTheDefaultConfigCanBeOpened() { - FarmAnimal daisy = ownedCow(1000, 1000); - collect(entityOf(daisy), EquipmentSlot.HAND); - ItemStack bundle = onlyCollected(); - bundle.setAmount(1); - ItemStack held = bundle.clone(); - alice.getInventory().clear(); - - open(held, Action.RIGHT_CLICK_AIR); - - assertEquals(0, held.getAmount()); - boolean beef = alice.getInventory().contains(Material.BEEF, 4); - boolean leather = alice.getInventory().contains(Material.LEATHER, 4); - assertTrue(beef ^ leather, "one common prize: beef or leather"); - } -} diff --git a/src/test/java/net/tfminecraft/breedingbuddies/listeners/DayChangeListenerTest.java b/src/test/java/net/tfminecraft/breedingbuddies/listeners/DayChangeListenerTest.java deleted file mode 100644 index 1e0d625..0000000 --- a/src/test/java/net/tfminecraft/breedingbuddies/listeners/DayChangeListenerTest.java +++ /dev/null @@ -1,382 +0,0 @@ -package net.tfminecraft.breedingbuddies.listeners; - -import net.tfminecraft.breedingbuddies.AnimalStates; -import net.tfminecraft.breedingbuddies.ChunkArea; -import net.tfminecraft.breedingbuddies.FarmAnimal; -import net.tfminecraft.breedingbuddies.OwnershipManager; -import net.tfminecraft.breedingbuddies.PluginData; -import net.tfminecraft.breedingbuddies.PluginTestBase; -import net.tfminecraft.breedingbuddies.UnownedAnimalsManager; -import net.tfminecraft.breedingbuddies.configurables.Numbers; -import net.tfminecraft.breedingbuddies.events.DayChangeEvent; -import org.bukkit.Bukkit; -import org.bukkit.Chunk; -import org.bukkit.configuration.file.YamlConfiguration; -import org.bukkit.entity.Cow; -import org.bukkit.entity.Entity; -import org.bukkit.entity.Horse; -import org.junit.jupiter.api.AfterEach; -import org.junit.jupiter.api.BeforeEach; -import org.junit.jupiter.api.Test; -import org.mockbukkit.mockbukkit.entity.PlayerMock; - -import java.io.ByteArrayOutputStream; -import java.io.PrintStream; -import java.nio.file.Files; -import java.time.Instant; -import java.time.LocalDateTime; -import java.util.ArrayList; -import java.util.List; -import java.util.UUID; - -import static org.junit.jupiter.api.Assertions.*; -import static org.mockito.Mockito.*; - -class DayChangeListenerTest extends PluginTestBase { - private final ByteArrayOutputStream console = new ByteArrayOutputStream(); - private PrintStream originalOut; - private PlayerMock alice; - - @BeforeEach - void captureConsole() { - originalOut = System.out; - System.setOut(new PrintStream(console, true)); - alice = player(); - } - - @AfterEach - void restoreConsole() { - System.setOut(originalOut); - } - - private void dayChange() throws InterruptedException { - server.getPluginManager().callEvent(new DayChangeEvent()); - awaitDayChange(); - } - - private FarmAnimal ownedCow(boolean fedAndCared) { - FarmAnimal animal = own(alice, spawn(Cow.class)); - animal.setFriendshipPoints(500); - animal.setFed(fedAndCared); - animal.setCared(fedAndCared); - return animal; - } - - private Entity entityOf(FarmAnimal animal) { - return server.getEntity(animal.getUuid()); - } - - private long storedLastDayChange() { - return YamlConfiguration.loadConfiguration(plugin.getNumbersFile()).getLong("lastDayChangeDate"); - } - - @Test - void animalsSleepingInAWateredRoomyStableStayHappy() throws Exception { - FarmAnimal daisy = ownedCow(true); - daisy.setDaysOut(2); - stable(fakeChunk(0, 0, true, entityOf(daisy))); - long before = storedLastDayChange() - 3600; - YamlConfiguration numbers = YamlConfiguration.loadConfiguration(plugin.getNumbersFile()); - numbers.set("lastDayChangeDate", before); - DayChangeListener.setNumbersConfig(numbers); - - dayChange(); - - assertEquals(AnimalStates.HAPPY, daisy.getState()); - assertFalse(daisy.getFed()); - assertFalse(daisy.getCared()); - assertEquals(400, daisy.getFriendshipPoints(), "a day costs the configured care and feeding loss"); - assertTrue(daisy.isSleptInStable()); - assertFalse(daisy.isWaterNeeded()); - assertFalse(daisy.isSpaceNeeded()); - assertEquals(0, daisy.getDaysOut()); - assertTrue(Files.exists(playerFile(alice.getUniqueId())), "the day change saves data"); - assertTrue(storedLastDayChange() > before); - assertFalse(DayChangeListener.isIsChangingDay()); - } - - @Test - void theDayChangeRunsOnTheServerThreadOnTheNextTick() { - FarmAnimal daisy = ownedCow(true); - Chunk home = fakeChunk(0, 0, true); - List readOnServerThread = new ArrayList<>(); - when(home.getEntities()).thenAnswer(call -> { - readOnServerThread.add(Bukkit.isPrimaryThread()); - return new Entity[] {entityOf(daisy)}; - }); - stable(home); - - server.getPluginManager().callEvent(new DayChangeEvent()); - - assertTrue(DayChangeListener.isIsChangingDay()); - assertTrue(daisy.getFed(), "nothing changes before the task runs"); - - awaitDayChange(); - - assertFalse(daisy.getFed()); - assertFalse(readOnServerThread.isEmpty()); - assertFalse(readOnServerThread.contains(false), "chunk entities are read on the server thread"); - } - - @Test - void stablesWithoutWaterOrRoomLeaveAnimalsSad() throws Exception { - FarmAnimal thirsty = ownedCow(true); - stable(fakeChunk(0, 0, false, entityOf(thirsty))); - Numbers.maxAnimalsPerChunk = 0; - FarmAnimal crowded = ownedCow(true); - stable(fakeChunk(5, 5, true, entityOf(crowded))); - - dayChange(); - - assertEquals(AnimalStates.SAD, thirsty.getState()); - assertTrue(thirsty.isWaterNeeded()); - assertEquals(AnimalStates.SAD, crowded.getState()); - assertTrue(crowded.isSpaceNeeded()); - } - - @Test - void animalsOutsideAnyStableCountANightOutButMountsDoNot() throws Exception { - FarmAnimal stray = ownedCow(true); - FarmAnimal mount = own(alice, spawn(Horse.class)); - mount.setFed(true); - mount.setCared(true); - - dayChange(); - - assertEquals(AnimalStates.SAD, stray.getState()); - assertFalse(stray.isSleptInStable()); - assertEquals(1, stray.getDaysOut()); - assertEquals(AnimalStates.HAPPY, mount.getState()); - assertTrue(mount.isSleptInStable()); - assertEquals(0, mount.getDaysOut()); - } - - @Test - void animalsLeftOutTooLongEscapeTheirOwners() throws Exception { - sessions.put(alice.getUniqueId(), new net.tfminecraft.tlibs.character.CharacterSession( - new net.tfminecraft.tlibs.character.CharacterRef(alice.getUniqueId(), UUID.randomUUID()), 2L)); - FarmAnimal stray = ownedCow(false); - stray.setDaysOut(Numbers.maxIrlDaysOut); - - dayChange(); - - assertEquals(AnimalStates.ESCAPED, stray.getState()); - assertFalse(OwnershipManager.hasOwner(stray.getUuid())); - assertTrue(OwnershipManager.getAnimals(alice.getUniqueId()).isEmpty()); - assertSame(stray, UnownedAnimalsManager.getAnimal(stray.getUuid())); - PluginData.loadAnimals(); - assertEquals(AnimalStates.ESCAPED, UnownedAnimalsManager.getAnimal(stray.getUuid()).getState()); - assertFalse(OwnershipManager.hasOwner(stray.getUuid())); - } - - @Test - void stablesWithoutOwnedAnimalsCountAsAbandoned() throws Exception { - Cow wild = spawn(Cow.class); - ChunkArea empty = stable(fakeChunk(0, 0, true, wild)); - FarmAnimal daisy = ownedCow(true); - ChunkArea used = stable(fakeChunk(5, 5, true, entityOf(daisy))); - - dayChange(); - - assertEquals(1, empty.getDaysAbandoned()); - assertEquals(0, used.getDaysAbandoned()); - } - - @Test - void stablesAbandonedTooLongAreReleased() throws Exception { - ChunkArea expired = stable(fakeChunk(0, 0, true)); - expired.setDaysAbandoned(Numbers.maxIrlDaysChunkAbandoned + 1); - ChunkArea kept = stable(fakeChunk(5, 5, true)); - kept.setDaysAbandoned(Numbers.maxIrlDaysChunkAbandoned); - // Another day change already running means no second pass is started. - setStatic(DayChangeListener.class, "isChangingDay", true); - - server.getPluginManager().callEvent(new DayChangeEvent()); - setStatic(DayChangeListener.class, "isChangingDay", false); - - assertEquals(java.util.List.of(kept), PluginData.getChunkAreas()); - assertEquals(Numbers.maxIrlDaysChunkAbandoned, kept.getDaysAbandoned()); - } - - @Test - void aChunkThatFailsMidwayIsLoggedAndOthersStillChange() throws Exception { - FarmAnimal daisy = ownedCow(true); - Chunk broken = fakeChunk(4, 5, true); - when(broken.getEntities()).thenReturn(new Entity[0]).thenThrow(new IllegalStateException("chunk unloaded")); - stable(broken); - stable(fakeChunk(9, 9, true, entityOf(daisy))); - - try (LogCapture log = new LogCapture(plugin.getLogger())) { - dayChange(); - assertTrue(log.contains("Error processing chunk 4,5: chunk unloaded")); - } - - assertFalse(broken.isForceLoaded(), "the failed chunk is released"); - assertEquals(AnimalStates.HAPPY, daisy.getState()); - assertTrue(daisy.isSleptInStable()); - } - - @Test - void anAnimalThatFailsIsLoggedAndTheRestOfTheChunkStillChanges() throws Exception { - FarmAnimal broken = spy(new FarmAnimal(spawn(Cow.class).getUniqueId(), "Broken", alice.getUniqueId(), null)); - doThrow(new IllegalStateException("bad record")).doCallRealMethod() - .when(broken).changeDay(anyBoolean(), anyBoolean(), anyBoolean(), anyInt()); - OwnershipManager.registerOwnership(alice.getUniqueId(), broken); - FarmAnimal daisy = ownedCow(true); - stable(fakeChunk(0, 0, true, entityOf(broken), entityOf(daisy))); - - try (LogCapture log = new LogCapture(plugin.getLogger())) { - dayChange(); - assertTrue(log.contains("Error processing animal " + broken.getUuid() + ": bad record")); - } - - assertEquals(AnimalStates.HAPPY, daisy.getState()); - assertTrue(daisy.isSleptInStable()); - assertFalse(console.toString().contains("Error on DayChange"), "the rest of the day change completed"); - } - - @Test - void aFailedDayChangeIsReportedAndDoesNotBlockTheNextOne() throws Exception { - FarmAnimal daisy = ownedCow(true); - Chunk broken = fakeChunk(0, 0, true); - when(broken.isForceLoaded()).thenThrow(new IllegalStateException("world closed")); - ChunkArea brokenArea = stable(broken); - - dayChange(); - - assertTrue(console.toString().contains("Error on DayChange: java.lang.IllegalStateException: world closed")); - assertFalse(DayChangeListener.isIsChangingDay()); - assertTrue(daisy.getFed(), "nothing changed"); - - PluginData.getChunkAreas().remove(brokenArea); - dayChange(); - assertFalse(daisy.getFed()); - } - - @Test - void aMissedDayIsAppliedWhenAnOwnerInteracts() throws Exception { - FarmAnimal daisy = ownedCow(true); - Chunk home = entityOf(daisy).getLocation().getChunk(); - stable(home, fakeChunk(1, 0, true)); - FarmAnimal other = ownedCow(false); - setLastDayChange(2); - - DayChangeListener.changeDayOnInteract(entityOf(daisy), daisy); - awaitDayChange(); - - assertEquals(AnimalStates.HAPPY, daisy.getState()); - assertEquals(300, daisy.getFriendshipPoints(), "two missed days cost twice the daily loss, once"); - assertEquals(0, daisy.getDaysOut(), "it slept in its stable"); - assertEquals(AnimalStates.SAD, other.getState()); - assertEquals(300, other.getFriendshipPoints()); - assertTrue(Math.abs(Instant.now().getEpochSecond() - storedLastDayChange()) < 60); - } - - @Test - void interactionsOnTheSameDayChangeNothing() throws Exception { - FarmAnimal daisy = ownedCow(true); - setLastDayChange(0); - - DayChangeListener.changeDayOnInteract(entityOf(daisy), daisy); - awaitDayChange(); - - assertTrue(daisy.getFed()); - assertEquals(500, daisy.getFriendshipPoints()); - } - - @Test - void anInteractedAnimalAwayFromItsStableHadANightOut() throws Exception { - FarmAnimal daisy = ownedCow(true); - setLastDayChange(1); - - DayChangeListener.changeDayOnInteract(entityOf(daisy), daisy); - awaitDayChange(); - - assertEquals(AnimalStates.SAD, daisy.getState()); - assertFalse(daisy.isSleptInStable()); - assertEquals(1, daisy.getDaysOut()); - assertEquals(400, daisy.getFriendshipPoints()); - } - - @Test - void anInteractedMountNeedsNoStable() throws Exception { - FarmAnimal mount = own(alice, spawn(Horse.class)); - mount.setFed(true); - mount.setCared(true); - setLastDayChange(1); - - DayChangeListener.changeDayOnInteract(entityOf(mount), mount); - - assertEquals(AnimalStates.HAPPY, mount.getState(), "applied before the background pass"); - assertTrue(mount.isSleptInStable()); - awaitDayChange(); - assertEquals(0, mount.getDaysOut()); - } - - @Test - void aFarmReportCatchesUpOnMissedDaysStraightAway() throws Exception { - FarmAnimal daisy = ownedCow(true); - setLastDayChange(3); - - DayChangeListener.changeDayOnReport(); - - assertFalse(daisy.getFed()); - assertEquals(200, daisy.getFriendshipPoints()); - assertTrue(Files.exists(playerFile(alice.getUniqueId()))); - assertFalse(DayChangeListener.isIsChangingDay()); - assertTrue(Math.abs(Instant.now().getEpochSecond() - storedLastDayChange()) < 60); - } - - @Test - void aFarmReportOnTheSameDayOrDuringAChangeDoesNothing() throws Exception { - FarmAnimal daisy = ownedCow(true); - setLastDayChange(0); - - DayChangeListener.changeDayOnReport(); - assertTrue(daisy.getFed()); - assertFalse(DayChangeListener.isIsChangingDay()); - - setLastDayChange(2); - setStatic(DayChangeListener.class, "isChangingDay", true); - DayChangeListener.changeDayOnReport(); - assertTrue(daisy.getFed()); - assertTrue(DayChangeListener.isIsChangingDay()); - setStatic(DayChangeListener.class, "isChangingDay", false); - } - - @Test - void aFailedFarmReportCatchUpIsReportedAndReleased() throws Exception { - FarmAnimal daisy = ownedCow(true); - Chunk broken = fakeChunk(0, 0, true); - when(broken.isForceLoaded()).thenThrow(new IllegalStateException("world closed")); - stable(broken); - setLastDayChange(1); - - DayChangeListener.changeDayOnReport(); - - assertTrue(console.toString().contains("Error on DayChange: java.lang.IllegalStateException: world closed")); - assertFalse(DayChangeListener.isIsChangingDay()); - assertTrue(daisy.getFed()); - } - - @Test - void anUnsavableConfigStillCompletesTheDayChange() throws Exception { - FarmAnimal daisy = ownedCow(true); - setLastDayChange(1); - Files.delete(plugin.getNumbersFile().toPath()); - Files.createDirectory(plugin.getNumbersFile().toPath()); - - DayChangeListener.changeDayOnReport(); - - assertFalse(daisy.getFed()); - assertTrue(console.toString().contains("Error saving lastDayChangeDate on config: ")); - } - - @Test - void wholeDaysAreCountedBetweenDates() throws Exception { - LocalDateTime start = LocalDateTime.of(2026, 10, 1, 21, 0); - - assertEquals(0, DayChangeListener.daysBetween(start, start.plusHours(23))); - assertEquals(2, DayChangeListener.daysBetween(start, start.plusDays(2).plusHours(5))); - } -} diff --git a/src/test/java/net/tfminecraft/breedingbuddies/listeners/FarmToolsListenerTest.java b/src/test/java/net/tfminecraft/breedingbuddies/listeners/FarmToolsListenerTest.java deleted file mode 100644 index e8d2910..0000000 --- a/src/test/java/net/tfminecraft/breedingbuddies/listeners/FarmToolsListenerTest.java +++ /dev/null @@ -1,216 +0,0 @@ -package net.tfminecraft.breedingbuddies.listeners; - -import net.tfminecraft.breedingbuddies.ChunkManager; -import net.tfminecraft.breedingbuddies.FarmAnimal; -import net.tfminecraft.breedingbuddies.ItemUtils; -import net.tfminecraft.breedingbuddies.OwnershipManager; -import net.tfminecraft.breedingbuddies.PluginData; -import net.tfminecraft.breedingbuddies.PluginTestBase; -import net.tfminecraft.breedingbuddies.UnownedAnimalsManager; -import net.tfminecraft.breedingbuddies.configurables.Messages; -import org.bukkit.Material; -import org.bukkit.Sound; -import org.bukkit.block.BlockFace; -import org.bukkit.entity.Cow; -import org.bukkit.entity.LivingEntity; -import org.bukkit.entity.Zombie; -import org.bukkit.event.Event; -import org.bukkit.event.block.Action; -import org.bukkit.event.entity.EntityDeathEvent; -import org.bukkit.event.inventory.PrepareAnvilEvent; -import org.bukkit.event.player.PlayerInteractEvent; -import org.bukkit.inventory.AnvilInventory; -import org.bukkit.inventory.EquipmentSlot; -import org.bukkit.inventory.ItemStack; -import org.junit.jupiter.api.BeforeEach; -import org.junit.jupiter.api.Test; -import org.mockbukkit.mockbukkit.entity.PlayerMock; - -import static org.hamcrest.MatcherAssert.assertThat; -import static org.mockbukkit.mockbukkit.matcher.sound.SoundReceiverSoundHeardMatcher.hasHeard; -import static org.junit.jupiter.api.Assertions.*; -import static org.mockito.Mockito.mock; -import static org.mockito.Mockito.when; - -/** Farm report, stable chunk claiming, anvil renaming and animal deaths. */ -class FarmToolsListenerTest extends PluginTestBase { - private PlayerMock alice; - - @BeforeEach - void addPlayer() { - alice = player(); - } - - private PlayerInteractEvent click(Action action, ItemStack item) { - return new PlayerInteractEvent(alice, action, item, null, BlockFace.SELF, EquipmentSlot.HAND); - } - - /** With no clicked block isCancelled() is always true, so check the item use instead. */ - private static boolean itemUseDenied(PlayerInteractEvent event) { - return event.useItemInHand() == Event.Result.DENY; - } - - @Test - void theFarmReportSummarisesTheOwnersAnimals() { - FarmAnimal fed = own(alice, spawn(Cow.class)); - fed.setFed(true); - fed.setCared(true); - FarmAnimal outside = own(alice, spawn(Cow.class)); - outside.setSleptInStable(false); - outside.setSpaceNeeded(true); - outside.setWaterNeeded(true); - outside.setFed(true); - - new FarmReport().onRightClick(click(Action.RIGHT_CLICK_AIR, mmoItem("FARMREPORT_ITEM"))); - - assertEquals("§6§nFarm Report:", alice.nextMessage()); - assertEquals("§e-§oAnimals fed:§r 2.", alice.nextMessage()); - assertEquals("§e-§oAnimals taken care of:§r 1.", alice.nextMessage()); - assertEquals("§e-§oTotal animals owned:§r 2.", alice.nextMessage()); - assertEquals(Messages.animalsOut, alice.nextMessage()); - assertEquals(Messages.spaceNeeded, alice.nextMessage()); - assertEquals(Messages.waterNeeded, alice.nextMessage()); - assertNull(alice.nextMessage()); - assertThat(alice, hasHeard(Sound.ITEM_BOOK_PAGE_TURN)); - } - - @Test - void aHealthyFarmReportsNoWarnings() { - own(alice, spawn(Cow.class)); - - new FarmReport().onRightClick(click(Action.RIGHT_CLICK_BLOCK, mmoItem("FARMREPORT_ITEM"))); - - assertEquals("§6§nFarm Report:", alice.nextMessage()); - assertEquals("§e-§oAnimals fed:§r 0.", alice.nextMessage()); - assertEquals("§e-§oAnimals taken care of:§r 0.", alice.nextMessage()); - assertEquals("§e-§oTotal animals owned:§r 1.", alice.nextMessage()); - assertNull(alice.nextMessage()); - } - - @Test - void theFarmReportFirstCatchesUpOnMissedDays() { - FarmAnimal daisy = own(alice, spawn(Cow.class)); - daisy.setFed(true); - setLastDayChange(1); - - new FarmReport().onRightClick(click(Action.RIGHT_CLICK_AIR, mmoItem("FARMREPORT_ITEM"))); - - alice.nextMessage(); - assertEquals("§e-§oAnimals fed:§r 0.", alice.nextMessage()); - } - - @Test - void otherItemsAndClicksDoNotProduceAReport() { - FarmReport report = new FarmReport(); - - report.onRightClick(click(Action.LEFT_CLICK_AIR, mmoItem("FARMREPORT_ITEM"))); - report.onRightClick(click(Action.RIGHT_CLICK_AIR, null)); - report.onRightClick(click(Action.RIGHT_CLICK_AIR, new ItemStack(Material.BOOK))); - - assertNull(alice.nextMessage()); - } - - @Test - void theStableItemClaimsTheChunkUnderThePlayerOnce() { - ChunkCreation listener = new ChunkCreation(); - ItemStack fences = mmoItem("STABLECHUNK_ITEM"); - fences.setAmount(2); - - PlayerInteractEvent first = click(Action.RIGHT_CLICK_BLOCK, fences); - listener.onRightClick(first); - - assertTrue(itemUseDenied(first)); - assertTrue(ChunkManager.isStableChunk(alice.getLocation().getChunk())); - assertEquals(1, fences.getAmount()); - assertEquals(Messages.stableChunk, alice.nextMessage()); - assertThat(alice, hasHeard(Sound.BLOCK_HANGING_SIGN_PLACE)); - - PlayerInteractEvent again = click(Action.RIGHT_CLICK_AIR, fences); - listener.onRightClick(again); - - assertTrue(itemUseDenied(again)); - assertEquals(1, fences.getAmount()); - assertEquals(Messages.alreadyStableChunk, alice.nextMessage()); - assertEquals(1, PluginData.getChunkAreas().size()); - } - - @Test - void otherItemsAndClicksClaimNothing() { - ChunkCreation listener = new ChunkCreation(); - PlayerInteractEvent leftClick = click(Action.LEFT_CLICK_BLOCK, mmoItem("STABLECHUNK_ITEM")); - PlayerInteractEvent fence = click(Action.RIGHT_CLICK_BLOCK, new ItemStack(Material.OAK_FENCE)); - PlayerInteractEvent empty = click(Action.RIGHT_CLICK_BLOCK, null); - - listener.onRightClick(leftClick); - listener.onRightClick(fence); - listener.onRightClick(empty); - - assertFalse(itemUseDenied(fence)); - assertTrue(PluginData.getChunkAreas().isEmpty()); - } - - private PrepareAnvilEvent anvil(ItemStack result, String renameText) { - PrepareAnvilEvent event = mock(PrepareAnvilEvent.class); - AnvilInventory inventory = mock(AnvilInventory.class); - when(event.getResult()).thenReturn(result); - when(event.getInventory()).thenReturn(inventory); - when(inventory.getRenameText()).thenReturn(renameText); - return event; - } - - @Test - void renamingATamingTokenInAnAnvilRecordsTheName() { - AnvilRenameListener listener = new AnvilRenameListener(plugin); - ItemStack token = mmoItem("TAMING_ITEM"); - ItemStack paper = new ItemStack(Material.PAPER); - - listener.onAnvilRename(anvil(token, "Daisy")); - listener.onAnvilRename(anvil(paper, "Daisy")); - listener.onAnvilRename(anvil(null, "Daisy")); - - assertEquals("Daisy", ItemUtils.getDataFromItem(token, plugin)); - assertNull(ItemUtils.getDataFromItem(paper, plugin)); - - ItemStack unnamed = mmoItem("TAMING_ITEM"); - listener.onAnvilRename(anvil(unnamed, null)); - assertNull(ItemUtils.getDataFromItem(unnamed, plugin)); - } - - @Test - void deadAnimalsAreForgotten() { - Cow owned = spawn(Cow.class); - own(alice, owned); - Cow unowned = spawn(Cow.class); - UnownedAnimalsManager.addUnownedAnimal(new FarmAnimal(unowned.getUniqueId(), unowned)); - AnimalDeathListener listener = new AnimalDeathListener(); - - listener.onAnimalDeath(death(owned)); - listener.onAnimalDeath(death(unowned)); - - assertFalse(OwnershipManager.hasOwner(owned.getUniqueId())); - assertTrue(OwnershipManager.getAnimals(alice.getUniqueId()).isEmpty()); - assertFalse(UnownedAnimalsManager.isUnowned(unowned.getUniqueId())); - } - - @Test - void otherDeathsAreIgnored() { - Zombie zombie = spawn(Zombie.class); - Cow cow = spawn(Cow.class); - FarmAnimal daisy = own(alice, cow); - // A record sharing the zombie's ID shows the zombie's death is not looked up. - FarmAnimal odd = new FarmAnimal(zombie.getUniqueId(), "Odd", new java.util.HashSet<>()); - odd.setState(net.tfminecraft.breedingbuddies.AnimalStates.HAPPY); - OwnershipManager.registerOwnership(alice.getUniqueId(), odd); - - new AnimalDeathListener().onAnimalDeath(death(zombie)); - - assertEquals(2, OwnershipManager.getAnimals(alice.getUniqueId()).size()); - assertTrue(OwnershipManager.getAnimals(alice.getUniqueId()).contains(daisy)); - } - - private static EntityDeathEvent death(LivingEntity entity) { - EntityDeathEvent event = mock(EntityDeathEvent.class); - when(event.getEntity()).thenReturn(entity); - return event; - } -} diff --git a/src/test/java/net/tfminecraft/breedingbuddies/listeners/InventoryManagerTest.java b/src/test/java/net/tfminecraft/breedingbuddies/listeners/InventoryManagerTest.java deleted file mode 100644 index 58bb594..0000000 --- a/src/test/java/net/tfminecraft/breedingbuddies/listeners/InventoryManagerTest.java +++ /dev/null @@ -1,316 +0,0 @@ -package net.tfminecraft.breedingbuddies.listeners; - -import net.tfminecraft.breedingbuddies.AnimalInventoryHolder; -import net.tfminecraft.breedingbuddies.AnimalStates; -import net.tfminecraft.breedingbuddies.FarmAnimal; -import net.tfminecraft.breedingbuddies.MountUtils; -import net.tfminecraft.breedingbuddies.OwnershipManager; -import net.tfminecraft.breedingbuddies.PluginTestBase; -import net.tfminecraft.breedingbuddies.UnownedAnimalsManager; -import org.bukkit.Material; -import org.bukkit.attribute.Attribute; -import org.bukkit.entity.Cow; -import org.bukkit.entity.Entity; -import org.bukkit.entity.Horse; -import org.bukkit.entity.Zombie; -import org.bukkit.event.inventory.ClickType; -import org.bukkit.event.inventory.InventoryAction; -import org.bukkit.event.inventory.InventoryClickEvent; -import org.bukkit.event.inventory.InventoryType; -import org.bukkit.event.player.PlayerInteractEntityEvent; -import org.bukkit.inventory.EquipmentSlot; -import org.bukkit.inventory.Inventory; -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.entity.PlayerMock; - -import java.util.Set; - -import static org.junit.jupiter.api.Assertions.*; - -class InventoryManagerTest extends PluginTestBase { - private InventoryManager listener; - private PlayerMock alice; - - @BeforeEach - void createListener() { - listener = new InventoryManager(); - alice = player(); - } - - @AfterEach - void giveBackAnOpenInventory() { - // MockBukkit leaves no top inventory after closeInventory; a live server shows the crafting grid. - for (var player : server.getOnlinePlayers()) { - player.openInventory(server.createInventory(null, 9)); - } - } - - private PlayerInteractEntityEvent interact(PlayerMock player, Entity target, ItemStack held, boolean sneaking) { - player.setSneaking(sneaking); - player.getInventory().setItemInMainHand(held); - PlayerInteractEntityEvent event = new PlayerInteractEntityEvent(player, target, EquipmentSlot.HAND); - listener.onPlayerInteractEntity(event); - return event; - } - - private static Inventory topOf(PlayerMock player) { - return player.getOpenInventory().getTopInventory(); - } - - private static boolean showsAnimalMenu(PlayerMock player) { - // MockBukkit has no top inventory at all until one is opened. - Inventory top = topOf(player); - return top != null && top.getHolder() instanceof AnimalInventoryHolder; - } - - private static String nameAt(Inventory inventory, int slot) { - return inventory.getItem(slot).getItemMeta().getDisplayName(); - } - - private Horse horse() { - Horse horse = spawn(Horse.class); - horse.getAttribute(Attribute.MAX_HEALTH).setBaseValue(30); - horse.getAttribute(Attribute.MOVEMENT_SPEED).setBaseValue(0.225); - horse.setJumpStrength(1.0); - return horse; - } - - @Test - void sneakingWithAnEmptyHandShowsAnOwnersAnimal() { - Cow cow = spawn(Cow.class); - FarmAnimal daisy = own(alice, cow); - daisy.setFriendshipPoints(4000); - daisy.setGeneticPoints(10_000); - - interact(alice, cow, new ItemStack(Material.AIR), true); - - Inventory menu = topOf(alice); - assertEquals(27, menu.getSize()); - assertEquals(cow.getUniqueId(), ((AnimalInventoryHolder) menu.getHolder()).getEntityUUID()); - assertEquals("§6Name: Daisy", nameAt(menu, 3)); - assertEquals("§6Next bundle: 00:00", nameAt(menu, 4)); - assertEquals("§6State: HAPPY", nameAt(menu, 5)); - assertEquals(Material.WRITABLE_BOOK, menu.getItem(26).getType()); - assertEquals("§4Remove ownership", nameAt(menu, 26)); - for (int i = 0; i < 5; i++) { - assertEquals(i < 2 ? Material.RED_DYE : Material.GRAY_DYE, menu.getItem(11 + i).getType()); - assertEquals("§cFriendship: 4000/10000", nameAt(menu, 11 + i)); - assertEquals(Material.SLIME_SPAWN_EGG, menu.getItem(20 + i).getType()); - assertEquals("§eGenetics: 10000/10000", nameAt(menu, 20 + i)); - } - } - - @Test - void unownedAnimalsShowTheirMenuWithoutAnOwnershipButton() { - Cow cow = spawn(Cow.class); - UnownedAnimalsManager.addUnownedAnimal(new FarmAnimal(cow.getUniqueId(), 0, cow)); - - interact(alice, cow, new ItemStack(Material.AIR), true); - - Inventory menu = topOf(alice); - assertTrue(showsAnimalMenu(alice)); - assertEquals("§6Name: ???", nameAt(menu, 3)); - assertEquals("§6Next bundle: No bundle", nameAt(menu, 4)); - assertNull(menu.getItem(26)); - assertEquals(Material.VEX_SPAWN_EGG, menu.getItem(20).getType()); - } - - @Test - void otherPlayersAnimalsAreShownOnlyToStaff() { - Cow cow = spawn(Cow.class); - own(player(), cow); - PlayerMock staff = player(); - staff.addAttachment(plugin, "breedingbuddies.use", true); - - interact(alice, cow, new ItemStack(Material.AIR), true); - interact(staff, cow, new ItemStack(Material.AIR), true); - - assertFalse(showsAnimalMenu(alice)); - assertTrue(showsAnimalMenu(staff)); - assertNull(topOf(staff).getItem(26), "staff are not offered someone else's ownership"); - } - - @Test - void ordinaryClicksAndUntrackedAnimalsOpenNothing() { - Cow owned = spawn(Cow.class); - own(alice, owned); - Cow wild = spawn(Cow.class); - Zombie zombie = spawn(Zombie.class); - - interact(alice, owned, new ItemStack(Material.AIR), false); - interact(alice, wild, new ItemStack(Material.AIR), true); - interact(alice, zombie, new ItemStack(Material.CARROT_ON_A_STICK), true); - interact(alice, owned, new ItemStack(Material.WHEAT), true); - - assertFalse(showsAnimalMenu(alice)); - } - - @Test - void sneakingOnAMountLeavesTheVanillaHorseInventoryAlone() { - Horse horse = horse(); - own(alice, horse); - - PlayerInteractEntityEvent event = interact(alice, horse, new ItemStack(Material.AIR), true); - - assertFalse(event.isCancelled()); - assertFalse(showsAnimalMenu(alice)); - } - - @Test - void theMountStatsItemShowsAnyUntrackedMountsStats() { - Horse horse = horse(); - MountUtils.setNeuteredFlag(horse, true); - - PlayerInteractEntityEvent event = interact(alice, horse, new ItemStack(Material.CARROT_ON_A_STICK), false); - - assertTrue(event.isCancelled()); - Inventory menu = topOf(alice); - assertEquals(36, menu.getSize()); - assertEquals("§6Name: ???", nameAt(menu, 3)); - assertEquals("§6State: UNOWNED", nameAt(menu, 5)); - assertEquals("§6Neutered: true", nameAt(menu, 8)); - assertEquals("§dHealth: 15.00", nameAt(menu, 30)); - assertEquals(String.format("§bSpeed: %.2f", 0.225 * 42.16), nameAt(menu, 31)); - assertEquals(String.format("§aJump: %.2f", MountUtils.jumpStrengthToBlockHeight(1.0)), nameAt(menu, 32)); - assertNull(menu.getItem(35)); - assertFalse(UnownedAnimalsManager.isUnowned(horse.getUniqueId()), "looking does not start tracking"); - } - - @Test - void ownersSeeTheirMountsRecordAndOwnershipButton() { - Horse horse = horse(); - FarmAnimal storm = own(alice, horse); - storm.setFriendshipPoints(10_000); - - interact(alice, horse, new ItemStack(Material.CARROT_ON_A_STICK), false); - - Inventory menu = topOf(alice); - assertEquals("§6Name: Daisy", nameAt(menu, 3)); - assertEquals("§6Neutered: false", nameAt(menu, 8)); - assertEquals(Material.WRITABLE_BOOK, menu.getItem(35).getType()); - for (int i = 0; i < 5; i++) { - assertEquals(Material.RED_DYE, menu.getItem(11 + i).getType()); - } - } - - @Test - void otherPlayersMountsAreShownOnlyToStaff() { - Horse horse = horse(); - own(player(), horse); - - PlayerInteractEntityEvent event = interact(alice, horse, new ItemStack(Material.CARROT_ON_A_STICK), false); - - assertFalse(event.isCancelled()); - assertFalse(showsAnimalMenu(alice)); - } - - @Test - void theMountStatsItemShowsTheAnimalMenuForOtherFarmAnimals() { - Cow owned = spawn(Cow.class); - own(alice, owned); - Cow unowned = spawn(Cow.class); - UnownedAnimalsManager.addUnownedAnimal(new FarmAnimal(unowned.getUniqueId(), unowned)); - Cow theirs = spawn(Cow.class); - own(player(), theirs); - - interact(alice, owned, new ItemStack(Material.CARROT_ON_A_STICK), false); - assertEquals(27, topOf(alice).getSize()); - assertEquals("§6Name: Daisy", nameAt(topOf(alice), 3)); - - interact(alice, unowned, new ItemStack(Material.CARROT_ON_A_STICK), false); - assertEquals("§6Name: ???", nameAt(topOf(alice), 3)); - - alice.openInventory(server.createInventory(null, 9)); - interact(alice, theirs, new ItemStack(Material.CARROT_ON_A_STICK), false); - assertFalse(showsAnimalMenu(alice)); - } - - @Test - void inspectingAnOwnedAnimalCatchesUpOnMissedDays() throws Exception { - Cow cow = spawn(Cow.class); - FarmAnimal daisy = own(alice, cow); - daisy.setFed(true); - setLastDayChange(1); - - interact(alice, cow, new ItemStack(Material.AIR), true); - awaitDayChange(); - - assertFalse(daisy.getFed()); - } - - private InventoryClickEvent click(PlayerMock player, int slot) { - InventoryClickEvent event = new InventoryClickEvent(player.getOpenInventory(), InventoryType.SlotType.CONTAINER, - slot, ClickType.LEFT, InventoryAction.PICKUP_ALL); - listener.onInventoryClick(event); - return event; - } - - @Test - void theAnimalMenuCannotBeRearranged() { - Cow cow = spawn(Cow.class); - own(alice, cow); - interact(alice, cow, new ItemStack(Material.AIR), true); - - InventoryClickEvent onName = click(alice, 3); - InventoryClickEvent onEmpty = click(alice, 0); - - assertTrue(onName.isCancelled()); - assertTrue(onEmpty.isCancelled()); - assertTrue(OwnershipManager.isOwner(alice.getUniqueId(), cow.getUniqueId())); - } - - @Test - void theLastOwnerGivingUpAnAnimalLeavesItUnowned() { - Cow cow = spawn(Cow.class); - FarmAnimal daisy = own(alice, cow); - interact(alice, cow, new ItemStack(Material.AIR), true); - - click(alice, 26); - - assertFalse(OwnershipManager.isOwner(alice.getUniqueId(), cow.getUniqueId())); - assertEquals(AnimalStates.UNOWNED, daisy.getState()); - assertSame(daisy, UnownedAnimalsManager.getAnimal(cow.getUniqueId())); - assertEquals("§6You are no longer the owner of Daisy.", alice.nextMessage()); - assertFalse(showsAnimalMenu(alice)); - } - - @Test - void aCoOwnerGivingUpLeavesTheOthersInCharge() { - Cow cow = spawn(Cow.class); - FarmAnimal daisy = own(alice, cow); - PlayerMock bob = player(); - OwnershipManager.registerOwnership(bob.getUniqueId(), daisy); - interact(alice, cow, new ItemStack(Material.AIR), true); - - click(alice, 26); - - assertEquals(Set.of(bob.getUniqueId()), daisy.getOwnersUuids()); - assertEquals(AnimalStates.HAPPY, daisy.getState()); - assertFalse(UnownedAnimalsManager.isUnowned(cow.getUniqueId())); - } - - @Test - void theRemoveButtonDoesNothingForANonOwner() { - Cow cow = spawn(Cow.class); - FarmAnimal daisy = own(player(), cow); - Inventory menu = new AnimalInventoryHolder(cow.getUniqueId(), 27, "Animal Info").getInventory(); - menu.setItem(26, new ItemStack(Material.WRITABLE_BOOK)); - alice.openInventory(menu); - - assertTrue(click(alice, 26).isCancelled()); - assertEquals(1, daisy.getOwnersUuids().size()); - assertNull(alice.nextMessage()); - } - - @Test - void otherInventoriesAreLeftAlone() { - Inventory chest = server.createInventory(null, 27); - chest.setItem(0, new ItemStack(Material.WRITABLE_BOOK)); - alice.openInventory(chest); - - assertFalse(click(alice, 0).isCancelled()); - } -} diff --git a/src/test/java/net/tfminecraft/breedingbuddies/listeners/MountListenerTest.java b/src/test/java/net/tfminecraft/breedingbuddies/listeners/MountListenerTest.java deleted file mode 100644 index 64e03ae..0000000 --- a/src/test/java/net/tfminecraft/breedingbuddies/listeners/MountListenerTest.java +++ /dev/null @@ -1,263 +0,0 @@ -package net.tfminecraft.breedingbuddies.listeners; - -import net.tfminecraft.breedingbuddies.FarmAnimal; -import net.tfminecraft.breedingbuddies.MountUtils; -import net.tfminecraft.breedingbuddies.PluginTestBase; -import net.tfminecraft.breedingbuddies.configurables.Numbers; -import org.bukkit.Material; -import org.bukkit.NamespacedKey; -import org.bukkit.Sound; -import org.bukkit.attribute.Attribute; -import org.bukkit.entity.Cow; -import org.bukkit.entity.Entity; -import org.bukkit.entity.Horse; -import org.bukkit.entity.Zombie; -import org.bukkit.event.entity.CreatureSpawnEvent; -import org.bukkit.event.entity.EntityMountEvent; -import org.bukkit.event.player.PlayerInteractEntityEvent; -import org.bukkit.event.world.ChunkLoadEvent; -import org.bukkit.inventory.EquipmentSlot; -import org.bukkit.inventory.ItemStack; -import org.bukkit.persistence.PersistentDataType; -import org.junit.jupiter.api.BeforeEach; -import org.junit.jupiter.api.Test; -import org.mockbukkit.mockbukkit.entity.PlayerMock; - -import static org.hamcrest.MatcherAssert.assertThat; -import static org.mockbukkit.mockbukkit.matcher.sound.SoundReceiverSoundHeardMatcher.hasHeard; -import static org.junit.jupiter.api.Assertions.*; -import static org.mockito.Mockito.mock; - -/** Riding, neutering and the optional stat nerf for mounts. */ -class MountListenerTest extends PluginTestBase { - private MountListener mounts; - private PlayerMock alice; - - @BeforeEach - void createListener() { - mounts = new MountListener(); - alice = player(); - } - - private EntityMountEvent mount(Entity rider, Entity mount) { - EntityMountEvent event = new EntityMountEvent(rider, mount); - mounts.onMount(event); - mounts.onMountFix(event); - return event; - } - - private PlayerInteractEntityEvent interact(PlayerMock player, Entity target, ItemStack held, EquipmentSlot hand) { - player.getInventory().setItemInMainHand(held); - PlayerInteractEntityEvent event = new PlayerInteractEntityEvent(player, target, hand); - mounts.onInteract(event); - mounts.onInteractFix(event); - return event; - } - - private PlayerMock staff() { - PlayerMock staff = player(); - staff.addAttachment(plugin, "breedingbuddies.use", true); - return staff; - } - - private Horse foal() { - Horse horse = spawn(Horse.class); - horse.setBaby(); - return horse; - } - - @Test - void onlyOwnersMayRideAnOwnedAnimal() { - Horse horse = spawn(Horse.class); - own(alice, horse); - PlayerMock bob = player(); - - EntityMountEvent byStranger = mount(bob, horse); - EntityMountEvent byOwner = mount(alice, horse); - EntityMountEvent onWild = mount(bob, spawn(Horse.class)); - - assertTrue(byStranger.isCancelled()); - assertThat(bob, hasHeard("entity.horse.ambient")); - assertFalse(byOwner.isCancelled()); - assertFalse(onWild.isCancelled()); - } - - @Test - void ridingByMobsOrOnNonAnimalsIsIgnored() { - Horse horse = spawn(Horse.class); - FarmAnimal record = own(alice, horse); - record.setStableNeeded(true); - - EntityMountEvent mobRider = mount(spawn(Zombie.class), horse); - EntityMountEvent boat = mount(alice, mock(Entity.class)); - - assertFalse(mobRider.isCancelled()); - assertFalse(boat.isCancelled()); - assertTrue(record.isStableNeeded(), "only the owner riding updates the record"); - } - - @Test - void ridingOrTouchingAnOwnedMountMarksItAsNotNeedingAStable() { - Horse ridden = spawn(Horse.class); - FarmAnimal riddenRecord = own(alice, ridden); - riddenRecord.setStableNeeded(true); - Horse touched = spawn(Horse.class); - FarmAnimal touchedRecord = own(alice, touched); - touchedRecord.setStableNeeded(true); - Cow cow = spawn(Cow.class); - FarmAnimal cowRecord = own(alice, cow); - - mount(alice, ridden); - interact(alice, touched, new ItemStack(Material.AIR), EquipmentSlot.HAND); - interact(alice, cow, new ItemStack(Material.AIR), EquipmentSlot.HAND); - interact(alice, spawn(Horse.class), new ItemStack(Material.AIR), EquipmentSlot.HAND); - - assertFalse(riddenRecord.isStableNeeded()); - assertFalse(touchedRecord.isStableNeeded()); - assertTrue(cowRecord.isStableNeeded()); - } - - @Test - void ownersCanNeuterTheirFoals() { - Horse foal = foal(); - own(alice, foal); - - PlayerInteractEntityEvent event = interact(alice, foal, new ItemStack(Material.SHEARS), EquipmentSlot.HAND); - - assertTrue(event.isCancelled()); - assertTrue(MountUtils.isNeutered(foal)); - assertThat(alice, hasHeard(Sound.ENTITY_HORSE_DEATH)); - } - - @Test - void adultsCanOnlyBeNeuteredByStaffWhoOwnThem() { - Horse adult = spawn(Horse.class); - own(alice, adult); - PlayerMock staff = staff(); - Horse staffHorse = spawn(Horse.class); - own(staff, staffHorse); - - PlayerInteractEntityEvent byOwner = interact(alice, adult, new ItemStack(Material.SHEARS), EquipmentSlot.HAND); - PlayerInteractEntityEvent byStaffNotOwner = interact(staff, adult, new ItemStack(Material.SHEARS), EquipmentSlot.HAND); - PlayerInteractEntityEvent byStaffOwner = interact(staff, staffHorse, new ItemStack(Material.SHEARS), EquipmentSlot.HAND); - - assertFalse(byOwner.isCancelled()); - assertFalse(byStaffNotOwner.isCancelled()); - assertFalse(MountUtils.isNeutered(adult)); - assertTrue(byStaffOwner.isCancelled()); - assertTrue(MountUtils.isNeutered(staffHorse)); - } - - @Test - void neuteringNeedsShearsTheMainHandAnOwnerAndAnIntactMount() { - Horse foal = foal(); - own(alice, foal); - Horse theirs = foal(); - own(player(), theirs); - Horse done = foal(); - own(alice, done); - MountUtils.setNeuteredFlag(done, true); - Cow calf = spawn(Cow.class); - own(alice, calf); - - assertFalse(interact(alice, foal, new ItemStack(Material.WHEAT), EquipmentSlot.HAND).isCancelled()); - assertFalse(interact(alice, foal, new ItemStack(Material.SHEARS), EquipmentSlot.OFF_HAND).isCancelled()); - assertTrue(interact(alice, theirs, new ItemStack(Material.SHEARS), EquipmentSlot.HAND).isCancelled()); - assertFalse(interact(alice, done, new ItemStack(Material.SHEARS), EquipmentSlot.HAND).isCancelled()); - assertFalse(interact(alice, calf, new ItemStack(Material.SHEARS), EquipmentSlot.HAND).isCancelled()); - - assertFalse(MountUtils.isNeutered(foal)); - assertFalse(MountUtils.isNeutered(theirs)); - } - - // Nerfing - - private final NerfMountListener nerf = new NerfMountListener(); - - private Horse horse(double health, double speed, double jump) { - Horse horse = spawn(Horse.class); - horse.getAttribute(Attribute.MAX_HEALTH).setBaseValue(health); - horse.getAttribute(Attribute.MOVEMENT_SPEED).setBaseValue(speed); - horse.setJumpStrength(jump); - return horse; - } - - private static void assertStats(Horse horse, double health, double speed, double jump) { - assertEquals(health, horse.getAttribute(Attribute.MAX_HEALTH).getBaseValue(), 1e-9); - assertEquals(speed, horse.getAttribute(Attribute.MOVEMENT_SPEED).getBaseValue(), 1e-9); - assertEquals(jump, horse.getJumpStrength(), 1e-9); - } - - @Test - void naturalAndEggSpawnedMountsAreNerfedOnceWhenEnabled() { - Numbers.nerfMountStats = true; - Horse natural = horse(20, 0.2, 0.7); - Horse egg = horse(20, 0.2, 0.7); - - nerf.onHorseSpawn(new CreatureSpawnEvent(natural, CreatureSpawnEvent.SpawnReason.NATURAL)); - nerf.onHorseSpawn(new CreatureSpawnEvent(egg, CreatureSpawnEvent.SpawnReason.SPAWNER_EGG)); - nerf.onHorseSpawn(new CreatureSpawnEvent(natural, CreatureSpawnEvent.SpawnReason.NATURAL)); - - assertStats(natural, 10, 0.1, 0.35); - assertStats(egg, 10, 0.1, 0.35); - assertTrue(nerf.isNerfed(natural)); - } - - @Test - void commandSpawnedMountsAreOnlyMarkedAndBredOnesUntouched() { - Numbers.nerfMountStats = true; - Horse commanded = horse(20, 0.2, 0.7); - Horse bred = horse(20, 0.2, 0.7); - - nerf.onHorseSpawn(new CreatureSpawnEvent(commanded, CreatureSpawnEvent.SpawnReason.COMMAND)); - nerf.onHorseSpawn(new CreatureSpawnEvent(bred, CreatureSpawnEvent.SpawnReason.BREEDING)); - nerf.onHorseSpawn(new CreatureSpawnEvent(spawn(Cow.class), CreatureSpawnEvent.SpawnReason.NATURAL)); - - assertStats(commanded, 20, 0.2, 0.7); - assertTrue(nerf.isNerfed(commanded)); - assertStats(bred, 20, 0.2, 0.7); - assertFalse(nerf.isNerfed(bred)); - } - - @Test - void nothingIsNerfedWhileTheOptionIsOff() { - Horse natural = horse(20, 0.2, 0.7); - - nerf.onHorseSpawn(new CreatureSpawnEvent(natural, CreatureSpawnEvent.SpawnReason.NATURAL)); - nerf.onChunkLoad(new ChunkLoadEvent(fakeChunk(0, 0, false, natural), true)); - - assertStats(natural, 20, 0.2, 0.7); - assertFalse(nerf.isNerfed(natural)); - } - - @Test - void mountsInLoadedChunksAreNerfedUnlessAlreadyDone() { - Numbers.nerfMountStats = true; - Horse fresh = horse(20, 0.2, 0.7); - Horse old = horse(20, 0.2, 0.7); - Horse done = horse(20, 0.2, 0.7); - nerf.setNerfedFlag(done, true); - Cow cow = spawn(Cow.class); - - nerf.onChunkLoad(new ChunkLoadEvent(fakeChunk(0, 0, false, fresh, cow), true)); - nerf.onChunkLoad(new ChunkLoadEvent(fakeChunk(1, 0, false, old, done), false)); - - assertStats(fresh, 10, 0.1, 0.35); - assertStats(old, 10, 0.1, 0.35); - assertStats(done, 20, 0.2, 0.7); - assertEquals((byte) 1, done.getPersistentDataContainer().get(new NamespacedKey(plugin, "is_nerfed"), PersistentDataType.BYTE)); - } - - @Test - void aBadDivisorIsLoggedInsteadOfBreakingTheSpawn() { - Numbers.nerfMountStats = true; - Numbers.mountNerfDivisor = 0; - Horse still = horse(20, 0.2, 0); - - try (LogCapture log = new LogCapture(org.bukkit.Bukkit.getLogger())) { - assertDoesNotThrow(() -> nerf.onHorseSpawn(new CreatureSpawnEvent(still, CreatureSpawnEvent.SpawnReason.NATURAL))); - assertTrue(log.contains("[BreedinBuddies] Error while nerfing horse")); - } - assertTrue(nerf.isNerfed(still)); - } -} diff --git a/src/test/java/net/tfminecraft/breedingbuddies/listeners/TamingListenerTest.java b/src/test/java/net/tfminecraft/breedingbuddies/listeners/TamingListenerTest.java deleted file mode 100644 index 0752bbd..0000000 --- a/src/test/java/net/tfminecraft/breedingbuddies/listeners/TamingListenerTest.java +++ /dev/null @@ -1,348 +0,0 @@ -package net.tfminecraft.breedingbuddies.listeners; - -import net.tfminecraft.breedingbuddies.AnimalStates; -import net.tfminecraft.breedingbuddies.FarmAnimal; -import net.tfminecraft.breedingbuddies.ItemUtils; -import net.tfminecraft.breedingbuddies.OwnershipManager; -import net.tfminecraft.breedingbuddies.PluginTestBase; -import net.tfminecraft.breedingbuddies.UnownedAnimalsManager; -import net.tfminecraft.breedingbuddies.configurables.Messages; -import org.bukkit.Material; -import org.bukkit.Sound; -import org.bukkit.block.BlockFace; -import org.bukkit.entity.Cow; -import org.bukkit.entity.Entity; -import org.bukkit.entity.Horse; -import org.bukkit.entity.Pig; -import org.bukkit.entity.SkeletonHorse; -import org.bukkit.event.block.Action; -import org.bukkit.event.player.PlayerInteractEntityEvent; -import org.bukkit.event.player.PlayerInteractEvent; -import org.bukkit.inventory.EquipmentSlot; -import org.bukkit.inventory.ItemStack; -import org.bukkit.inventory.meta.ItemMeta; -import org.junit.jupiter.api.BeforeEach; -import org.junit.jupiter.api.Test; -import org.mockbukkit.mockbukkit.entity.PlayerMock; - -import java.util.HashSet; -import java.util.List; -import java.util.Set; -import java.util.UUID; - -import static org.hamcrest.MatcherAssert.assertThat; -import static org.mockbukkit.mockbukkit.matcher.sound.SoundReceiverSoundHeardMatcher.hasHeard; -import static org.junit.jupiter.api.Assertions.*; - -class TamingListenerTest extends PluginTestBase { - private TamingListener listener; - private PlayerMock alice; - - @BeforeEach - void createListener() { - listener = new TamingListener(plugin); - alice = player(); - } - - /** A taming token named in an anvil, which also stores the new name on it. */ - private ItemStack tamingToken(String name, int amount) { - ItemStack token = mmoItem("TAMING_ITEM"); - token.setAmount(amount); - ItemMeta meta = token.getItemMeta(); - meta.setDisplayName(name); - token.setItemMeta(meta); - ItemUtils.storeDataInItem(token, name, plugin); - return token; - } - - private ItemStack shareToken(UUID linkedAnimal) { - ItemStack token = mmoItem("COOWNERSHIP_ITEM"); - token.setAmount(2); - if (linkedAnimal != null) { - ItemUtils.storeDataInItem(token, linkedAnimal.toString(), plugin); - } - return token; - } - - private PlayerInteractEntityEvent useOn(PlayerMock player, Entity target, ItemStack held, EquipmentSlot hand) { - player.getInventory().setItemInMainHand(held); - PlayerInteractEntityEvent event = new PlayerInteractEntityEvent(player, target, hand); - listener.onInteract(event); - return event; - } - - private PlayerInteractEntityEvent useOn(Entity target, ItemStack held) { - return useOn(alice, target, held, EquipmentSlot.HAND); - } - - private static ItemStack held(PlayerMock player) { - return player.getInventory().getItemInMainHand(); - } - - private void rightClickAir(PlayerMock player, ItemStack held, Action action) { - listener.onRightClick(new PlayerInteractEvent(player, action, held, null, BlockFace.SELF, EquipmentSlot.HAND)); - } - - @Test - void aRenamedTokenTamesAFarmAnimal() { - Cow cow = spawn(Cow.class); - ItemStack token = tamingToken("Daisy", 2); - - PlayerInteractEntityEvent event = useOn(cow, token); - - assertTrue(event.isCancelled()); - FarmAnimal animal = OwnershipManager.getAnimal(cow.getUniqueId(), alice.getUniqueId()); - assertEquals("Daisy", animal.getName()); - assertEquals(AnimalStates.HAPPY, animal.getState()); - assertEquals("Daisy", cow.getCustomName()); - assertFalse(cow.isCustomNameVisible()); - assertEquals(1, held(alice).getAmount()); - assertEquals("§6You have successfully tamed Daisy and are now its proud owner!", alice.nextMessage()); - assertThat(alice, hasHeard(Sound.BLOCK_AMETHYST_CLUSTER_STEP)); - } - - @Test - void aTokenThatWasNotRenamedInAnAnvilCannotTame() { - Cow cow = spawn(Cow.class); - ItemStack named = mmoItem("TAMING_ITEM"); - ItemMeta meta = named.getItemMeta(); - meta.setDisplayName("Daisy"); - named.setItemMeta(meta); - - useOn(cow, named); - useOn(cow, mmoItem("TAMING_ITEM")); - - assertFalse(OwnershipManager.hasOwner(cow.getUniqueId())); - assertEquals(Messages.nameFirst, alice.nextMessage()); - assertEquals(Messages.nameFirst, alice.nextMessage()); - assertEquals(1, named.getAmount()); - } - - @Test - void tamingASpawnedAnimalKeepsItsStatsAndTakesTheNewName() { - Cow cow = spawn(Cow.class); - FarmAnimal spawned = new FarmAnimal(cow.getUniqueId(), "COW", UUID.randomUUID(), cow, 4000, 300); - spawned.setState(AnimalStates.SPAWNED); - UnownedAnimalsManager.addUnownedAnimal(spawned); - - useOn(cow, tamingToken("Daisy", 1)); - - FarmAnimal tamed = OwnershipManager.getAnimal(cow.getUniqueId(), alice.getUniqueId()); - assertEquals(4000, tamed.getGeneticPoints()); - assertEquals(300, tamed.getFriendshipPoints()); - assertEquals("Daisy", tamed.getName()); - assertEquals(Set.of(alice.getUniqueId()), tamed.getOwnersUuids()); - assertFalse(UnownedAnimalsManager.isUnowned(cow.getUniqueId())); - } - - @Test - void tamingABredAnimalKeepsItsGenetics() { - Cow cow = spawn(Cow.class); - UnownedAnimalsManager.addUnownedAnimal(new FarmAnimal(cow.getUniqueId(), 777, cow)); - - useOn(cow, tamingToken("Daisy", 1)); - - FarmAnimal tamed = OwnershipManager.getAnimal(cow.getUniqueId(), alice.getUniqueId()); - assertEquals(777, tamed.getGeneticPoints()); - assertEquals("Daisy", tamed.getName()); - assertFalse(UnownedAnimalsManager.isUnowned(cow.getUniqueId())); - } - - @Test - void ownersCanRenameTheirAnimals() { - Cow cow = spawn(Cow.class); - FarmAnimal daisy = own(alice, cow); - ItemStack token = tamingToken("Clover", 2); - - useOn(cow, token); - - assertEquals("Clover", daisy.getName()); - assertEquals("Clover", cow.getCustomName()); - assertEquals(1, held(alice).getAmount()); - assertThat(alice, hasHeard(Sound.BLOCK_AMETHYST_CLUSTER_STEP)); - - useOn(cow, mmoItem("TAMING_ITEM")); - assertEquals(Messages.nameFirst, alice.nextMessage()); - assertEquals("Clover", daisy.getName()); - } - - @Test - void anotherPlayersAnimalCannotBeTamedOrRenamed() { - Cow cow = spawn(Cow.class); - FarmAnimal daisy = own(player(), cow); - - useOn(cow, tamingToken("Mine", 1)); - - assertEquals(Messages.notOwner, alice.nextMessage()); - assertEquals("Daisy", daisy.getName()); - assertFalse(OwnershipManager.isOwner(alice.getUniqueId(), cow.getUniqueId())); - } - - @Test - void untamedTameableAnimalsMustBeTamedTheVanillaWayFirst() { - Horse wild = spawn(Horse.class); - ItemStack token = tamingToken("Storm", 1); - - PlayerInteractEntityEvent event = useOn(wild, token); - - assertTrue(event.isCancelled()); - assertEquals(Messages.tameFirst, alice.nextMessage()); - assertFalse(OwnershipManager.hasOwner(wild.getUniqueId())); - - Horse tamed = spawn(Horse.class); - tamed.setTamed(true); - useOn(tamed, token); - assertTrue(OwnershipManager.isOwner(alice.getUniqueId(), tamed.getUniqueId())); - } - - @Test - void tokensOnlyActFromTheMainHandAndOnFarmAnimals() { - Cow cow = spawn(Cow.class); - Pig pig = spawn(Pig.class); - - PlayerInteractEntityEvent offHand = useOn(alice, cow, tamingToken("Daisy", 1), EquipmentSlot.OFF_HAND); - PlayerInteractEntityEvent onPig = useOn(pig, tamingToken("Babe", 1)); - PlayerInteractEntityEvent plainItem = useOn(cow, new ItemStack(Material.WHEAT)); - - assertTrue(offHand.isCancelled(), "the token's vanilla use is still blocked"); - assertTrue(onPig.isCancelled()); - assertFalse(plainItem.isCancelled()); - assertFalse(OwnershipManager.hasOwner(cow.getUniqueId())); - assertFalse(OwnershipManager.hasOwner(pig.getUniqueId())); - assertNull(alice.nextMessage()); - } - - @Test - void ownersLinkAShareTokenToTheirAnimal() { - Cow cow = spawn(Cow.class); - cow.setCustomName("§aDaisy"); - own(alice, cow); - ItemStack token = shareToken(null); - - useOn(cow, token); - - ItemStack linked = held(alice); - assertEquals(cow.getUniqueId().toString(), ItemUtils.getDataFromItem(linked, plugin)); - assertEquals(List.of("§7This token is linked to Daisy the Cow."), linked.getItemMeta().getLore()); - assertThat(alice, hasHeard("block.amethyst_block.step")); - - useOn(cow, linked); - assertEquals("§6Already linked to §aDaisy.", alice.nextMessage()); - } - - @Test - void shareTokensNeedAnOwnedAnimalAndItsOwner() { - Cow wild = spawn(Cow.class); - Cow theirs = spawn(Cow.class); - theirs.setCustomName("Bessie"); - own(player(), theirs); - ItemStack token = shareToken(null); - - useOn(wild, token); - useOn(theirs, token); - - assertEquals(Messages.ownerFirst, alice.nextMessage()); - assertEquals("§6Only Bessie's owner can share its ownership.", alice.nextMessage()); - assertNull(ItemUtils.getDataFromItem(held(alice), plugin)); - } - - @Test - void aLinkedTokenGivenToAnotherPlayerMakesThemACoOwner() { - Cow cow = spawn(Cow.class); - FarmAnimal daisy = own(alice, cow); - PlayerMock bob = player(); - ItemStack token = shareToken(cow.getUniqueId()); - - useOn(bob, token); - - assertEquals(Set.of(alice.getUniqueId(), bob.getUniqueId()), daisy.getOwnersUuids()); - assertTrue(OwnershipManager.isOwner(bob.getUniqueId(), cow.getUniqueId())); - assertEquals(1, held(alice).getAmount()); - assertThat(bob, hasHeard(Sound.BLOCK_AMETHYST_CLUSTER_STEP)); - - useOn(bob, held(alice)); - assertEquals("§6Already an owner of Daisy.", alice.nextMessage()); - assertEquals(1, held(alice).getAmount()); - } - - @Test - void unlinkedOrStaleTokensCannotBeGivenToPlayers() { - PlayerMock bob = player(); - - useOn(bob, shareToken(null)); - useOn(bob, shareToken(UUID.randomUUID())); - - assertEquals(Messages.useOnOwnedAnimal, alice.nextMessage()); - assertEquals(Messages.invalidAnimalOrNotFound, alice.nextMessage()); - assertTrue(OwnershipManager.getAnimals(bob.getUniqueId()).isEmpty()); - } - - @Test - void usingALinkedTokenClaimsCoOwnershipForTheHolder() { - Cow cow = spawn(Cow.class); - FarmAnimal daisy = own(player(), cow); - ItemStack token = shareToken(cow.getUniqueId()); - - rightClickAir(alice, token, Action.RIGHT_CLICK_AIR); - - assertTrue(daisy.getOwnersUuids().contains(alice.getUniqueId())); - assertEquals(1, token.getAmount()); - assertThat(alice, hasHeard(Sound.BLOCK_AMETHYST_CLUSTER_STEP)); - - rightClickAir(alice, token, Action.RIGHT_CLICK_BLOCK); - assertEquals("§6Already an owner of Daisy.", alice.nextMessage()); - assertEquals(1, token.getAmount()); - } - - @Test - void usingAnUnlinkedOrStaleTokenExplainsWhy() { - rightClickAir(alice, shareToken(null), Action.RIGHT_CLICK_AIR); - rightClickAir(alice, shareToken(UUID.randomUUID()), Action.RIGHT_CLICK_AIR); - - assertEquals(Messages.useOnOwnedAnimal, alice.nextMessage()); - assertEquals(Messages.invalidAnimalOrNotFound, alice.nextMessage()); - } - - @Test - void otherClicksWithTokensDoNothing() { - Cow cow = spawn(Cow.class); - own(player(), cow); - - rightClickAir(alice, shareToken(cow.getUniqueId()), Action.LEFT_CLICK_AIR); - rightClickAir(alice, null, Action.RIGHT_CLICK_AIR); - rightClickAir(alice, tamingToken("Daisy", 1), Action.RIGHT_CLICK_AIR); - - assertFalse(OwnershipManager.isOwner(alice.getUniqueId(), cow.getUniqueId())); - assertNull(alice.nextMessage()); - } - - @Test - void tokenLoreNamesTheAnimalAndItsSpecies() { - SkeletonHorse bones = spawn(SkeletonHorse.class); - bones.setCustomName("Bones"); - Cow unnamed = spawn(Cow.class); - ItemStack air = new ItemStack(Material.AIR); - ItemStack token = mmoItem("COOWNERSHIP_ITEM"); - - assertSame(air, listener.changeTokenLore(bones, air)); - assertSame(token, listener.changeTokenLore(null, token)); - assertNull(listener.changeTokenLore(bones, null)); - listener.changeTokenLore(unnamed, token); - assertNull(token.getItemMeta().getLore()); - - listener.changeTokenLore(bones, token); - assertEquals(List.of("§7This token is linked to Bones the Skeleton Horse."), token.getItemMeta().getLore()); - } - - @Test - void namingHelpersOnlyNameWithAnAnvilRenamedToken() { - Cow cow = spawn(Cow.class); - FarmAnimal animal = new FarmAnimal(cow.getUniqueId(), "Old", new HashSet<>()); - - assertFalse(listener.name(cow, new ItemStack(Material.PAPER))); - assertFalse(listener.name(cow, new ItemStack(Material.PAPER), animal)); - assertTrue(listener.name(cow, tamingToken("New", 1), animal)); - assertEquals("New", animal.getName()); - assertEquals("New", cow.getCustomName()); - } -} diff --git a/src/test/java/net/tfminecraft/breedingbuddies/testsupport/TestCharacters.java b/src/test/java/net/tfminecraft/breedingbuddies/testsupport/TestCharacters.java new file mode 100644 index 0000000..c607988 --- /dev/null +++ b/src/test/java/net/tfminecraft/breedingbuddies/testsupport/TestCharacters.java @@ -0,0 +1,58 @@ +package net.tfminecraft.breedingbuddies.testsupport; + +import static org.mockito.Mockito.*; +import java.util.HashMap; +import java.util.Map; +import java.util.Optional; +import java.util.UUID; +import org.bukkit.Bukkit; +import org.bukkit.entity.Player; +import org.bukkit.plugin.Plugin; +import org.bukkit.plugin.ServicePriority; +import net.tfminecraft.tlibs.character.CharacterIdentity; +import net.tfminecraft.tlibs.character.CharacterRef; +import net.tfminecraft.tlibs.character.CharacterSession; + +/** Real facade and registry with controllable activation generations. */ +public final class TestCharacters implements AutoCloseable { + public final CharacterIdentity provider = mock(CharacterIdentity.class); + private final Plugin plugin; + private final Map sessions = new HashMap<>(); + private long generation; + + public TestCharacters(Plugin plugin) { + this.plugin = plugin; + when(provider.currentSession(any())).thenAnswer(call -> { + Player player = call.getArgument(0); + return player == null ? Optional.empty() : Optional.ofNullable(sessions.get(player.getUniqueId())); + }); + when(provider.isCurrent(any())).thenAnswer(call -> { + CharacterSession session = call.getArgument(0); + return session != null && session.equals(sessions.get(session.ref().ownerId())); + }); + register(); + } + + public CharacterSession activate(Player player, UUID characterId) { + CharacterSession token = new CharacterSession(new CharacterRef(player.getUniqueId(), characterId), ++generation); + sessions.put(player.getUniqueId(), token); + return token; + } + + public CharacterSession activate(Player player, String characterId) { + return activate(player, UUID.fromString(characterId)); + } + + public void set(Player player, CharacterSession session) { + if (session == null) sessions.remove(player.getUniqueId()); + else sessions.put(player.getUniqueId(), session); + } + + public void register() { + Bukkit.getServicesManager().register(CharacterIdentity.class, provider, plugin, ServicePriority.Normal); + } + + @Override public void close() { + Bukkit.getServicesManager().unregister(CharacterIdentity.class, provider); + } +} diff --git a/tools/animal-ownership-rollback.py b/tools/animal-ownership-rollback.py deleted file mode 100644 index 2bd53d7..0000000 --- a/tools/animal-ownership-rollback.py +++ /dev/null @@ -1,129 +0,0 @@ -#!/usr/bin/env python3 -"""Prepare legacy files for the pre-character d6fa922 source lineage (no published JAR), -on a stopped server. Apply preserves character data, journals and receipts. A -character-ownership build resumes the existing store and does not migrate again. -After pre-character writes, that store refuses to open until staff review. -""" -import argparse -import hashlib -import json -import os -from pathlib import Path -import shutil - - -def _unique_object(pairs): - value = {} - for key, item in pairs: - if key in value: - raise ValueError('duplicate JSON key: ' + key) - value[key] = item - return value - - -def parse_document(text): - return json.loads(text, object_pairs_hook=_unique_object) - - -def preview(root): - root = Path(root).resolve() - archive = parse_document((root / 'ownership/source.json').read_text(encoding='utf-8')) - plan = parse_document((root / 'ownership/migration.json').read_text(encoding='utf-8')) - conflicts = [] - legacy = {str(p.relative_to(root)) for p in (root / 'PlayerData').glob('*.json')} - if (root / 'unownedanimals.json').exists(): - legacy.add('unownedanimals.json') - if legacy != set(archive): - conflicts.append('legacy file inventory changed') - for name, text in archive.items(): - path = root / name - if path.is_absolute() and not path.resolve().is_relative_to(root): - raise ValueError('unsafe archive path') - if not path.exists() or path.read_bytes() != text.encode('utf-8'): - conflicts.append('legacy source changed: ' + name) - for path in (root / 'ownership/journals').glob('*.json'): - if not parse_document(path.read_text(encoding='utf-8')).get('done'): - conflicts.append('incomplete journal: ' + path.name) - for path in (root / 'ownership/token-effects').glob('*.json'): - if parse_document(path.read_text(encoding='utf-8')).get('status') != 'done': - conflicts.append('unfinished token effect: ' + path.name) - copies = {} - differing = set() - character_files = list((root / 'CharacterData').glob('*.json')) - unowned = root / 'character-unowned.json' - if unowned.exists(): - character_files.append(unowned) - for path in character_files: - document = parse_document(path.read_text(encoding='utf-8')) - for row in document['animals']: - previous = copies.setdefault(row['uuid'], row) - if previous != row: - conflicts.append('conflicting fanout: ' + row['uuid']) - if plan['canonical'].get(row['uuid']) != row: - differing.add(row['uuid']) - canonical = plan['canonical'] - changed = sorted(differing | (set(canonical) ^ set(copies))) - conflicts.extend('post-migration animal changes: ' + key for key in changed) - return {'archived_files': len(archive), 'canonical_animals': len(canonical), 'current_animals': len(copies), - 'held_animals': len(plan['held']), 'changed_animals': changed, 'conflicts': sorted(set(conflicts)), 'applied': False, - 'target': 'pre-character d6fa922 source lineage; no published JAR', - 'preserved': ['character data', 'journals', 'receipts'], - 'character_restart': 'resumes the existing character store and does not migrate again', - 'after_legacy_writes': 'character store refuses to open until staff review'} - - -def rollback(root, apply=False, backup=None): - root = Path(root).resolve() - result = preview(root) - if not apply: - return result - if result['conflicts']: - raise ValueError('Manual reconciliation required: ' + '; '.join(result['conflicts'])) - if backup is None: - raise ValueError('Choose a new backup directory with --backup') - target = Path(backup).resolve() - if target.is_relative_to(root) or root.is_relative_to(target): - raise ValueError('Recovery directory must be separate from plugin data') - target.mkdir(parents=True, exist_ok=False) - originals = {str(p.relative_to(root)): hashlib.sha256(p.read_bytes()).hexdigest() for p in root.rglob('*') if p.is_file()} - shutil.copytree(root, target / 'data') - copied = {str(p.relative_to(target / 'data')): hashlib.sha256(p.read_bytes()).hexdigest() for p in (target / 'data').rglob('*') if p.is_file()} - if copied != originals: - raise ValueError('Recovery image verification failed') - (target / 'SHA256.json').write_text(json.dumps(originals, indent=2), encoding='utf-8') - # Force every copied file and directory before touching legacy authority. - for path in sorted(target.rglob('*'), key=lambda p: len(p.parts), reverse=True): - descriptor = os.open(path, os.O_RDONLY) - try: - os.fsync(descriptor) - finally: - os.close(descriptor) - archive = parse_document((root / 'ownership/source.json').read_text(encoding='utf-8')) - for name, text in archive.items(): - path = root / name - temporary = path.with_name(path.name + '.rollback-next') - with temporary.open('xb') as handle: - handle.write(text.encode('utf-8')) - handle.flush() - os.fsync(handle.fileno()) - os.replace(temporary, path) - descriptor = os.open(path.parent, os.O_RDONLY) - try: - os.fsync(descriptor) - finally: - os.close(descriptor) - result.update(applied=True, backup=str(target)) - return result - - -def main(): - parser = argparse.ArgumentParser(description=__doc__) - parser.add_argument('data', type=Path) - parser.add_argument('--apply', action='store_true') - parser.add_argument('--backup', type=Path) - arguments = parser.parse_args() - print(json.dumps(rollback(arguments.data, arguments.apply, arguments.backup), indent=2)) - - -if __name__ == '__main__': - main() diff --git a/tools/husbandry-ownership-rollback.py b/tools/husbandry-ownership-rollback.py new file mode 100644 index 0000000..b22e66c --- /dev/null +++ b/tools/husbandry-ownership-rollback.py @@ -0,0 +1,65 @@ +#!/usr/bin/env python3 +"""Preview an offline ownership rollback; --apply preserves a consistent current SQLite backup first.""" +import argparse +import json +import os +from pathlib import Path +import sqlite3 +import time + + +def rollback(path, apply=False, backup=None): + path = Path(path).resolve() + uri = path.as_uri() + ('?mode=rw' if apply else '?mode=ro') + with sqlite3.connect(uri, uri=True) as database: + database.execute('PRAGMA foreign_keys=ON') + if database.execute('PRAGMA integrity_check').fetchone() != ('ok',): + raise ValueError('SQLite integrity check failed') + columns = [row[1] for row in database.execute('PRAGMA table_info(husbandry_owner_archive)')] + if not columns: + raise ValueError('No immutable husbandry owner archive exists') + current_columns = {row[1] for row in database.execute('PRAGMA table_info(owners)')} + if not set(columns) <= current_columns: + raise ValueError('Legacy owner schema conflicts with archive; review by hand') + result = { + 'archived_slots': database.execute('SELECT COUNT(*) FROM husbandry_owner_archive').fetchone()[0], + 'restorable_slots': database.execute('SELECT COUNT(*) FROM husbandry_owner_archive o JOIN animals a ON a.uuid=o.animal_uuid').fetchone()[0], + 'current_character_slots': database.execute('SELECT COUNT(*) FROM character_owners').fetchone()[0], + 'held_legacy_slots': database.execute("SELECT COUNT(*) FROM husbandry_owner_plan WHERE status='held'").fetchone()[0], + 'changed_resolved_slots': database.execute('''SELECT COUNT(*) FROM husbandry_owner_plan p + WHERE p.status='resolved' AND NOT EXISTS (SELECT 1 FROM character_owners c + WHERE c.animal_uuid=p.animal_uuid AND c.account_uuid=p.account_uuid + AND c.character_uuid=p.character_uuid AND c.role=p.role)''').fetchone()[0], + 'applied': False, + } + if not apply: + return result + target = Path(backup) if backup else path.with_name(path.name + '.before-owner-rollback-' + str(time.time_ns())) + # Never overwrite the only recovery image. SQLite backup includes committed WAL pages. + descriptor = os.open(target, os.O_CREAT | os.O_EXCL | os.O_WRONLY, 0o600) + os.close(descriptor) + with sqlite3.connect(target) as copy: + database.backup(copy) + if copy.execute('PRAGMA integrity_check').fetchone() != ('ok',): + raise ValueError('Current-state backup verification failed') + quoted = ', '.join('"' + column.replace('"', '""') + '"' for column in columns) + selected = ', '.join('o."' + column.replace('"', '""') + '"' for column in columns) + database.execute('BEGIN IMMEDIATE') + database.execute('DELETE FROM owners') + database.execute(f'INSERT INTO owners ({quoted}) SELECT {selected} FROM husbandry_owner_archive o JOIN animals a ON a.uuid=o.animal_uuid') + database.commit() + result.update(applied=True, backup=str(target)) + return result + + +def main(): + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument('database', type=Path) + parser.add_argument('--apply', action='store_true', help='Restore account owner slots for old Cooking while the server is stopped') + parser.add_argument('--backup', type=Path, help='New path for the current-state SQLite recovery image') + arguments = parser.parse_args() + print(json.dumps(rollback(arguments.database, arguments.apply, arguments.backup), indent=2)) + + +if __name__ == '__main__': + main() diff --git a/tools/test_animal_ownership_rollback.py b/tools/test_animal_ownership_rollback.py deleted file mode 100644 index c75f17b..0000000 --- a/tools/test_animal_ownership_rollback.py +++ /dev/null @@ -1,119 +0,0 @@ -import importlib.util -import json -from pathlib import Path -import tempfile -import unittest -import subprocess -import sys - -spec = importlib.util.spec_from_file_location('rollback', Path(__file__).with_name('animal-ownership-rollback.py')) -module = importlib.util.module_from_spec(spec) -spec.loader.exec_module(module) - - -class RollbackTest(unittest.TestCase): - def setUp(self): - self.temporary = tempfile.TemporaryDirectory() - self.addCleanup(self.temporary.cleanup) - self.root = Path(self.temporary.name) / 'Data' - for folder in ['ownership/journals', 'PlayerData', 'CharacterData']: - (self.root / folder).mkdir(parents=True, exist_ok=True) - self.row = {'uuid': 'animal', 'future': {'kept': None}, 'characterOwners': [{'account': 'account', 'character': 'character'}]} - self.legacy = json.dumps({'animals': [{'uuid': 'animal', 'future': None, 'ownersUuids': ['account']}]}) - (self.root / 'PlayerData/account.json').write_text(self.legacy) - (self.root / 'ownership/source.json').write_text(json.dumps({'PlayerData/account.json': self.legacy})) - (self.root / 'ownership/migration.json').write_text(json.dumps({'canonical': {'animal': self.row}, 'held': {}})) - (self.root / 'CharacterData/character.json').write_text(json.dumps({'animals': [self.row]})) - (self.root / 'character-unowned.json').write_text(json.dumps({'animals': []})) - - def test_preview_is_read_only_apply_preserves_every_byte_and_refuses_backup_overwrite(self): - before = {p.relative_to(self.root): p.read_bytes() for p in self.root.rglob('*') if p.is_file()} - self.assertEqual([], module.rollback(self.root)['conflicts']) - self.assertEqual(before, {p.relative_to(self.root): p.read_bytes() for p in self.root.rglob('*') if p.is_file()}) - backup = Path(self.temporary.name) / 'recovery' - self.assertTrue(module.rollback(self.root, True, backup)['applied']) - self.assertEqual(before, {p.relative_to(backup / 'data'): p.read_bytes() for p in (backup / 'data').rglob('*') if p.is_file()}) - with self.assertRaises(FileExistsError): - module.rollback(self.root, True, backup) - with self.assertRaises(ValueError): - module.rollback(self.root, True) - with self.assertRaises(ValueError): - module.rollback(self.root, True, self.root / 'backup') - - def test_progress_death_grants_and_conflicting_fanout_refuse_apply_without_rewriting(self): - changed = dict(self.row, fed=True) - (self.root / 'CharacterData/character.json').write_text(json.dumps({'animals': [changed]})) - (self.root / 'CharacterData/other.json').write_text(json.dumps({'animals': [self.row]})) - result = module.rollback(self.root) - self.assertIn('conflicting fanout: animal', result['conflicts']) - self.assertEqual(['animal'], result['changed_animals']) - with self.assertRaises(ValueError): - module.rollback(self.root, True, Path(self.temporary.name) / 'refused') - self.assertTrue(json.loads((self.root / 'CharacterData/character.json').read_text())['animals'][0]['fed']) - self.assertFalse((Path(self.temporary.name) / 'refused').exists()) - (self.root / 'CharacterData/other.json').unlink() - (self.root / 'CharacterData/character.json').write_text(json.dumps({'animals': []})) - self.assertEqual(['animal'], module.rollback(self.root)['changed_animals']) - - def test_utf8_crlf_sources_compare_and_restore_as_exact_bytes_without_resetting_evidence(self): - text = '{\r\n "animals": [], "name": "Éléphant 水", "future": null\r\n}\r\n' - source = self.root / 'PlayerData/account.json' - source.write_bytes(text.encode('utf-8')) - (self.root / 'ownership/source.json').write_text(json.dumps({'PlayerData/account.json': text}, ensure_ascii=False), encoding='utf-8') - (self.root / 'ownership/journals/estate.json').write_text('{"done": true, "estate": {"kept": null}}', encoding='utf-8') - (self.root / 'ownership/token-effects').mkdir() - (self.root / 'ownership/token-effects/completed.json').write_text('{"status": "done", "ownerJournal": "estate"}', encoding='utf-8') - before = {p.relative_to(self.root): p.read_bytes() for p in self.root.rglob('*') if p.is_file()} - self.assertEqual([], module.rollback(self.root)['conflicts']) - result = module.rollback(self.root, True, Path(self.temporary.name) / 'utf8-recovery') - self.assertTrue(result['applied']) - self.assertEqual(before, {p.relative_to(self.root): p.read_bytes() for p in self.root.rglob('*') if p.is_file()}) - self.assertEqual(before, {p.relative_to(Path(result['backup']) / 'data'): p.read_bytes() for p in (Path(result['backup']) / 'data').rglob('*') if p.is_file()}) - - def test_json_simple_escapes_raw_utf8_and_cli_name_the_actual_rollback_contract(self): - text = '{\r\n "animals": [], "path": "stable/farm", "name": "Éléphant 水 €", "future": null\r\n}\r\n' - (self.root / 'PlayerData/account.json').write_bytes(text.encode('utf-8')) - archive = json.dumps({'PlayerData/account.json': text}, ensure_ascii=False).replace('/', '\\/').replace('€', '\\u20ac') - (self.root / 'ownership/source.json').write_bytes(archive.encode('utf-8')) - self.assertIn(b'\\r\\n', archive.encode('utf-8')) - self.assertIn(b'\\/', archive.encode('utf-8')) - self.assertIn(b'\\u20ac', archive.encode('utf-8')) - self.assertIn('水'.encode('utf-8'), archive.encode('utf-8')) - before = {p.relative_to(self.root): p.read_bytes() for p in self.root.rglob('*') if p.is_file()} - self.assertEqual([], module.rollback(self.root)['conflicts']) - result = json.loads(subprocess.check_output([sys.executable, str(Path(module.__file__)), str(self.root), '--apply', '--backup', str(Path(self.temporary.name) / 'escaped-recovery')], text=True)) - self.assertTrue(result['applied']) - self.assertIn('d6fa922', result['target']) - self.assertIn('no published JAR', result['target']) - self.assertIn('does not migrate again', result['character_restart']) - self.assertIn('refuses to open until staff review', result['after_legacy_writes']) - self.assertEqual(['character data', 'journals', 'receipts'], result['preserved']) - self.assertEqual(before, {p.relative_to(self.root): p.read_bytes() for p in self.root.rglob('*') if p.is_file()}) - self.assertEqual(before, {p.relative_to(Path(result['backup']) / 'data'): p.read_bytes() for p in (Path(result['backup']) / 'data').rglob('*') if p.is_file()}) - help_text = ' '.join(subprocess.check_output([sys.executable, str(Path(module.__file__)), '--help'], text=True).split()) - self.assertIn('pre-character', help_text) - self.assertIn('d6fa922', help_text) - self.assertIn('does not migrate again', help_text) - self.assertIn('staff review', help_text) - - def test_nested_duplicate_keys_refuse_preview_and_apply_without_writing(self): - path = self.root / 'CharacterData/character.json' - path.write_text('{"animals": [{"uuid": "animal", "future": {"kept": null, "kept": 1}}]}') - original = path.read_bytes() - for apply in [False, True]: - with self.assertRaises(ValueError): - module.rollback(self.root, apply, Path(self.temporary.name) / 'refused') - self.assertEqual(original, path.read_bytes()) - self.assertFalse((Path(self.temporary.name) / 'refused').exists()) - - def test_pending_journal_and_legacy_changes_refuse_and_unknown_source_paths_are_rejected(self): - (self.root / 'ownership/journals/pending.json').write_text(json.dumps({'done': False})) - (self.root / 'PlayerData/account.json').write_text('{}') - (self.root / 'PlayerData/new.json').write_text('{}') - result = module.rollback(self.root) - self.assertEqual(3, len(result['conflicts'])) - with self.assertRaises(ValueError): - module.rollback(self.root, True, Path(self.temporary.name) / 'refused') - (self.root / 'ownership/source.json').write_text(json.dumps({'../outside': '{}'})) - with self.assertRaises(ValueError): - module.rollback(self.root) diff --git a/tools/test_husbandry_ownership_rollback.py b/tools/test_husbandry_ownership_rollback.py new file mode 100644 index 0000000..4f76686 --- /dev/null +++ b/tools/test_husbandry_ownership_rollback.py @@ -0,0 +1,98 @@ +import importlib.util +import os +from pathlib import Path +import sqlite3 +import tempfile +import unittest + +spec = importlib.util.spec_from_file_location('owner_rollback', Path(__file__).with_name('husbandry-ownership-rollback.py')) +rollback = importlib.util.module_from_spec(spec) +spec.loader.exec_module(rollback) + + +class OwnershipRollbackTest(unittest.TestCase): + def setUp(self): + self.temporary = tempfile.TemporaryDirectory() + self.path = Path(self.temporary.name) / 'husbandry.db' + self.database = sqlite3.connect(self.path) + self.database.executescript(''' + PRAGMA journal_mode=WAL; + CREATE TABLE animals (uuid TEXT PRIMARY KEY, care INTEGER, snapshot BLOB); + CREATE TABLE owners (animal_uuid TEXT, player_uuid TEXT, role TEXT, unknown TEXT, + FOREIGN KEY(animal_uuid) REFERENCES animals(uuid)); + INSERT INTO animals VALUES ('animal', 71, X'0001FF'); + INSERT INTO owners VALUES ('animal','account','owner',NULL); + CREATE TABLE husbandry_owner_archive AS SELECT * FROM owners; + INSERT INTO husbandry_owner_archive VALUES ('deleted-animal','account','coowner','original'); + CREATE TABLE character_owners (animal_uuid TEXT, account_uuid TEXT, character_uuid TEXT, role TEXT); + INSERT INTO character_owners VALUES ('animal','heir-account','heir','owner'); + CREATE TABLE husbandry_owner_plan (animal_uuid TEXT, account_uuid TEXT, character_uuid TEXT, role TEXT, status TEXT); + INSERT INTO husbandry_owner_plan VALUES ('animal','account','original-character','owner','resolved'); + INSERT INTO husbandry_owner_plan VALUES ('deleted-animal','account',NULL,'coowner','held'); + ''') + self.database.commit() + + def tearDown(self): + self.database.close() + self.temporary.cleanup() + + def test_preview_retains_every_current_row(self): + result = rollback.rollback(self.path) + self.assertEqual(2, result['archived_slots']) + self.assertEqual(1, result['restorable_slots']) + self.assertEqual(1, result['changed_resolved_slots']) + self.assertFalse(result['applied']) + self.assertEqual(('heir-account',), self.database.execute('SELECT account_uuid FROM character_owners').fetchone()) + self.assertEqual(71, self.database.execute('SELECT care FROM animals').fetchone()[0]) + + def test_apply_preserves_verified_wal_backup_progress_and_null_fields(self): + backup = self.path.with_name('recover.db') + result = rollback.rollback(self.path, True, backup) + self.assertTrue(result['applied']) + self.assertEqual([('animal','account','owner',None)], self.database.execute('SELECT * FROM owners').fetchall()) + self.assertEqual((71,b'\x00\x01\xff'), self.database.execute('SELECT care,snapshot FROM animals').fetchone()) + with sqlite3.connect(backup) as recovered: + self.assertEqual(('heir-account',), recovered.execute('SELECT account_uuid FROM character_owners').fetchone()) + self.assertEqual(('ok',), recovered.execute('PRAGMA integrity_check').fetchone()) + with self.assertRaises(FileExistsError): + rollback.rollback(self.path, True, backup) + self.assertEqual(1, self.database.execute('SELECT COUNT(*) FROM animals').fetchone()[0]) + + def test_missing_archive_and_changed_legacy_schema_are_refused(self): + self.database.execute('ALTER TABLE owners DROP COLUMN unknown') + self.database.commit() + with self.assertRaisesRegex(ValueError, 'schema conflicts'): + rollback.rollback(self.path) + self.database.execute('DROP TABLE husbandry_owner_archive') + self.database.commit() + with self.assertRaisesRegex(ValueError, 'No immutable'): + rollback.rollback(self.path) + + def test_backup_stays_private_with_permissive_umask_and_never_changes_existing_modes(self): + self.path.chmod(0o600) + self.database.execute('UPDATE animals SET care=93') + self.database.commit() # Committed WAL content must reach the restricted recovery image. + backup = self.path.with_name('private-recovery.db') + previous = os.umask(0) + try: + result = rollback.rollback(self.path, True, backup) + finally: + os.umask(previous) + self.assertTrue(result['applied']) + self.assertEqual(0o600, backup.stat().st_mode & 0o777) + self.assertEqual(0o600, self.path.stat().st_mode & 0o777) + with sqlite3.connect(backup) as recovered: + self.assertEqual((93, b'\x00\x01\xff'), recovered.execute('SELECT care,snapshot FROM animals').fetchone()) + self.assertEqual(('ok',), recovered.execute('PRAGMA integrity_check').fetchone()) + existing = self.path.with_name('existing.db') + existing.write_bytes(b'original recovery image') + existing.chmod(0o640) + with self.assertRaises(FileExistsError): + rollback.rollback(self.path, True, existing) + self.assertEqual(b'original recovery image', existing.read_bytes()) + self.assertEqual(0o640, existing.stat().st_mode & 0o777) + self.assertEqual(0o600, self.path.stat().st_mode & 0o777) + + +if __name__ == '__main__': + unittest.main()