22
33import java .io .File ;
44import java .util .ArrayList ;
5+ import java .util .Collection ;
56import java .util .HashMap ;
67import java .util .List ;
78import java .util .Map ;
1011import java .util .Set ;
1112import java .util .UUID ;
1213import java .util .concurrent .ConcurrentHashMap ;
14+ import java .util .stream .Collectors ;
1315
1416import org .bukkit .Bukkit ;
1517import org .bukkit .World ;
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 */
2427public 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 ) {
0 commit comments