diff --git a/src/main/java/net/tfminecraft/cooking/husbandry/HusbandryLocator.java b/src/main/java/net/tfminecraft/cooking/husbandry/HusbandryLocator.java index 6552460..f0629ce 100644 --- a/src/main/java/net/tfminecraft/cooking/husbandry/HusbandryLocator.java +++ b/src/main/java/net/tfminecraft/cooking/husbandry/HusbandryLocator.java @@ -2,6 +2,7 @@ import java.io.File; import java.util.ArrayList; +import java.util.Collection; import java.util.HashMap; import java.util.List; import java.util.Map; @@ -10,6 +11,7 @@ import java.util.Set; import java.util.UUID; import java.util.concurrent.ConcurrentHashMap; +import java.util.stream.Collectors; import org.bukkit.Bukkit; import org.bukkit.World; @@ -19,7 +21,8 @@ /** * Finds owned animals sitting in unloaded chunks by reading the saved entity chunks, - * so {@code /animals} can point at them. Animals that are not in any saved chunk are marked missing. + * so {@code /animals} can point at them. An owned animal that is not in any saved chunk + * of a fully scanned world is a ghost: its row is deleted and logged. */ public final class HusbandryLocator { @@ -82,7 +85,9 @@ private static void apply( return; } int located = 0; + int dropped = 0; int missing = 0; + List scannedWorlds = worlds.stream().map(HusbandryEntityScan.WorldDir::world).toList(); for (Map.Entry target : targets.entrySet()) { UUID uuid = target.getKey(); if (HusbandryEntities.getLoaded(uuid).isPresent()) { @@ -95,10 +100,18 @@ private static void apply( } HusbandryEntityScan.Found found = result.found().get(uuid); if (found == null) { - String storedWorld = stored.get().world(); + HusbandryAnimal animal = stored.get(); // An animal last seen in a world that was not scanned may still be there. - if (result.complete() && (storedWorld == null - || worlds.stream().anyMatch(world -> world.world().equals(storedWorld)))) { + if (!isConfirmedGhost(result.complete(), animal.world(), scannedWorlds)) { + continue; + } + try { + String line = deleteGhost(repository, animal); + Bukkit.getLogger().warning(line); + dropped++; + } catch (SqliteDatabaseException ex) { + Bukkit.getLogger().severe("[Cooking] Failed to drop ghost animal " + uuid + + ": " + ex.getMessage()); MISSING.add(uuid); missing++; } @@ -117,11 +130,56 @@ private static void apply( Bukkit.getLogger().severe("[Cooking] Failed to save husbandry animal location: " + ex.getMessage()); } } - if (located > 0 || missing > 0 || !result.complete()) { - Bukkit.getLogger().info("[Cooking] Animal scan: updated " + located + " locations, " + missing - + " owned animals not found in saved chunks" - + (result.complete() ? "." : " (some chunks could not be read, none marked missing).")); + if (located > 0 || dropped > 0 || missing > 0 || !result.complete()) { + String failed = missing > 0 + ? ", " + missing + " still marked missing after a failed drop" + : ""; + Bukkit.getLogger().info("[Cooking] Animal scan: updated " + located + " locations, dropped " + + dropped + " ghost animals" + failed + + (result.complete() ? "." : " (some chunks could not be read, none dropped).")); + } + } + + /** + * A ghost is an owned animal absent from every saved chunk after a complete scan of its world. + * An incomplete scan, or a last world that was not scanned, is not enough to drop the row. + */ + static boolean isConfirmedGhost(boolean scanComplete, String storedWorld, Collection scannedWorlds) { + if (!scanComplete) { + return false; + } + if (storedWorld == null || storedWorld.isBlank()) { + return true; } + return scannedWorlds != null && scannedWorlds.contains(storedWorld); + } + + /** Deletes the animal and its owners. Returns the log line for the dropped record. */ + static String deleteGhost(HusbandryRepository repository, HusbandryAnimal animal) { + List owners = repository.listOwners(animal.uuid()); + repository.deleteAnimal(animal.uuid()); + for (HusbandryOwner owner : owners) { + repository.deleteOwner(animal.uuid(), owner.playerUuid()); + } + HusbandryEntities.evict(animal.uuid()); + MISSING.remove(animal.uuid()); + return ghostLog(animal, owners); + } + + static String ghostLog(HusbandryAnimal animal, List owners) { + String name = animal.name() == null || animal.name().isBlank() ? "(unnamed)" : animal.name(); + String type = animal.type() == null || animal.type().isBlank() ? "unknown" : animal.type(); + String place = animal.hasLocation() + ? animal.world() + " " + animal.x() + ", " + animal.y() + ", " + animal.z() + : "unknown"; + String ownerText = owners == null || owners.isEmpty() + ? "none" + : owners.stream() + .sorted((left, right) -> left.playerUuid().compareTo(right.playerUuid())) + .map(owner -> owner.playerUuid() + " (" + owner.role() + ")") + .collect(Collectors.joining(", ")); + return "[Cooking] Dropped ghost animal " + name + " (" + type + ") " + animal.uuid() + + " owners=" + ownerText + " last seen " + place; } private static boolean sameLocation(HusbandryAnimal animal, HusbandryEntityScan.Found found) { diff --git a/src/test/java/net/tfminecraft/cooking/husbandry/HusbandryLocatorTest.java b/src/test/java/net/tfminecraft/cooking/husbandry/HusbandryLocatorTest.java new file mode 100644 index 0000000..eb912dc --- /dev/null +++ b/src/test/java/net/tfminecraft/cooking/husbandry/HusbandryLocatorTest.java @@ -0,0 +1,69 @@ +package net.tfminecraft.cooking.husbandry; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertTrue; + +import java.io.File; +import java.nio.file.Path; +import java.util.List; +import java.util.UUID; + +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.io.TempDir; + +class HusbandryLocatorTest { + + @Test + void onlyACompleteScanOfTheAnimalsWorldConfirmsAGhost() { + assertTrue(HusbandryLocator.isConfirmedGhost(true, "TFMC_Map", List.of("TFMC_Map", "TFMC_Map_nether"))); + assertTrue(HusbandryLocator.isConfirmedGhost(true, null, List.of("TFMC_Map"))); + assertTrue(HusbandryLocator.isConfirmedGhost(true, " ", List.of("TFMC_Map"))); + assertFalse(HusbandryLocator.isConfirmedGhost(false, "TFMC_Map", List.of("TFMC_Map"))); + assertFalse(HusbandryLocator.isConfirmedGhost(true, "other", List.of("TFMC_Map"))); + assertFalse(HusbandryLocator.isConfirmedGhost(true, "TFMC_Map", null)); + } + + @Test + void droppingAGhostRemovesTheRowAndOwners(@TempDir Path tempDir) { + File dbFile = tempDir.resolve("husbandry.db").toFile(); + HusbandryRepository repository = HusbandryRepository.open(dbFile); + try { + UUID animalId = UUID.randomUUID(); + UUID ownerId = UUID.randomUUID(); + UUID coOwnerId = UUID.randomUUID(); + HusbandryAnimal animal = new HusbandryAnimal(animalId, "COW", "Bess"); + animal.setLastLocation("TFMC_Map", 4369, 167, 1950); + animal.setUnloadedAt(50L); + repository.upsertAnimal(animal); + repository.upsertOwner(new HusbandryOwner(animalId, ownerId, "owner")); + repository.upsertOwner(new HusbandryOwner(animalId, coOwnerId, "coowner")); + HusbandryEntities.putLoaded(animal); + + String line = HusbandryLocator.deleteGhost(repository, animal); + + assertFalse(repository.exists(animalId)); + assertTrue(repository.listOwners(animalId).isEmpty()); + assertEquals(0, repository.countForPlayer(ownerId)); + assertEquals(0, repository.countForPlayer(coOwnerId)); + assertTrue(HusbandryEntities.getLoaded(animalId).isEmpty()); + assertFalse(HusbandryLocator.isMissing(animalId)); + String owners = List.of( + new HusbandryOwner(animalId, ownerId, "owner"), + new HusbandryOwner(animalId, coOwnerId, "coowner")) + .stream() + .sorted((left, right) -> left.playerUuid().compareTo(right.playerUuid())) + .map(owner -> owner.playerUuid() + " (" + owner.role() + ")") + .reduce((left, right) -> left + ", " + right) + .orElseThrow(); + assertEquals( + "[Cooking] Dropped ghost animal Bess (COW) " + animalId + + " owners=" + owners + + " last seen TFMC_Map 4369, 167, 1950", + line); + } finally { + repository.close(); + HusbandryEntities.evict(null); + } + } +}