Skip to content

Commit 953a596

Browse files
authored
fix: drop ghost husbandry animals missing from the world save (#52)
An owned animal that the startup chunk scan cannot find is only a leftover record. Remove that row and log who owned it, and leave the record when the scan is incomplete.
1 parent 4806ff6 commit 953a596

2 files changed

Lines changed: 135 additions & 8 deletions

File tree

‎src/main/java/net/tfminecraft/cooking/husbandry/HusbandryLocator.java‎

Lines changed: 66 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@
22

33
import java.io.File;
44
import java.util.ArrayList;
5+
import java.util.Collection;
56
import java.util.HashMap;
67
import java.util.List;
78
import java.util.Map;
@@ -10,6 +11,7 @@
1011
import java.util.Set;
1112
import java.util.UUID;
1213
import java.util.concurrent.ConcurrentHashMap;
14+
import java.util.stream.Collectors;
1315

1416
import org.bukkit.Bukkit;
1517
import org.bukkit.World;
@@ -19,7 +21,8 @@
1921

2022
/**
2123
* Finds owned animals sitting in unloaded chunks by reading the saved entity chunks,
22-
* so {@code /animals} can point at them. Animals that are not in any saved chunk are marked missing.
24+
* so {@code /animals} can point at them. An owned animal that is not in any saved chunk
25+
* of a fully scanned world is a ghost: its row is deleted and logged.
2326
*/
2427
public final class HusbandryLocator {
2528

@@ -82,7 +85,9 @@ private static void apply(
8285
return;
8386
}
8487
int located = 0;
88+
int dropped = 0;
8589
int missing = 0;
90+
List<String> scannedWorlds = worlds.stream().map(HusbandryEntityScan.WorldDir::world).toList();
8691
for (Map.Entry<UUID, Long> target : targets.entrySet()) {
8792
UUID uuid = target.getKey();
8893
if (HusbandryEntities.getLoaded(uuid).isPresent()) {
@@ -95,10 +100,18 @@ private static void apply(
95100
}
96101
HusbandryEntityScan.Found found = result.found().get(uuid);
97102
if (found == null) {
98-
String storedWorld = stored.get().world();
103+
HusbandryAnimal animal = stored.get();
99104
// An animal last seen in a world that was not scanned may still be there.
100-
if (result.complete() && (storedWorld == null
101-
|| worlds.stream().anyMatch(world -> world.world().equals(storedWorld)))) {
105+
if (!isConfirmedGhost(result.complete(), animal.world(), scannedWorlds)) {
106+
continue;
107+
}
108+
try {
109+
String line = deleteGhost(repository, animal);
110+
Bukkit.getLogger().warning(line);
111+
dropped++;
112+
} catch (SqliteDatabaseException ex) {
113+
Bukkit.getLogger().severe("[Cooking] Failed to drop ghost animal " + uuid
114+
+ ": " + ex.getMessage());
102115
MISSING.add(uuid);
103116
missing++;
104117
}
@@ -117,11 +130,56 @@ private static void apply(
117130
Bukkit.getLogger().severe("[Cooking] Failed to save husbandry animal location: " + ex.getMessage());
118131
}
119132
}
120-
if (located > 0 || missing > 0 || !result.complete()) {
121-
Bukkit.getLogger().info("[Cooking] Animal scan: updated " + located + " locations, " + missing
122-
+ " owned animals not found in saved chunks"
123-
+ (result.complete() ? "." : " (some chunks could not be read, none marked missing)."));
133+
if (located > 0 || dropped > 0 || missing > 0 || !result.complete()) {
134+
String failed = missing > 0
135+
? ", " + missing + " still marked missing after a failed drop"
136+
: "";
137+
Bukkit.getLogger().info("[Cooking] Animal scan: updated " + located + " locations, dropped "
138+
+ dropped + " ghost animals" + failed
139+
+ (result.complete() ? "." : " (some chunks could not be read, none dropped)."));
140+
}
141+
}
142+
143+
/**
144+
* A ghost is an owned animal absent from every saved chunk after a complete scan of its world.
145+
* An incomplete scan, or a last world that was not scanned, is not enough to drop the row.
146+
*/
147+
static boolean isConfirmedGhost(boolean scanComplete, String storedWorld, Collection<String> scannedWorlds) {
148+
if (!scanComplete) {
149+
return false;
150+
}
151+
if (storedWorld == null || storedWorld.isBlank()) {
152+
return true;
124153
}
154+
return scannedWorlds != null && scannedWorlds.contains(storedWorld);
155+
}
156+
157+
/** Deletes the animal and its owners. Returns the log line for the dropped record. */
158+
static String deleteGhost(HusbandryRepository repository, HusbandryAnimal animal) {
159+
List<HusbandryOwner> owners = repository.listOwners(animal.uuid());
160+
repository.deleteAnimal(animal.uuid());
161+
for (HusbandryOwner owner : owners) {
162+
repository.deleteOwner(animal.uuid(), owner.playerUuid());
163+
}
164+
HusbandryEntities.evict(animal.uuid());
165+
MISSING.remove(animal.uuid());
166+
return ghostLog(animal, owners);
167+
}
168+
169+
static String ghostLog(HusbandryAnimal animal, List<HusbandryOwner> owners) {
170+
String name = animal.name() == null || animal.name().isBlank() ? "(unnamed)" : animal.name();
171+
String type = animal.type() == null || animal.type().isBlank() ? "unknown" : animal.type();
172+
String place = animal.hasLocation()
173+
? animal.world() + " " + animal.x() + ", " + animal.y() + ", " + animal.z()
174+
: "unknown";
175+
String ownerText = owners == null || owners.isEmpty()
176+
? "none"
177+
: owners.stream()
178+
.sorted((left, right) -> left.playerUuid().compareTo(right.playerUuid()))
179+
.map(owner -> owner.playerUuid() + " (" + owner.role() + ")")
180+
.collect(Collectors.joining(", "));
181+
return "[Cooking] Dropped ghost animal " + name + " (" + type + ") " + animal.uuid()
182+
+ " owners=" + ownerText + " last seen " + place;
125183
}
126184

127185
private static boolean sameLocation(HusbandryAnimal animal, HusbandryEntityScan.Found found) {
Lines changed: 69 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,69 @@
1+
package net.tfminecraft.cooking.husbandry;
2+
3+
import static org.junit.jupiter.api.Assertions.assertEquals;
4+
import static org.junit.jupiter.api.Assertions.assertFalse;
5+
import static org.junit.jupiter.api.Assertions.assertTrue;
6+
7+
import java.io.File;
8+
import java.nio.file.Path;
9+
import java.util.List;
10+
import java.util.UUID;
11+
12+
import org.junit.jupiter.api.Test;
13+
import org.junit.jupiter.api.io.TempDir;
14+
15+
class HusbandryLocatorTest {
16+
17+
@Test
18+
void onlyACompleteScanOfTheAnimalsWorldConfirmsAGhost() {
19+
assertTrue(HusbandryLocator.isConfirmedGhost(true, "TFMC_Map", List.of("TFMC_Map", "TFMC_Map_nether")));
20+
assertTrue(HusbandryLocator.isConfirmedGhost(true, null, List.of("TFMC_Map")));
21+
assertTrue(HusbandryLocator.isConfirmedGhost(true, " ", List.of("TFMC_Map")));
22+
assertFalse(HusbandryLocator.isConfirmedGhost(false, "TFMC_Map", List.of("TFMC_Map")));
23+
assertFalse(HusbandryLocator.isConfirmedGhost(true, "other", List.of("TFMC_Map")));
24+
assertFalse(HusbandryLocator.isConfirmedGhost(true, "TFMC_Map", null));
25+
}
26+
27+
@Test
28+
void droppingAGhostRemovesTheRowAndOwners(@TempDir Path tempDir) {
29+
File dbFile = tempDir.resolve("husbandry.db").toFile();
30+
HusbandryRepository repository = HusbandryRepository.open(dbFile);
31+
try {
32+
UUID animalId = UUID.randomUUID();
33+
UUID ownerId = UUID.randomUUID();
34+
UUID coOwnerId = UUID.randomUUID();
35+
HusbandryAnimal animal = new HusbandryAnimal(animalId, "COW", "Bess");
36+
animal.setLastLocation("TFMC_Map", 4369, 167, 1950);
37+
animal.setUnloadedAt(50L);
38+
repository.upsertAnimal(animal);
39+
repository.upsertOwner(new HusbandryOwner(animalId, ownerId, "owner"));
40+
repository.upsertOwner(new HusbandryOwner(animalId, coOwnerId, "coowner"));
41+
HusbandryEntities.putLoaded(animal);
42+
43+
String line = HusbandryLocator.deleteGhost(repository, animal);
44+
45+
assertFalse(repository.exists(animalId));
46+
assertTrue(repository.listOwners(animalId).isEmpty());
47+
assertEquals(0, repository.countForPlayer(ownerId));
48+
assertEquals(0, repository.countForPlayer(coOwnerId));
49+
assertTrue(HusbandryEntities.getLoaded(animalId).isEmpty());
50+
assertFalse(HusbandryLocator.isMissing(animalId));
51+
String owners = List.of(
52+
new HusbandryOwner(animalId, ownerId, "owner"),
53+
new HusbandryOwner(animalId, coOwnerId, "coowner"))
54+
.stream()
55+
.sorted((left, right) -> left.playerUuid().compareTo(right.playerUuid()))
56+
.map(owner -> owner.playerUuid() + " (" + owner.role() + ")")
57+
.reduce((left, right) -> left + ", " + right)
58+
.orElseThrow();
59+
assertEquals(
60+
"[Cooking] Dropped ghost animal Bess (COW) " + animalId
61+
+ " owners=" + owners
62+
+ " last seen TFMC_Map 4369, 167, 1950",
63+
line);
64+
} finally {
65+
repository.close();
66+
HusbandryEntities.evict(null);
67+
}
68+
}
69+
}

0 commit comments

Comments
 (0)