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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -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;
Expand All @@ -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 {

Expand Down Expand Up @@ -82,7 +85,9 @@ private static void apply(
return;
}
int located = 0;
int dropped = 0;
int missing = 0;
List<String> scannedWorlds = worlds.stream().map(HusbandryEntityScan.WorldDir::world).toList();
for (Map.Entry<UUID, Long> target : targets.entrySet()) {
UUID uuid = target.getKey();
if (HusbandryEntities.getLoaded(uuid).isPresent()) {
Expand All @@ -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++;
}
Expand All @@ -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<String> 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<HusbandryOwner> 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<HusbandryOwner> 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) {
Expand Down
Original file line number Diff line number Diff line change
@@ -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);
}
}
}