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)