diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml
index 846523f..818f879 100644
--- a/.github/workflows/build.yml
+++ b/.github/workflows/build.yml
@@ -21,6 +21,10 @@ jobs:
with:
distribution: temurin
java-version: '21'
+ - name: Install shared plugin dependencies
+ uses: TF-Minecraft/TLibs/.github/actions/setup-plugins@8ef41de589f121c98ff892b10696e7c1feb4ca74
+ with:
+ mode: pinned
- name: Prepare pinned dependencies
env:
GH_TOKEN: ${{ secrets.DEPS_TOKEN }}
diff --git a/.github/workflows/maven-release.yml b/.github/workflows/maven-release.yml
index 159ccf6..63bb665 100644
--- a/.github/workflows/maven-release.yml
+++ b/.github/workflows/maven-release.yml
@@ -46,6 +46,10 @@ jobs:
}
[[ "${TAG^^}" != *SNAPSHOT* ]] || exit 1
+ - name: Install shared plugin dependencies
+ uses: TF-Minecraft/TLibs/.github/actions/setup-plugins@8ef41de589f121c98ff892b10696e7c1feb4ca74
+ with:
+ mode: pinned
- name: Prepare pinned dependencies
env:
GH_TOKEN: ${{ secrets.DEPS_TOKEN }}
@@ -75,7 +79,8 @@ jobs:
run: |
python3 .github/scripts/plugin-artifact.py \
--jar "$ARTIFACT_PATH" --version "${TAG#v}" \
- --stage "$RUNNER_TEMP/plugin-release"
+ --stage "$RUNNER_TEMP/plugin-release" \
+ --dependencies .build/plugin-dependencies.json
- uses: actions/upload-artifact@v7
with:
diff --git a/pom.xml b/pom.xml
index dbefb81..0d7e89c 100644
--- a/pom.xml
+++ b/pom.xml
@@ -12,12 +12,17 @@
https://github.com/TF-Minecraft/BreedingBuddies
+ 2.6.0
21
UTF-8
+
+ me.pluginstlibs${tlibs.version}provided
+ **
+
io.papermc.paper
paper-api
diff --git a/src/main/java/net/tfminecraft/breedingbuddies/AnimalCodec.java b/src/main/java/net/tfminecraft/breedingbuddies/AnimalCodec.java
new file mode 100644
index 0000000..e8d418e
--- /dev/null
+++ b/src/main/java/net/tfminecraft/breedingbuddies/AnimalCodec.java
@@ -0,0 +1,37 @@
+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
new file mode 100644
index 0000000..4f1040c
--- /dev/null
+++ b/src/main/java/net/tfminecraft/breedingbuddies/AnimalEstates.java
@@ -0,0 +1,117 @@
+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
new file mode 100644
index 0000000..113d50d
--- /dev/null
+++ b/src/main/java/net/tfminecraft/breedingbuddies/AnimalFiles.java
@@ -0,0 +1,52 @@
+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
new file mode 100644
index 0000000..3536f6e
--- /dev/null
+++ b/src/main/java/net/tfminecraft/breedingbuddies/AnimalHeirs.java
@@ -0,0 +1,8 @@
+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
new file mode 100644
index 0000000..746042c
--- /dev/null
+++ b/src/main/java/net/tfminecraft/breedingbuddies/AnimalIdentity.java
@@ -0,0 +1,51 @@
+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
index d38ab51..aa0a273 100644
--- a/src/main/java/net/tfminecraft/breedingbuddies/AnimalInventoryHolder.java
+++ b/src/main/java/net/tfminecraft/breedingbuddies/AnimalInventoryHolder.java
@@ -8,10 +8,17 @@
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);
}
diff --git a/src/main/java/net/tfminecraft/breedingbuddies/AnimalOwnershipStore.java b/src/main/java/net/tfminecraft/breedingbuddies/AnimalOwnershipStore.java
new file mode 100644
index 0000000..17bf871
--- /dev/null
+++ b/src/main/java/net/tfminecraft/breedingbuddies/AnimalOwnershipStore.java
@@ -0,0 +1,281 @@
+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
new file mode 100644
index 0000000..c4d9b91
--- /dev/null
+++ b/src/main/java/net/tfminecraft/breedingbuddies/AnimalReplay.java
@@ -0,0 +1,184 @@
+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/AnimalTokenIntent.java b/src/main/java/net/tfminecraft/breedingbuddies/AnimalTokenIntent.java
new file mode 100644
index 0000000..c8c3c3f
--- /dev/null
+++ b/src/main/java/net/tfminecraft/breedingbuddies/AnimalTokenIntent.java
@@ -0,0 +1,43 @@
+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/BreedingBuddies.java b/src/main/java/net/tfminecraft/breedingbuddies/BreedingBuddies.java
index 145e1e2..d05ea03 100644
--- a/src/main/java/net/tfminecraft/breedingbuddies/BreedingBuddies.java
+++ b/src/main/java/net/tfminecraft/breedingbuddies/BreedingBuddies.java
@@ -45,8 +45,9 @@ public class BreedingBuddies extends JavaPlugin implements TabCompleter {
@Override
public void onEnable() {
instance = this;
- PluginData.loadAllData();
createAndLoadConfigs();
+ PluginData.loadAllData();
+ AnimalIdentity.start(this);
registerListeners();
this.dayScheduler = new DayChangeScheduler(this, Numbers.startingDayHour, Numbers.startingDayMin);
this.dayScheduler.startScheduler();
diff --git a/src/main/java/net/tfminecraft/breedingbuddies/FarmAnimal.java b/src/main/java/net/tfminecraft/breedingbuddies/FarmAnimal.java
index 01750cf..06d32c4 100644
--- a/src/main/java/net/tfminecraft/breedingbuddies/FarmAnimal.java
+++ b/src/main/java/net/tfminecraft/breedingbuddies/FarmAnimal.java
@@ -19,6 +19,8 @@ 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;
@@ -114,6 +116,7 @@ public Set getOwnersUuids() {
}
public void setOwnersUuids (Set newOwners) {
this.ownersUuids = newOwners;
+ ownerRefs.keySet().retainAll(newOwners);
}
public int getFriendshipPoints() {
diff --git a/src/main/java/net/tfminecraft/breedingbuddies/OwnershipManager.java b/src/main/java/net/tfminecraft/breedingbuddies/OwnershipManager.java
index de87ae9..9e5d9d3 100644
--- a/src/main/java/net/tfminecraft/breedingbuddies/OwnershipManager.java
+++ b/src/main/java/net/tfminecraft/breedingbuddies/OwnershipManager.java
@@ -1,81 +1,52 @@
package net.tfminecraft.breedingbuddies;
+import net.tfminecraft.tlibs.character.CharacterRef;
import java.util.*;
public class OwnershipManager {
- private OwnershipManager() {
- }
-
- public static void registerOwnership (UUID playerUuid, FarmAnimal animal) {
- Set animals = PluginData.getPlayerAnimals().getOrDefault(playerUuid, new HashSet<>());
- if (!animals.contains(animal)) {
- animals.add(animal);
- PluginData.getPlayerAnimals().put(playerUuid, animals);
- animal.getOwnersUuids().add(playerUuid);
- }
- }
-
- public static void removeOwnership (UUID playerUuid, FarmAnimal animal) {
- Set animals = PluginData.getPlayerAnimals().get(playerUuid);
- if (animals != null) {
- animal.getOwnersUuids().remove(playerUuid);
- animals.remove(animal);
- PluginData.getPlayerAnimals().put(playerUuid, animals);
- }
- }
-
- public static void removeAnimal(UUID animalUuid) {
- PluginData.getPlayerAnimals().forEach((uuid, animals) -> {
- animals.removeIf(animal -> {
- if (animal.getUuid().equals(animalUuid)) {
- animal.getOwnersUuids().clear(); // limpia la lista de owners
- return true;
- }
- return false;
- });
- });
- }
-
- public static Set getAnimals(UUID playerUuid) {
- return PluginData.getPlayerAnimals().getOrDefault(playerUuid, new HashSet<>());
+ 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 boolean isOwner(UUID playerUuid, UUID animalUuid) {
- Set animals = PluginData.getPlayerAnimals().get(playerUuid);
- if (animals != null) {
- for (FarmAnimal animal : animals) {
- if (animal.getUuid().equals(animalUuid)) {
- return true;
- }
+ 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();
}
- }
- return false;
+ });
}
-
- public static boolean hasOwner(UUID animalUuid) {
- return PluginData.getPlayerAnimals().values().stream()
- .flatMap(Set::stream)
- .anyMatch(animal -> animal.getUuid().equals(animalUuid) && !animal.getOwnersUuids().isEmpty());
+ 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);
}
-
- public static FarmAnimal getAnimal(UUID animalUuid, UUID playerUuid) {
- Set animals = PluginData.getPlayerAnimals().get(playerUuid);
-
- if (animals != null) {
- for (FarmAnimal animal : animals) {
- if (animal.getUuid().equals(animalUuid)) {
- return animal;
- }
- }
- }
- return null;
- }
-
- public static FarmAnimal getAnimal(UUID animalUuid) {
- return PluginData.getPlayerAnimals().values().stream()
- .flatMap(Set::stream)
- .filter(animal -> animal.getUuid().equals(animalUuid))
- .findFirst()
- .orElse(null);
- }
}
diff --git a/src/main/java/net/tfminecraft/breedingbuddies/PluginData.java b/src/main/java/net/tfminecraft/breedingbuddies/PluginData.java
index 1c43d36..0692031 100644
--- a/src/main/java/net/tfminecraft/breedingbuddies/PluginData.java
+++ b/src/main/java/net/tfminecraft/breedingbuddies/PluginData.java
@@ -36,16 +36,14 @@ private PluginData() {
public static void saveAllData(boolean force) {
if (!force && DayChangeListener.isIsChangingDay()) return;
- PluginData.saveAllPlayerData();
- PluginData.saveUnownedAnimals();
+ PluginData.saveAnimals();
PluginData.saveStableChunks();
Bukkit.getLogger().info("[BreedingBuddies] Saving all data...");
}
public static void loadAllData() {
- loadUnownedAnimals();
loadStableChunks();
- loadAllPlayerData();
+ loadAnimals();
Bukkit.getLogger().info("[BreedingBuddies] Loading all data...");
}
@@ -124,275 +122,43 @@ public static void loadStableChunks() {
}
}
- public static void saveUnownedAnimals() {
- try {
- File file = new File(UNOWNED_ANIMALS_DATA_DIR);
- file.getParentFile().mkdirs(); // Ensure the directory exists
-
- JSONArray animalsJsonArray = new JSONArray();
-
- for (FarmAnimal animal : unownedAnimals.values()) {
- JSONObject animalJson = new JSONObject();
- animalJson.put("uuid", animal.getUuid().toString());
- animalJson.put("name", animal.getName());
- JSONArray ownersJsonArray = new JSONArray();
- for (UUID ownerUUID : animal.getOwnersUuids()) {
- ownersJsonArray.add(ownerUUID.toString());
- }
- animalJson.put("ownersUuids", ownersJsonArray);
- animalJson.put("stableNeeded", animal.isStableNeeded());
- animalJson.put("friendshipPoints", animal.getFriendshipPoints());
- animalJson.put("geneticPoints", animal.getGeneticPoints());
- animalJson.put("state", animal.getState().toString());
- animalJson.put("fed", animal.getFed());
- animalJson.put("cared", animal.getCared());
- animalJson.put("sleptInStable", animal.isSleptInStable());
- animalJson.put("spaceNeeded", animal.isSpaceNeeded());
- animalJson.put("waterNeeded", animal.isWaterNeeded());
- animalJson.put("daysOut", animal.getDaysOut());
-
- if (animal.getLastRewardCollected() != null) {
- animalJson.put("lastRewardCollected", animal.getLastRewardCollected().getTime());
- } else {
- animalJson.put("lastRewardCollected", null);
- }
-
- if (animal.getLastUpdated() != null) {
- animalJson.put("lastUpdated", animal.getLastUpdated().getTime());
- } else {
- animalJson.put("lastUpdated", null);
- }
-
- animalsJsonArray.add(animalJson);
- }
-
- JSONObject playerJson = new JSONObject();
- playerJson.put("animals", animalsJsonArray);
-
- FileWriter fileWriter = new FileWriter(file);
- fileWriter.write(playerJson.toJSONString());
- fileWriter.flush();
- fileWriter.close();
- } catch (Exception e) {
- e.printStackTrace();
- }
- }
-
-
- public static void loadUnownedAnimals() {
- try {
- File file = new File(UNOWNED_ANIMALS_DATA_DIR);
- if (!file.exists()) {
- return;
- }
- JSONParser parser = new JSONParser();
- JSONObject playerJson = (JSONObject) parser.parse(new FileReader(file));
- JSONArray animalsJsonArray = (JSONArray) playerJson.get("animals");
-
- for (Object animalObj : animalsJsonArray) {
- JSONObject animalJson = (JSONObject) animalObj;
-
- UUID animalUUID = UUID.fromString((String) animalJson.get("uuid"));
- String name = (String) animalJson.get("name");
- // Parsear ownersUuids de JSONArray a List
- JSONArray ownersJsonArray = (JSONArray) animalJson.get("ownersUuids");
- Set ownersUuids = new HashSet<>();
- for (Object ownerObj : ownersJsonArray) {
- ownersUuids.add(UUID.fromString((String) ownerObj));
- }
-
- // Construir el objeto FarmAnimal
- FarmAnimal farmAnimal = new FarmAnimal(animalUUID, name, ownersUuids);
-
- farmAnimal.setStableNeeded((boolean) animalJson.get("stableNeeded"));
- farmAnimal.setFriendshipPoints(((Long) animalJson.get("friendshipPoints")).intValue());
- farmAnimal.setGeneticPoints(((Long) animalJson.get("geneticPoints")).intValue());
- farmAnimal.setState(AnimalStates.valueOf((String) animalJson.get("state")));
- farmAnimal.setFed((boolean) animalJson.get("fed"));
- farmAnimal.setCared((boolean) animalJson.get("cared"));
- farmAnimal.setSleptInStable((boolean) animalJson.get("sleptInStable"));
- farmAnimal.setSpaceNeeded((boolean) animalJson.get("spaceNeeded"));
- farmAnimal.setWaterNeeded((boolean) animalJson.get("waterNeeded"));
- farmAnimal.setDaysOut(((Long) animalJson.get("daysOut")).intValue());
- if (animalJson.get("lastRewardCollected") != null) {
- farmAnimal.setLastRewardCollected(new Date((Long) animalJson.get("lastRewardCollected")));
- }
- if (animalJson.get("lastUpdated") != null) {
- farmAnimal.setLastUpdated(new Date((Long) animalJson.get("lastUpdated")));
- }
-
- unownedAnimals.put(animalUUID, farmAnimal);
- }
-
- } catch (Exception e) {
- e.printStackTrace();
- }
- }
-
- // Offline owners' animals change too (day changes, escapes, deaths), so every owner is written.
- static void saveAllPlayerData() {
- for (Map.Entry> entry : playerAnimals.entrySet()) {
- savePlayerData(entry.getKey(), entry.getValue());
- }
- }
-
- public static void savePlayerData(UUID playerUUID, Set animals) {
- try {
- File file = new File(PLAYER_DATA_DIR, playerUUID.toString() + ".json");
- file.getParentFile().mkdirs(); // Ensure the directory exists
-
- JSONArray animalsJsonArray = new JSONArray();
-
- for (FarmAnimal animal : animals) {
- JSONObject animalJson = new JSONObject();
- animalJson.put("uuid", animal.getUuid().toString());
- animalJson.put("name", animal.getName());
-
- Set uniqueOwners = new HashSet<>(animal.getOwnersUuids());
- JSONArray ownersJsonArray = new JSONArray();
- for (UUID ownerUUID : uniqueOwners) {
- ownersJsonArray.add(ownerUUID.toString());
- }
-
- animalJson.put("ownersUuids", ownersJsonArray);
- animalJson.put("stableNeeded", animal.isStableNeeded());
- animalJson.put("friendshipPoints", animal.getFriendshipPoints());
- animalJson.put("geneticPoints", animal.getGeneticPoints());
- animalJson.put("state", animal.getState().toString());
- animalJson.put("fed", animal.getFed());
- animalJson.put("cared", animal.getCared());
- animalJson.put("sleptInStable", animal.isSleptInStable());
- animalJson.put("spaceNeeded", animal.isSpaceNeeded());
- animalJson.put("waterNeeded", animal.isWaterNeeded());
- animalJson.put("daysOut", animal.getDaysOut());
-
- if (animal.getLastRewardCollected() != null) {
- animalJson.put("lastRewardCollected", animal.getLastRewardCollected().getTime());
- } else {
- animalJson.put("lastRewardCollected", null);
- }
-
- if (animal.getLastUpdated() != null) {
- animalJson.put("lastUpdated", animal.getLastUpdated().getTime());
- } else {
- animalJson.put("lastUpdated", null);
- }
-
- animalsJsonArray.add(animalJson);
- }
-
- JSONObject playerJson = new JSONObject();
- playerJson.put("animals", animalsJsonArray);
-
- FileWriter fileWriter = new FileWriter(file);
- fileWriter.write(playerJson.toJSONString());
- fileWriter.flush();
- fileWriter.close();
- } catch (Exception e) {
- e.printStackTrace();
- }
- }
-
- public static void loadPlayerData(UUID playerUUID, Map tempAnimalMap) {
- try {
- File file = new File(PLAYER_DATA_DIR, playerUUID.toString() + ".json");
- if (!file.exists()) {
- return;
- }
- JSONParser parser = new JSONParser();
- JSONObject playerJson = (JSONObject) parser.parse(new FileReader(file));
-
- JSONArray animalsJsonArray = (JSONArray) playerJson.get("animals");
- Set loadedAnimals = new HashSet<>();
-
- for (Object animalObj : animalsJsonArray) {
- JSONObject animalJson = (JSONObject) animalObj;
-
- UUID animalUUID = UUID.fromString((String) animalJson.get("uuid"));
-
- // Verificar si el animal ya ha sido cargado en esta sesión
- FarmAnimal farmAnimal = tempAnimalMap.get(animalUUID);
-
- if (farmAnimal == null) {
- String name = (String) animalJson.get("name");
-
- // Parsear ownersUuids de JSONArray a List
- JSONArray ownersJsonArray = (JSONArray) animalJson.get("ownersUuids");
- Set ownersUuids = new HashSet<>();
- for (Object ownerObj : ownersJsonArray) {
- ownersUuids.add(UUID.fromString((String) ownerObj));
- }
-
- // Construir el objeto FarmAnimal
- farmAnimal = new FarmAnimal(animalUUID, name, ownersUuids);
- farmAnimal.setStableNeeded((boolean) animalJson.get("stableNeeded"));
- farmAnimal.setFriendshipPoints(((Long) animalJson.get("friendshipPoints")).intValue());
- farmAnimal.setGeneticPoints(((Long) animalJson.get("geneticPoints")).intValue());
- farmAnimal.setState(AnimalStates.valueOf((String) animalJson.get("state")));
- farmAnimal.setFed((boolean) animalJson.get("fed"));
- farmAnimal.setCared((boolean) animalJson.get("cared"));
- farmAnimal.setSleptInStable((boolean) animalJson.get("sleptInStable"));
- farmAnimal.setSpaceNeeded((boolean) animalJson.get("spaceNeeded"));
- farmAnimal.setWaterNeeded((boolean) animalJson.get("waterNeeded"));
-
- farmAnimal.setDaysOut(((Long) animalJson.get("daysOut")).intValue());
- if (animalJson.get("lastRewardCollected") != null) {
- farmAnimal.setLastRewardCollected(new Date((Long) animalJson.get("lastRewardCollected")));
- }
-
- if (animalJson.get("lastUpdated") != null) {
- farmAnimal.setLastUpdated(new Date((Long) animalJson.get("lastUpdated")));
- }
-
- // Guardar la instancia única en el mapa temporal
- tempAnimalMap.put(animalUUID, farmAnimal);
- }
-
- // Añadir el animal cargado a la lista de animales del jugador actual
- loadedAnimals.add(farmAnimal);
-
- // Asegurarse de que el jugador actual está en la lista de propietarios
- if (!farmAnimal.getOwnersUuids().contains(playerUUID)) {
- farmAnimal.getOwnersUuids().add(playerUUID);
- }
- }
-
- // Agregar los animales cargados al mapa playerAnimals
- playerAnimals.put(playerUUID, loadedAnimals);
- } catch (Exception e) {
- e.printStackTrace();
- }
- }
-
- public static void loadAllPlayerData() {
- Map tempAnimalMap = new HashMap<>();
-
- // listFiles() is null when the folder is missing or unreadable.
- File[] playerFiles = new File(PLAYER_DATA_DIR).listFiles();
- if (playerFiles == null) {
- return;
- }
-
- // Recorre cada carpeta (cada jugador) dentro de la carpeta PlayerData
- for (File playerFolder : playerFiles) {
- if (playerFolder.isFile()) {
- String fileName = playerFolder.getName();
-
- // Verifica y elimina la extensión .json si está presente
- if (fileName.endsWith(".json")) {
- String uuidString = fileName.substring(0, fileName.length() - 5);
- try {
- UUID playerUUID = UUID.fromString(uuidString);
- // Llama a la función loadPlayerData con el UUID del jugador
- loadPlayerData(playerUUID, tempAnimalMap);
- } catch (IllegalArgumentException e) {
- // Maneja el caso en que el nombre de archivo no sea un UUID válido
- Bukkit.getLogger().warning("Invalid player data file: " + fileName);
- }
- }
- }
- }
- }
+ 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;
@@ -405,4 +171,4 @@ public static Map> getPlayerAnimals() {
public static ArrayList getChunkAreas() {
return chunkAreas;
}
-}
\ No newline at end of file
+}
diff --git a/src/main/java/net/tfminecraft/breedingbuddies/listeners/AnimalCareListener.java b/src/main/java/net/tfminecraft/breedingbuddies/listeners/AnimalCareListener.java
index b929d22..93b3c41 100644
--- a/src/main/java/net/tfminecraft/breedingbuddies/listeners/AnimalCareListener.java
+++ b/src/main/java/net/tfminecraft/breedingbuddies/listeners/AnimalCareListener.java
@@ -7,6 +7,8 @@
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;
@@ -35,7 +37,8 @@ public void onInteract(PlayerInteractEntityEvent event) {
care(animal, entity, player, itemInHand);
event.setCancelled(true);
}
- } else if (OwnershipManager.hasOwner(entity.getUniqueId())
+ } 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);
diff --git a/src/main/java/net/tfminecraft/breedingbuddies/listeners/AnimalDeathListener.java b/src/main/java/net/tfminecraft/breedingbuddies/listeners/AnimalDeathListener.java
index ecc076b..fea857a 100644
--- a/src/main/java/net/tfminecraft/breedingbuddies/listeners/AnimalDeathListener.java
+++ b/src/main/java/net/tfminecraft/breedingbuddies/listeners/AnimalDeathListener.java
@@ -13,8 +13,8 @@ public class AnimalDeathListener implements Listener {
public void onAnimalDeath(EntityDeathEvent event) {
if (event.getEntity() instanceof Animals) {
Entity animal = event.getEntity();
- OwnershipManager.removeAnimal(animal.getUniqueId());
UnownedAnimalsManager.removeUnownedAnimal(animal.getUniqueId());
+ OwnershipManager.removeAnimal(animal.getUniqueId());
}
}
}
diff --git a/src/main/java/net/tfminecraft/breedingbuddies/listeners/BreedingListener.java b/src/main/java/net/tfminecraft/breedingbuddies/listeners/BreedingListener.java
index 1d23ca6..8e89f72 100644
--- a/src/main/java/net/tfminecraft/breedingbuddies/listeners/BreedingListener.java
+++ b/src/main/java/net/tfminecraft/breedingbuddies/listeners/BreedingListener.java
@@ -22,6 +22,10 @@ public void onEntityBreed(EntityBreedEvent event) {
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());
@@ -43,6 +47,7 @@ public void onEntityBreed(EntityBreedEvent event) {
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,
@@ -56,7 +61,7 @@ public void onEntityBreed(EntityBreedEvent event) {
} else {
specialBreed(mother, father, childEntity);
}
- } else if (parentsHaveOwner(fatherEntity, motherEntity) && !playerIsOwner) {
+ } else if (tracked && parentsHaveOwner(fatherEntity, motherEntity) && !playerIsOwner) {
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
index c639f11..3ccd236 100644
--- a/src/main/java/net/tfminecraft/breedingbuddies/listeners/DayChangeListener.java
+++ b/src/main/java/net/tfminecraft/breedingbuddies/listeners/DayChangeListener.java
@@ -35,6 +35,7 @@ public void onDayChange(DayChangeEvent event) {
}
public static void globalChangeDay(UUID excludeUuid, int daysChecked) {
+ if (!PluginData.ownershipReady()) return;
List chunkAreas = PluginData.getChunkAreas();
Set processed = new HashSet<>();
@@ -144,7 +145,7 @@ private static void logAnimalError(Entity entity, Exception ex) {
// Chunk loading and entity reads must run on the server thread.
private static void scheduleGlobalChangeDay(UUID excludedEntity, int daysChecked) {
- if (isChangingDay) return;
+ if (isChangingDay || !PluginData.ownershipReady()) return;
isChangingDay = true;
Bukkit.getScheduler().runTask(BreedingBuddies.getInstance(), () -> runGlobalChangeDay(excludedEntity, daysChecked));
}
@@ -185,6 +186,7 @@ private static void IndividualChangeDay(Entity entity, FarmAnimal animal, int da
}
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);
@@ -192,7 +194,7 @@ public static void changeDayOnInteract(Entity entity, FarmAnimal animal) {
}
public static void changeDayOnReport() {
- if (isChangingDay) return;
+ if (isChangingDay || !PluginData.ownershipReady()) return;
isChangingDay = true;
try {
int daysChecked = daysBetween(getLastChangeDate(), LocalDateTime.now(ZoneOffset.UTC));
diff --git a/src/main/java/net/tfminecraft/breedingbuddies/listeners/InventoryManager.java b/src/main/java/net/tfminecraft/breedingbuddies/listeners/InventoryManager.java
index c34bb49..6af5c70 100644
--- a/src/main/java/net/tfminecraft/breedingbuddies/listeners/InventoryManager.java
+++ b/src/main/java/net/tfminecraft/breedingbuddies/listeners/InventoryManager.java
@@ -73,13 +73,14 @@ public void onPlayerInteractEntity(PlayerInteractEntityEvent event) {
}
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).getInventory();
+ 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));
@@ -87,7 +88,7 @@ public void openAnimalMenu(Player player, FarmAnimal farmAnimal, Entity entity)
inv.setItem(5, createItem(Material.TOTEM_OF_UNDYING, ChatColor.GOLD + "State: " + animalStatus.toString()));
Set owners = farmAnimal.getOwnersUuids();
- if (owners.contains(player.getUniqueId()))
+ 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
@@ -115,6 +116,7 @@ public void openAnimalMenu(Player player, FarmAnimal farmAnimal, Entity entity)
}
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();
@@ -123,7 +125,7 @@ public void openMountMenu(Player player, LivingEntity entity, FarmAnimal farmAni
int pointsPerHeart = maxPoints / 5;
UUID entityUUID = entity.getUniqueId();
- Inventory inv = new AnimalInventoryHolder(entityUUID, 36, ChatColor.GOLD + Messages.inventoryName).getInventory();
+ 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));
@@ -132,7 +134,7 @@ public void openMountMenu(Player player, LivingEntity entity, FarmAnimal farmAni
inv.setItem(8, createItem(Material.SHEARS, ChatColor.GOLD + "Neutered: " + MountUtils.isNeutered(entity)));
Set owners = farmAnimal.getOwnersUuids();
- if (owners.contains(player.getUniqueId()))
+ 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)
@@ -176,6 +178,7 @@ public void onInventoryClick(InventoryClickEvent event) {
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);
diff --git a/src/main/java/net/tfminecraft/breedingbuddies/listeners/MountListener.java b/src/main/java/net/tfminecraft/breedingbuddies/listeners/MountListener.java
index 15e6713..fd9b594 100644
--- a/src/main/java/net/tfminecraft/breedingbuddies/listeners/MountListener.java
+++ b/src/main/java/net/tfminecraft/breedingbuddies/listeners/MountListener.java
@@ -28,9 +28,9 @@ public void onMount(EntityMountEvent event) {
}
Player player = (Player) rider;
- if (entity instanceof Animals) {
+ 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 (OwnershipManager.hasOwner(entity.getUniqueId()) && !OwnershipManager.isOwner(player.getUniqueId(), entity.getUniqueId())) {
+ if (AnimalIdentity.session(player).isEmpty() || (OwnershipManager.hasOwner(entity.getUniqueId()) && !OwnershipManager.isOwner(player.getUniqueId(), entity.getUniqueId()))) {
SoundManager.playAngryCowSound(entity);
event.setCancelled(true);
}
@@ -42,10 +42,11 @@ 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 (OwnershipManager.isOwner(player.getUniqueId(), entity.getUniqueId()) || player.hasPermission("breedingbuddies.use")) {
+ 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());
@@ -68,7 +69,7 @@ public void onInteractFix(PlayerInteractEntityEvent event) {
if(event.getHand().equals(EquipmentSlot.HAND)) {
FarmAnimal animal = OwnershipManager.getAnimal(entity.getUniqueId());
- if (MountUtils.isFullStatMount(entity) && animal != null) {
+ if (MountUtils.isFullStatMount(entity) && animal != null && OwnershipManager.isOwner(event.getPlayer().getUniqueId(), entity.getUniqueId())) {
animal.setStableNeeded(false);
}
}
diff --git a/src/main/java/net/tfminecraft/breedingbuddies/listeners/TamingListener.java b/src/main/java/net/tfminecraft/breedingbuddies/listeners/TamingListener.java
index 8e446af..cb218b4 100644
--- a/src/main/java/net/tfminecraft/breedingbuddies/listeners/TamingListener.java
+++ b/src/main/java/net/tfminecraft/breedingbuddies/listeners/TamingListener.java
@@ -83,6 +83,7 @@ public void onInteract(PlayerInteractEntityEvent event) {
}
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);
@@ -90,21 +91,21 @@ private void handleEmptyInteraction(Player player, ItemStack itemInHand) {
}
UUID uuid = UUID.fromString(entityUuid);
FarmAnimal animal = OwnershipManager.getAnimal(uuid);
- if (animal == null) {
+ 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 {
- OwnershipManager.registerOwnership(player.getUniqueId(), animal);
+ if (!acquire(player, player, animal, itemInHand, () -> {})) return;
SoundManager.playAmethystClusterStepSound(player);
ParticleManager.cherryParticles(player);
- itemInHand.setAmount(itemInHand.getAmount() - 1);
}
}
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);
@@ -112,22 +113,24 @@ private void handlePlayerInteraction(Player player, Entity targetPlayer, ItemSta
}
UUID uuid = UUID.fromString(entityUuid);
FarmAnimal animal = OwnershipManager.getAnimal(uuid);
- if (animal == null) {
+ 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 {
- OwnershipManager.registerOwnership(targetPlayer.getUniqueId(), animal);
+ 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);
- itemInHand.setAmount(itemInHand.getAmount() - 1);
}
}
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);
@@ -205,36 +208,45 @@ private void processTamingItem(Player player, Entity entity, ItemStack itemInHan
} else if (hasOwner) {
player.sendMessage(Messages.notOwner);
} else {
- if (name(entity, itemInHand)) {
- tame(player, entity);
- player.sendMessage(String.format(Messages.nowOwner, entity.getCustomName()));
- SoundManager.playAmethystClusterStepSound(entity);
- ParticleManager.cherryParticles(entity);
- } else {
- player.sendMessage(Messages.nameFirst);
- }
- }
- }
-
- public void tame(Player player, Entity entity) {
- FarmAnimal newAnimal;
- if (UnownedAnimalsManager.isUnowned(entity.getUniqueId()) && UnownedAnimalsManager.getAnimal(entity.getUniqueId()).getState() == AnimalStates.SPAWNED) {
- FarmAnimal oldAnimal = UnownedAnimalsManager.getAnimal(entity.getUniqueId());
- int friendships = oldAnimal.getFriendshipPoints();
- int genetics = oldAnimal.getGeneticPoints();
- UnownedAnimalsManager.removeUnownedAnimal(entity.getUniqueId());
- newAnimal = new FarmAnimal(entity.getUniqueId(), entity.getCustomName(), player.getUniqueId(), entity, genetics, friendships);
- newAnimal.setName(entity.getCustomName());
- } else if (UnownedAnimalsManager.isUnowned(entity.getUniqueId())) {
- int genetics = UnownedAnimalsManager.getAnimal(entity.getUniqueId()).getGeneticPoints();
- UnownedAnimalsManager.removeUnownedAnimal(entity.getUniqueId());
- newAnimal = new FarmAnimal(entity.getUniqueId(), entity.getCustomName(), player.getUniqueId(), entity, genetics);
- } else {
- newAnimal = new FarmAnimal(entity.getUniqueId(), entity.getCustomName(), player.getUniqueId(), entity);
- }
- OwnershipManager.registerOwnership(player.getUniqueId(), newAnimal);
- newAnimal.setState(AnimalStates.HAPPY);
- }
+ 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);
diff --git a/src/main/resources/plugin.yml b/src/main/resources/plugin.yml
index 489a138..d5c841e 100644
--- a/src/main/resources/plugin.yml
+++ b/src/main/resources/plugin.yml
@@ -3,7 +3,7 @@ api-version: '1.21.10'
name: BreedingBuddies
version: ${project.version}
author: Nowko
-depend: [MMOItems, MythicLib]
+depend: [MMOItems, MythicLib, TLibs]
commands:
breedingbuddies:
description: Main command for the BreedingBuddies plugin.
diff --git a/src/test/java/net/tfminecraft/breedingbuddies/AnimalCharacterAuthorityTest.java b/src/test/java/net/tfminecraft/breedingbuddies/AnimalCharacterAuthorityTest.java
new file mode 100644
index 0000000..8233f57
--- /dev/null
+++ b/src/test/java/net/tfminecraft/breedingbuddies/AnimalCharacterAuthorityTest.java
@@ -0,0 +1,86 @@
+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
new file mode 100644
index 0000000..e5243d8
--- /dev/null
+++ b/src/test/java/net/tfminecraft/breedingbuddies/AnimalEstatesTest.java
@@ -0,0 +1,179 @@
+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
new file mode 100644
index 0000000..9adffc4
--- /dev/null
+++ b/src/test/java/net/tfminecraft/breedingbuddies/AnimalJournalLifecycleTest.java
@@ -0,0 +1,120 @@
+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
new file mode 100644
index 0000000..96577ce
--- /dev/null
+++ b/src/test/java/net/tfminecraft/breedingbuddies/AnimalOwnershipStoreTest.java
@@ -0,0 +1,166 @@
+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
new file mode 100644
index 0000000..0795a27
--- /dev/null
+++ b/src/test/java/net/tfminecraft/breedingbuddies/AnimalReplayTest.java
@@ -0,0 +1,97 @@
+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
new file mode 100644
index 0000000..510931b
--- /dev/null
+++ b/src/test/java/net/tfminecraft/breedingbuddies/AnimalTokenIntentTest.java
@@ -0,0 +1,75 @@
+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/BreedingBuddiesTest.java b/src/test/java/net/tfminecraft/breedingbuddies/BreedingBuddiesTest.java
index 349b7af..48cea49 100644
--- a/src/test/java/net/tfminecraft/breedingbuddies/BreedingBuddiesTest.java
+++ b/src/test/java/net/tfminecraft/breedingbuddies/BreedingBuddiesTest.java
@@ -96,11 +96,10 @@ void listenersAreRegisteredWhenEnabled() {
@Test
void savedDataIsLoadedOnEnable() throws Exception {
- UUID owner = UUID.randomUUID();
+ UUID owner = player().getUniqueId();
FarmAnimal daisy = new FarmAnimal(UUID.randomUUID(), "Daisy", new HashSet<>());
daisy.setState(AnimalStates.HAPPY);
- PluginData.savePlayerData(owner, java.util.Set.of(daisy));
- PluginData.getPlayerAnimals().clear();
+ OwnershipManager.registerOwnership(owner, daisy);
server.getPluginManager().disablePlugin(plugin);
PluginData.getPlayerAnimals().clear();
@@ -112,14 +111,14 @@ void savedDataIsLoadedOnEnable() throws Exception {
@Test
void dataIsAutosavedAndSavedOnDisable() throws Exception {
PlayerMock alice = player();
- own(alice, spawn(Cow.class));
+ FarmAnimal saved = own(alice, spawn(Cow.class));
server.getScheduler().performOneTick();
assertTrue(Files.exists(playerFile(alice.getUniqueId())));
- Files.delete(playerFile(alice.getUniqueId()));
+ saved.setFed(true);
server.getPluginManager().disablePlugin(plugin);
- assertTrue(Files.exists(playerFile(alice.getUniqueId())));
+ assertTrue(Files.readString(playerFile(alice.getUniqueId())).contains("\"fed\":true"));
}
@Test
diff --git a/src/test/java/net/tfminecraft/breedingbuddies/OwnershipManagerTest.java b/src/test/java/net/tfminecraft/breedingbuddies/OwnershipManagerTest.java
index 8b8bc83..f8ca2ef 100644
--- a/src/test/java/net/tfminecraft/breedingbuddies/OwnershipManagerTest.java
+++ b/src/test/java/net/tfminecraft/breedingbuddies/OwnershipManagerTest.java
@@ -9,11 +9,11 @@
import static org.junit.jupiter.api.Assertions.*;
class OwnershipManagerTest extends PluginTestBase {
- private final UUID alice = UUID.randomUUID();
- private final UUID bob = UUID.randomUUID();
+ private UUID alice, bob;
+ @org.junit.jupiter.api.BeforeEach void actors() { alice = player().getUniqueId(); bob = player().getUniqueId(); }
private static FarmAnimal animal() {
- return new FarmAnimal(UUID.randomUUID(), "Daisy", new HashSet<>());
+ FarmAnimal a = new FarmAnimal(UUID.randomUUID(), "Daisy", new HashSet<>()); a.setState(AnimalStates.HAPPY); return a;
}
@Test
@@ -51,7 +51,7 @@ void animalsCanBeFoundForAnyOwnerOrAParticularOne() {
void anAnimalListedWithNoOwnersHasNoOwner() {
FarmAnimal daisy = animal();
OwnershipManager.registerOwnership(alice, daisy);
- daisy.getOwnersUuids().clear();
+ OwnershipManager.removeOwnership(alice, daisy);
assertFalse(OwnershipManager.hasOwner(daisy.getUuid()));
assertFalse(OwnershipManager.hasOwner(UUID.randomUUID()));
diff --git a/src/test/java/net/tfminecraft/breedingbuddies/PluginDataTest.java b/src/test/java/net/tfminecraft/breedingbuddies/PluginDataTest.java
index 823deac..24a70ce 100644
--- a/src/test/java/net/tfminecraft/breedingbuddies/PluginDataTest.java
+++ b/src/test/java/net/tfminecraft/breedingbuddies/PluginDataTest.java
@@ -79,7 +79,7 @@ void onlinePlayersAnimalsSurviveASaveAndReload() {
@Test
void offlineOwnersAreSavedAndReloaded() {
- UUID offline = UUID.randomUUID();
+ UUID offline = player().getUniqueId();
FarmAnimal daisy = detailedAnimal("Daisy");
OwnershipManager.registerOwnership(offline, daisy);
@@ -92,7 +92,7 @@ void offlineOwnersAreSavedAndReloaded() {
@Test
void anOwnerWhoGaveUpTheirLastAnimalDoesNotGetItBackAfterAReload() {
- UUID offline = UUID.randomUUID();
+ UUID offline = player().getUniqueId();
FarmAnimal daisy = detailedAnimal("Daisy");
OwnershipManager.registerOwnership(offline, daisy);
PluginData.saveAllData(false);
@@ -105,33 +105,10 @@ void anOwnerWhoGaveUpTheirLastAnimalDoesNotGetItBackAfterAReload() {
assertTrue(OwnershipManager.getAnimals(offline).isEmpty());
}
- @Test
- void aPlayerListedInTheFileIsAddedBackAsOwner() throws Exception {
- UUID player = UUID.randomUUID();
- FarmAnimal daisy = detailedAnimal("Daisy");
- daisy.getOwnersUuids().add(UUID.randomUUID());
- PluginData.savePlayerData(player, Set.of(daisy));
-
- PluginData.loadAllData();
-
- assertTrue(OwnershipManager.getAnimal(daisy.getUuid(), player).getOwnersUuids().contains(player));
- }
-
- @Test
- void loadingAPlayerWithoutSavedDataLeavesTheirAnimalsAlone() {
- UUID player = UUID.randomUUID();
- FarmAnimal daisy = detailedAnimal("Daisy");
- OwnershipManager.registerOwnership(player, daisy);
-
- PluginData.loadPlayerData(player, new java.util.HashMap<>());
-
- assertEquals(Set.of(daisy), OwnershipManager.getAnimals(player));
- }
-
@Test
void unownedAnimalsSurviveASaveAndReload() {
FarmAnimal stray = detailedAnimal("???");
- stray.getOwnersUuids().add(UUID.randomUUID());
+
FarmAnimal plain = new FarmAnimal(UUID.randomUUID(), "???", new HashSet<>());
plain.setState(AnimalStates.UNOWNED);
UnownedAnimalsManager.addUnownedAnimal(stray);
@@ -191,7 +168,9 @@ void savingWhileADayChangeRunsIsLeftToTheDayChange() throws Exception {
setStatic(DayChangeListener.class, "isChangingDay", true);
PluginData.saveAllData(false);
- assertFalse(Files.exists(playerFile(alice.getUniqueId())));
+ 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())));
@@ -215,7 +194,7 @@ void strayFilesInThePlayerFolderAreIgnoredOrReported() throws Exception {
try (LogCapture log = new LogCapture(Bukkit.getLogger())) {
PluginData.loadAllData();
- assertTrue(log.contains("Invalid player data file: not-a-uuid.json"));
+ assertTrue(log.contains("Animal ownership held"));
assertFalse(log.contains("notes.txt"));
}
assertTrue(PluginData.getPlayerAnimals().isEmpty());
@@ -224,11 +203,11 @@ void strayFilesInThePlayerFolderAreIgnoredOrReported() throws Exception {
@Test
void corruptFilesDoNotReplaceWhatIsAlreadyLoaded() throws Exception {
ChunkManager.addChunk(world.getChunkAt(0, 0));
- UUID player = UUID.randomUUID();
+ 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(playerFile(player), "{not json");
+ Files.writeString(DATA_DIR.resolve("PlayerData").resolve(player + ".json"), "{not json");
assertDoesNotThrow(PluginData::loadAllData);
@@ -237,17 +216,14 @@ void corruptFilesDoNotReplaceWhatIsAlreadyLoaded() throws Exception {
assertFalse(PluginData.getPlayerAnimals().containsKey(player));
}
- @Test
- void anUnwritableDataFolderDoesNotStopTheServer() throws Exception {
+ @Test void failedDurableFanoutRefusesFurtherOwnership() throws Exception {
PlayerMock alice = player();
- OwnershipManager.registerOwnership(alice.getUniqueId(), detailedAnimal("Daisy"));
- UnownedAnimalsManager.addUnownedAnimal(detailedAnimal("???"));
- ChunkManager.addChunk(world.getChunkAt(0, 0));
- Files.createDirectories(DATA_DIR.getParent());
- Files.writeString(DATA_DIR, "a file where the folder should be");
-
- assertDoesNotThrow(() -> PluginData.saveAllData(true));
-
- assertTrue(Files.isRegularFile(DATA_DIR));
+ 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
index 8ab6a00..6f4173b 100644
--- a/src/test/java/net/tfminecraft/breedingbuddies/PluginTestBase.java
+++ b/src/test/java/net/tfminecraft/breedingbuddies/PluginTestBase.java
@@ -63,6 +63,8 @@ public abstract class PluginTestBase {
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 {
@@ -75,6 +77,16 @@ void startServer() throws IOException {
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);
}
@@ -208,7 +220,7 @@ protected static void deleteRecursively(Path root) throws IOException {
}
protected static Path playerFile(UUID player) {
- return DATA_DIR.resolve("PlayerData").resolve(player + ".json");
+ return DATA_DIR.resolve("CharacterData").resolve(player + ".json");
}
/** Collects log records written to a logger while a test runs. */
diff --git a/src/test/java/net/tfminecraft/breedingbuddies/ReviewRegressionTest.java b/src/test/java/net/tfminecraft/breedingbuddies/ReviewRegressionTest.java
new file mode 100644
index 0000000..3a45ac3
--- /dev/null
+++ b/src/test/java/net/tfminecraft/breedingbuddies/ReviewRegressionTest.java
@@ -0,0 +1,112 @@
+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
new file mode 100644
index 0000000..f1dbc98
--- /dev/null
+++ b/src/test/java/net/tfminecraft/breedingbuddies/UnavailableStoreScopeTest.java
@@ -0,0 +1,105 @@
+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/listeners/DayChangeListenerTest.java b/src/test/java/net/tfminecraft/breedingbuddies/listeners/DayChangeListenerTest.java
index 78cd7ef..1e0d625 100644
--- a/src/test/java/net/tfminecraft/breedingbuddies/listeners/DayChangeListenerTest.java
+++ b/src/test/java/net/tfminecraft/breedingbuddies/listeners/DayChangeListenerTest.java
@@ -27,6 +27,7 @@
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.*;
@@ -152,6 +153,8 @@ void animalsOutsideAnyStableCountANightOutButMountsDoNot() throws Exception {
@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);
@@ -161,6 +164,9 @@ void animalsLeftOutTooLongEscapeTheirOwners() throws Exception {
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
diff --git a/src/test/java/net/tfminecraft/breedingbuddies/listeners/FarmToolsListenerTest.java b/src/test/java/net/tfminecraft/breedingbuddies/listeners/FarmToolsListenerTest.java
index c9518ab..e8d2910 100644
--- a/src/test/java/net/tfminecraft/breedingbuddies/listeners/FarmToolsListenerTest.java
+++ b/src/test/java/net/tfminecraft/breedingbuddies/listeners/FarmToolsListenerTest.java
@@ -198,7 +198,9 @@ void otherDeathsAreIgnored() {
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.
- OwnershipManager.registerOwnership(alice.getUniqueId(), new FarmAnimal(zombie.getUniqueId(), "Odd", new java.util.HashSet<>()));
+ 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));
diff --git a/src/test/java/net/tfminecraft/breedingbuddies/listeners/MountListenerTest.java b/src/test/java/net/tfminecraft/breedingbuddies/listeners/MountListenerTest.java
index 02e901c..64e03ae 100644
--- a/src/test/java/net/tfminecraft/breedingbuddies/listeners/MountListenerTest.java
+++ b/src/test/java/net/tfminecraft/breedingbuddies/listeners/MountListenerTest.java
@@ -108,7 +108,7 @@ void ridingOrTouchingAnOwnedMountMarksItAsNotNeedingAStable() {
FarmAnimal cowRecord = own(alice, cow);
mount(alice, ridden);
- interact(player(), touched, new ItemStack(Material.AIR), EquipmentSlot.HAND);
+ 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);
@@ -162,7 +162,7 @@ void neuteringNeedsShearsTheMainHandAnOwnerAndAnIntactMount() {
assertFalse(interact(alice, foal, new ItemStack(Material.WHEAT), EquipmentSlot.HAND).isCancelled());
assertFalse(interact(alice, foal, new ItemStack(Material.SHEARS), EquipmentSlot.OFF_HAND).isCancelled());
- assertFalse(interact(alice, theirs, new ItemStack(Material.SHEARS), EquipmentSlot.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());
diff --git a/tools/animal-ownership-rollback.py b/tools/animal-ownership-rollback.py
new file mode 100644
index 0000000..2bd53d7
--- /dev/null
+++ b/tools/animal-ownership-rollback.py
@@ -0,0 +1,129 @@
+#!/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/test_animal_ownership_rollback.py b/tools/test_animal_ownership_rollback.py
new file mode 100644
index 0000000..c75f17b
--- /dev/null
+++ b/tools/test_animal_ownership_rollback.py
@@ -0,0 +1,119 @@
+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)