From 0cb38cb88b61a0b7f3aa36950243a37ce99cac46 Mon Sep 17 00:00:00 2001 From: XxFran10xX <318299142+XxFran10xX@users.noreply.github.com> Date: Sat, 31 Oct 2026 12:02:55 +0100 Subject: [PATCH 1/2] fix: let the animal scan finish when an interrupted save left a temp player file A crash mid-save leaves a truncated -.dat beside the real player file. The startup scan read every *.dat, failed on those, and so treated every scan as incomplete: since 09-29 no lost animal on Main was restored or dropped, and their owners kept losing an animal place. - Read only .dat player files. - Log which files or chunks could not be read, and why (first 10). - While a scan is incomplete, show lost animals as Missing in /animals. Co-Authored-By: Claude Opus 5.5 --- .../husbandry/HusbandryEntityScan.java | 74 ++++++++++++------- .../cooking/husbandry/HusbandryLocator.java | 40 +++++++--- .../husbandry/HusbandryEntityScanTest.java | 43 +++++++++++ .../husbandry/HusbandryLocatorTest.java | 24 ++++-- .../HusbandryMaintenanceRegressionTest.java | 2 +- .../HusbandryRemainingCoverageTest.java | 4 +- .../HusbandryRuntimeCoverageTest.java | 16 ++-- 7 files changed, 151 insertions(+), 52 deletions(-) diff --git a/src/main/java/net/tfminecraft/cooking/husbandry/HusbandryEntityScan.java b/src/main/java/net/tfminecraft/cooking/husbandry/HusbandryEntityScan.java index 1d219f7..6d3246c 100644 --- a/src/main/java/net/tfminecraft/cooking/husbandry/HusbandryEntityScan.java +++ b/src/main/java/net/tfminecraft/cooking/husbandry/HusbandryEntityScan.java @@ -6,6 +6,7 @@ import java.io.IOException; import java.io.InputStream; import java.nio.file.Files; +import java.util.ArrayList; import java.util.HashMap; import java.util.HashSet; import java.util.List; @@ -30,9 +31,22 @@ record Found(String world, int x, int y, int z) {} /** * {@code ridden} holds animals saved under a logged-out rider. - * {@code complete} is false when some chunk or player file could not be read, so a missing animal may still exist. + * {@code unreadable} names each folder, file or chunk that could not be read, with the reason. */ - record Result(Map found, Set ridden, boolean complete) {} + record Result(Map found, Set ridden, List unreadable) { + + /** False when something could not be read, so a missing animal may still exist. */ + boolean complete() { + return unreadable.isEmpty(); + } + } + + /** + * Vanilla player files are {@code .dat}. An interrupted save leaves a truncated temp file + * named {@code -.dat} next to it; that and the {@code .dat_old} backup are not read. + */ + private static final Pattern PLAYER_FILE = Pattern.compile( + "[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12}\\.dat"); private static final Pattern REGION = Pattern.compile("r\\.(-?\\d{1,7})\\.(-?\\d{1,7})\\.mca"); private static final int SECTOR = 4096; @@ -48,9 +62,9 @@ static Result scan(List worlds, Set targets) { static Result scan(List worlds, File playerdata, Set targets) { Map found = new HashMap<>(); Set ridden = new HashSet<>(); - boolean complete = true; + List unreadable = new ArrayList<>(); if (targets.isEmpty()) { - return new Result(found, ridden, true); + return new Result(found, ridden, List.of()); } for (WorldDir world : worlds) { if (!world.entities().exists()) { @@ -58,7 +72,7 @@ static Result scan(List worlds, File playerdata, Set targets) { } File[] files = world.entities().listFiles(); if (files == null) { - complete = false; + unreadable.add(world.world() + " entities folder could not be listed"); continue; } for (File file : files) { @@ -68,22 +82,22 @@ static Result scan(List worlds, File playerdata, Set targets) { } int regionX = Integer.parseInt(name.group(1)); int regionZ = Integer.parseInt(name.group(2)); - complete &= scanRegion(world, file, regionX, regionZ, targets, found); + scanRegion(world, file, regionX, regionZ, targets, found, unreadable); } } - complete &= scanPlayers(playerdata, targets, ridden); - return new Result(found, ridden, complete); + scanPlayers(playerdata, targets, ridden, unreadable); + return new Result(found, ridden, List.copyOf(unreadable)); } - private static boolean scanPlayers(File playerdata, Set targets, Set ridden) { + private static void scanPlayers(File playerdata, Set targets, Set ridden, List unreadable) { if (playerdata == null || !playerdata.exists()) { - return true; + return; } - File[] files = playerdata.listFiles((dir, name) -> name.endsWith(".dat")); + File[] files = playerdata.listFiles((dir, name) -> PLAYER_FILE.matcher(name).matches()); if (files == null) { - return false; + unreadable.add("playerdata folder could not be listed"); + return; } - boolean complete = true; for (File file : files) { try (DataInputStream in = new DataInputStream(new ByteArrayInputStream( new GZIPInputStream(new ByteArrayInputStream(readBounded(file, MAX_CHUNK_BYTES))) @@ -92,50 +106,58 @@ private static boolean scanPlayers(File playerdata, Set targets, Set readPlayer(in, targets, vehicle); ridden.addAll(vehicle.keySet()); } catch (IOException | RuntimeException ex) { - complete = false; + unreadable.add("playerdata/" + file.getName() + ": " + reason(ex)); } } - return complete; } - private static boolean scanRegion( - WorldDir world, File file, int regionX, int regionZ, Set targets, Map found) { + private static String reason(Exception ex) { + String message = ex.getMessage(); + return message == null || message.isBlank() ? ex.getClass().getSimpleName() : message; + } + + private static void scanRegion(WorldDir world, File file, int regionX, int regionZ, Set targets, + Map found, List unreadable) { + String where = world.world() + "/" + file.getName(); byte[] data; try { data = readBounded(file, MAX_REGION_BYTES); } catch (IOException ex) { - return false; + unreadable.add(where + ": " + reason(ex)); + return; } if (data.length == 0) { - return true; + return; } if (data.length < SECTOR * 2) { - return false; + unreadable.add(where + ": only " + data.length + " bytes"); + return; } - boolean complete = true; for (int index = 0; index < 1024; index++) { int entry = readInt(data, index * 4); long sectorOffset = (long) (entry >>> 8) * SECTOR; if (sectorOffset == 0) { continue; } + int chunkX = regionX * 32 + index % 32; + int chunkZ = regionZ * 32 + index / 32; + String chunkWhere = where + " chunk " + chunkX + "," + chunkZ; if (sectorOffset + 5 > data.length) { - complete = false; + unreadable.add(chunkWhere + ": starts past the end of the file"); continue; } int offset = (int) sectorOffset; try { - byte[] chunk = chunkBytes(world, data, offset, regionX * 32 + index % 32, regionZ * 32 + index / 32); + byte[] chunk = chunkBytes(world, data, offset, chunkX, chunkZ); if (chunk == null) { - complete = false; + unreadable.add(chunkWhere + ": bad length or compression"); continue; } readEntityChunk(new DataInputStream(new ByteArrayInputStream(chunk)), world.world(), targets, found); } catch (IOException | RuntimeException ex) { - complete = false; + unreadable.add(chunkWhere + ": " + reason(ex)); } } - return complete; } private static byte[] chunkBytes(WorldDir world, byte[] data, int offset, int chunkX, int chunkZ) diff --git a/src/main/java/net/tfminecraft/cooking/husbandry/HusbandryLocator.java b/src/main/java/net/tfminecraft/cooking/husbandry/HusbandryLocator.java index 9cb7af2..0a142fe 100644 --- a/src/main/java/net/tfminecraft/cooking/husbandry/HusbandryLocator.java +++ b/src/main/java/net/tfminecraft/cooking/husbandry/HusbandryLocator.java @@ -24,6 +24,8 @@ * so {@code /animals} can point at them. An owned animal that is not in any saved chunk * of a fully scanned world, nor under a logged-out rider, was lost from the save: it is * respawned from its snapshot, or, without one, its row is deleted as a ghost and logged. + * When something could not be read it is only marked {@code Missing}, and the unreadable + * files are logged so staff can clear them. */ public final class HusbandryLocator { @@ -97,6 +99,7 @@ private static void apply( int dropped = 0; int missing = 0; int held = 0; + int unsure = 0; List scannedWorlds = worlds.stream().map(HusbandryEntityScan.WorldDir::world).toList(); for (Map.Entry target : targets.entrySet()) { UUID uuid = target.getKey(); @@ -117,7 +120,13 @@ private static void apply( if (found == null) { HusbandryAnimal animal = stored.get(); // An animal last seen in a world that was not scanned may still be there. - if (!isConfirmedGhost(result.complete(), animal.world(), scannedWorlds)) { + if (!inScannedWorld(animal.world(), scannedWorlds)) { + continue; + } + // It may be in a chunk or with a rider that could not be read. + if (!result.complete()) { + MISSING.add(uuid); + unsure++; continue; } try { @@ -162,25 +171,36 @@ private static void apply( Bukkit.getLogger().severe("[Cooking] Failed to save husbandry animal location: " + ex.getMessage()); } } - if (located > 0 || restoring > 0 || dropped > 0 || missing > 0 || held > 0 || !result.complete()) { + if (located > 0 || restoring > 0 || dropped > 0 || missing > 0 || held > 0 || unsure > 0 + || !result.complete()) { String failed = missing > 0 ? ", " + missing + " still marked missing after a failed drop" : ""; Bukkit.getLogger().info("[Cooking] Animal scan: updated " + located + " locations, restoring " + restoring + " lost animals from snapshots, dropped " + dropped + " ghost animals" + failed + (held > 0 ? ", held " + held + " missing animal records for staff review" : "") - + (result.complete() ? "." : " (some chunks could not be read, none dropped).")); + + (unsure > 0 ? ", kept " + unsure + " missing animals because some files could not be read" : "") + + "."); + } + if (!result.complete()) { + Bukkit.getLogger().warning(unreadableLog(result.unreadable())); } } - /** - * 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; + static final int LOGGED_PROBLEMS = 10; + + static String unreadableLog(List problems) { + StringBuilder line = new StringBuilder("[Cooking] Animal scan could not read ") + .append(problems.size()).append(problems.size() == 1 ? " file or chunk: " : " files or chunks: "); + line.append(String.join("; ", problems.subList(0, Math.min(LOGGED_PROBLEMS, problems.size())))); + if (problems.size() > LOGGED_PROBLEMS) { + line.append("; and ").append(problems.size() - LOGGED_PROBLEMS).append(" more"); } + return line.toString(); + } + + /** An animal last seen in a world that was not scanned may still be there. No world means any. */ + static boolean inScannedWorld(String storedWorld, Collection scannedWorlds) { if (storedWorld == null || storedWorld.isBlank()) { return true; } diff --git a/src/test/java/net/tfminecraft/cooking/husbandry/HusbandryEntityScanTest.java b/src/test/java/net/tfminecraft/cooking/husbandry/HusbandryEntityScanTest.java index 90f0af0..ecda8f7 100644 --- a/src/test/java/net/tfminecraft/cooking/husbandry/HusbandryEntityScanTest.java +++ b/src/test/java/net/tfminecraft/cooking/husbandry/HusbandryEntityScanTest.java @@ -163,6 +163,49 @@ void unreadablePlayerFilesMakeTheScanIncomplete(@TempDir Path dir) throws IOExce assertTrue(result.ridden().isEmpty()); } + @Test + void leftoverTempPlayerFilesAreNotRead(@TempDir Path dir) throws IOException { + File playerdata = dir.resolve("playerdata").toFile(); + playerdata.mkdirs(); + Files.write(new File(playerdata, RIDER + ".dat").toPath(), gzip(player(entity(HORSE, 5.5, 64.0, 5.5)))); + // An interrupted vanilla save leaves -.dat with only a gzip header. + Files.write(new File(playerdata, RIDER + "-7214243180096765426.dat").toPath(), + new byte[] {0x1F, (byte) 0x8B, 8, 0, 0, 0, 0, 0, 0, (byte) 0xFF}); + Files.write(new File(playerdata, "notes.dat").toPath(), new byte[] {1, 2, 3}); + + HusbandryEntityScan.Result result = HusbandryEntityScan.scan( + List.of(new HusbandryEntityScan.WorldDir("TFMC_Map", dir.resolve("entities").toFile())), + playerdata, Set.of(HORSE)); + + assertTrue(result.complete()); + assertEquals(Set.of(HORSE), result.ridden()); + } + + @Test + void unreadableFilesAndChunksAreNamedWithTheirReason(@TempDir Path dir) throws IOException { + File entities = dir.resolve("entities").toFile(); + entities.mkdirs(); + // Index 1 of region (-1, 2) is chunk (-31, 64); index 0 is chunk (-32, 64) and reads fine. + writeRegion(new File(entities, "r.-1.2.mca"), + Map.of(0, zlib(chunk(entity(COW, -510.0, 64.0, 1030.0))), 1, new byte[] {1, 2, 3}), Map.of(1, 4)); + Files.write(new File(entities, "r.3.3.mca").toPath(), new byte[100]); + File playerdata = dir.resolve("playerdata").toFile(); + playerdata.mkdirs(); + Files.write(new File(playerdata, RIDER + ".dat").toPath(), new byte[] {1, 2, 3}); + + HusbandryEntityScan.Result result = HusbandryEntityScan.scan( + List.of(new HusbandryEntityScan.WorldDir("world", entities)), playerdata, Set.of(COW)); + + assertFalse(result.complete()); + List unreadable = result.unreadable(); + assertTrue(unreadable.contains("world/r.-1.2.mca chunk -31,64: bad length or compression"), unreadable::toString); + assertTrue(unreadable.contains("world/r.3.3.mca: only 100 bytes"), unreadable::toString); + assertTrue(unreadable.stream().anyMatch(line -> line.startsWith("playerdata/" + RIDER + ".dat: ")), + unreadable::toString); + assertEquals(3, unreadable.size()); + assertTrue(result.found().containsKey(COW)); + } + @Test void missingFolderIsSkipped(@TempDir Path dir) { HusbandryEntityScan.Result result = HusbandryEntityScan.scan( diff --git a/src/test/java/net/tfminecraft/cooking/husbandry/HusbandryLocatorTest.java b/src/test/java/net/tfminecraft/cooking/husbandry/HusbandryLocatorTest.java index e8ab22f..3662ce8 100644 --- a/src/test/java/net/tfminecraft/cooking/husbandry/HusbandryLocatorTest.java +++ b/src/test/java/net/tfminecraft/cooking/husbandry/HusbandryLocatorTest.java @@ -15,13 +15,23 @@ 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)); + void onlyAnAnimalLastSeenInAScannedWorldCanBeAGhost() { + assertTrue(HusbandryLocator.inScannedWorld("TFMC_Map", List.of("TFMC_Map", "TFMC_Map_nether"))); + assertTrue(HusbandryLocator.inScannedWorld(null, List.of("TFMC_Map"))); + assertTrue(HusbandryLocator.inScannedWorld(" ", List.of("TFMC_Map"))); + assertFalse(HusbandryLocator.inScannedWorld("other", List.of("TFMC_Map"))); + assertFalse(HusbandryLocator.inScannedWorld("TFMC_Map", null)); + } + + @Test + void theUnreadableLogNamesTheFirstProblemsAndCountsTheRest() { + assertEquals("[Cooking] Animal scan could not read 1 file or chunk: playerdata/a.dat: truncated", + HusbandryLocator.unreadableLog(List.of("playerdata/a.dat: truncated"))); + List many = java.util.stream.IntStream.range(0, HusbandryLocator.LOGGED_PROBLEMS + 3) + .mapToObj(i -> "p" + i).toList(); + String line = HusbandryLocator.unreadableLog(many); + assertTrue(line.startsWith("[Cooking] Animal scan could not read 13 files or chunks: p0; p1;"), line); + assertTrue(line.endsWith("; p9; and 3 more"), line); } @Test diff --git a/src/test/java/net/tfminecraft/cooking/husbandry/HusbandryMaintenanceRegressionTest.java b/src/test/java/net/tfminecraft/cooking/husbandry/HusbandryMaintenanceRegressionTest.java index 0eb29de..e1e1c22 100644 --- a/src/test/java/net/tfminecraft/cooking/husbandry/HusbandryMaintenanceRegressionTest.java +++ b/src/test/java/net/tfminecraft/cooking/husbandry/HusbandryMaintenanceRegressionTest.java @@ -55,7 +55,7 @@ class HusbandryMaintenanceRegressionTest { }; Logger logger = Bukkit.getLogger(); logger.addHandler(handler); try (MockedStatic snapshots = mockStatic(HusbandrySnapshots.class); - var scan = new HusbandryRuntimeCoverageTest.Scan(new HusbandryEntityScan.Result(Map.of(), Set.of(), true))) { + var scan = new HusbandryRuntimeCoverageTest.Scan(new HusbandryEntityScan.Result(Map.of(), Set.of(), List.of()))) { snapshots.when(() -> HusbandrySnapshots.restore(any(), any(), anyList())).thenReturn(false); HusbandryLocator.scanUnloaded(); scan.background(); assertDoesNotThrow(scan::apply); } finally { logger.removeHandler(handler); } diff --git a/src/test/java/net/tfminecraft/cooking/husbandry/HusbandryRemainingCoverageTest.java b/src/test/java/net/tfminecraft/cooking/husbandry/HusbandryRemainingCoverageTest.java index 306dccd..6b08aef 100644 --- a/src/test/java/net/tfminecraft/cooking/husbandry/HusbandryRemainingCoverageTest.java +++ b/src/test/java/net/tfminecraft/cooking/husbandry/HusbandryRemainingCoverageTest.java @@ -409,7 +409,7 @@ private Player offlinePlayer(UUID id, String name) { void corruptNbtRootsMakeTheScanIncompleteInsteadOfDeclaringAnimalsMissing(String kind) throws Exception { Path entities = Files.createDirectory(directory.resolve("entities")), players = Files.createDirectory(directory.resolve("players")); if (kind.equals("entity")) region(entities, new byte[]{1}, 3); - else Files.write(players.resolve("owner.dat"), gzip(new byte[]{1})); + else Files.write(players.resolve(UUID.randomUUID() + ".dat"), gzip(new byte[]{1})); var result = HusbandryEntityScan.scan(List.of(new HusbandryEntityScan.WorldDir("farm", entities.toFile())), players.toFile(), Set.of(UUID.randomUUID())); assertFalse(result.complete(), "Unreadable save data must not authorize removal of missing animal records"); } @@ -467,7 +467,7 @@ void malformedEntityContainerTagsCannotAuthorizeGhostDeletion(String key) throws out.writeByte(3); out.writeUTF(key); out.writeInt(1); if (key.equals("Passengers") || key.equals("Entity")) out.writeByte(0); }); - if (key.equals("RootVehicle") || key.equals("Entity")) Files.write(players.resolve("owner.dat"), gzip(data)); + if (key.equals("RootVehicle") || key.equals("Entity")) Files.write(players.resolve(UUID.randomUUID() + ".dat"), gzip(data)); else region(entities, data, 3); var result = HusbandryEntityScan.scan(List.of(new HusbandryEntityScan.WorldDir("farm", entities.toFile())), players.toFile(), Set.of(target)); assertFalse(result.complete(), "A malformed " + key + " tag cannot prove an animal is absent"); diff --git a/src/test/java/net/tfminecraft/cooking/husbandry/HusbandryRuntimeCoverageTest.java b/src/test/java/net/tfminecraft/cooking/husbandry/HusbandryRuntimeCoverageTest.java index 901749a..611026b 100644 --- a/src/test/java/net/tfminecraft/cooking/husbandry/HusbandryRuntimeCoverageTest.java +++ b/src/test/java/net/tfminecraft/cooking/husbandry/HusbandryRuntimeCoverageTest.java @@ -31,6 +31,8 @@ import io.papermc.paper.entity.EntitySerializationFlag; class HusbandryRuntimeCoverageTest { + + private static final List UNREAD_PLAYER = List.of("playerdata/rider.dat: truncated"); @TempDir Path directory; Farm f; private final HusbandryLifecycleListener lifecycle = new HusbandryLifecycleListener(); @@ -166,7 +168,7 @@ class HusbandryRuntimeCoverageTest { same.setLastLocation("farm", 1, 2, 3); f.repository.upsertAnimal(same); HusbandryLocator.markMissing(rider.uuid()); WorldMock nether = f.addWorld("nether"); nether.setEnvironment(World.Environment.NETHER); WorldMock end = f.addWorld("end"); end.setEnvironment(World.Environment.THE_END); - var result = new HusbandryEntityScan.Result(Map.of(moved.uuid(), new HusbandryEntityScan.Found("nether", 9, 8, 7), same.uuid(), new HusbandryEntityScan.Found("farm", 1, 2, 3)), Set.of(rider.uuid()), true); + var result = new HusbandryEntityScan.Result(Map.of(moved.uuid(), new HusbandryEntityScan.Found("nether", 9, 8, 7), same.uuid(), new HusbandryEntityScan.Found("farm", 1, 2, 3)), Set.of(rider.uuid()), List.of()); try (Scan scan = new Scan(result)) { HusbandryLocator.scanUnloaded(); scan.background(); assertEquals(List.of(new File(f.world.getWorldFolder(), "entities"), new File(nether.getWorldFolder(), "DIM-1/entities"), new File(end.getWorldFolder(), "DIM1/entities")), scan.worlds.stream().map(HusbandryEntityScan.WorldDir::entities).toList()); @@ -180,14 +182,16 @@ class HusbandryRuntimeCoverageTest { @Test void scanDoesNotOverwriteNewerLoadsMovesDeletesOrUnscannedWorlds() { HusbandryAnimal loaded = f.ownedOffline("Loaded"), changed = f.ownedOffline("Changed"), deleted = f.ownedOffline("Deleted"), foreign = f.ownedOffline("Foreign"); foreign.setLastLocation("unscanned", 1, 2, 3); f.repository.upsertAnimal(foreign); - try (Scan scan = new Scan(new HusbandryEntityScan.Result(Map.of(), Set.of(), true))) { + try (Scan scan = new Scan(new HusbandryEntityScan.Result(Map.of(), Set.of(), List.of()))) { HusbandryLocator.scanUnloaded(); scan.background(); HusbandryEntities.putLoaded(loaded); changed.setUnloadedAt(999L); f.repository.upsertAnimal(changed); f.repository.deleteAnimal(deleted.uuid()); scan.apply(); } assertTrue(f.repository.exists(loaded.uuid())); assertEquals(999L, f.repository.getAnimal(changed.uuid()).orElseThrow().unloadedAt()); assertTrue(f.repository.exists(foreign.uuid())); HusbandryEntities.clearLoaded(); - try (Scan scan = new Scan(new HusbandryEntityScan.Result(Map.of(), Set.of(), false))) { HusbandryLocator.scanUnloaded(); scan.background(); scan.apply(); } + try (Scan scan = new Scan(new HusbandryEntityScan.Result(Map.of(), Set.of(), UNREAD_PLAYER))) { HusbandryLocator.scanUnloaded(); scan.background(); scan.apply(); } assertTrue(f.repository.exists(loaded.uuid())); assertTrue(f.repository.exists(changed.uuid())); + // Kept but shown as Missing while a rider's file could not be read. + assertTrue(HusbandryLocator.isMissing(loaded.uuid())); assertTrue(HusbandryLocator.isMissing(changed.uuid())); assertFalse(HusbandryLocator.isMissing(foreign.uuid())); } @Test void scanWaitsForSnapshotRestorationAndKeepsFailedRecordsForRetry() { @@ -196,7 +200,7 @@ class HusbandryRuntimeCoverageTest { doThrow(new SqliteDatabaseException("delete failure")).when(f.repository).deleteAnimal(failedDrop.uuid()); doThrow(new SqliteDatabaseException("write failure")).when(f.repository).upsertAnimal(argThat(a -> a != null && a.uuid().equals(failedMove.uuid()))); try (MockedStatic snapshots = mockStatic(HusbandrySnapshots.class, CALLS_REAL_METHODS); - Scan scan = new Scan(new HusbandryEntityScan.Result(Map.of(failedMove.uuid(), new HusbandryEntityScan.Found("farm", 3, 4, 5)), Set.of(), true))) { + Scan scan = new Scan(new HusbandryEntityScan.Result(Map.of(failedMove.uuid(), new HusbandryEntityScan.Found("farm", 3, 4, 5)), Set.of(), List.of()))) { snapshots.when(() -> HusbandrySnapshots.restore(eq(f.repository), argThat(a -> a != null && a.uuid().equals(restoring.uuid())), anyList())).thenReturn(true); HusbandryLocator.scanUnloaded(); scan.background(); scan.apply(); } @@ -205,7 +209,7 @@ class HusbandryRuntimeCoverageTest { } @Test void scanningStopsAcrossRepositoryAndPluginShutdownBoundaries() { - try (Scan scan = new Scan(new HusbandryEntityScan.Result(Map.of(), Set.of(), true))) { + try (Scan scan = new Scan(new HusbandryEntityScan.Result(Map.of(), Set.of(), List.of()))) { HusbandryLocator.scanUnloaded(); assertTrue(scan.async.isEmpty()); HusbandryAnimal owned = f.ownedOffline("Pending"); HusbandryEntities.putLoaded(owned); HusbandryLocator.scanUnloaded(); assertTrue(scan.async.isEmpty()); HusbandryEntities.clearLoaded(); when(f.plugin.getHusbandryRepository()).thenReturn(null); HusbandryLocator.scanUnloaded(); assertTrue(scan.async.isEmpty()); when(f.plugin.getHusbandryRepository()).thenReturn(f.repository); HusbandryLocator.scanUnloaded(); when(f.plugin.isEnabled()).thenReturn(false); scan.background(); assertTrue(scan.sync.isEmpty()); @@ -213,7 +217,7 @@ class HusbandryRuntimeCoverageTest { HusbandryLocator.scanUnloaded(); scan.background(); when(f.plugin.getHusbandryRepository()).thenReturn(null); scan.apply(); assertTrue(f.repository.exists(owned.uuid())); } HusbandryLocator.markMissing(null); HusbandryLocator.markFound(null); assertFalse(HusbandryLocator.isMissing(null)); - assertTrue(HusbandryLocator.isConfirmedGhost(true, null, List.of("farm"))); assertTrue(HusbandryLocator.isConfirmedGhost(true, " ", List.of("farm"))); assertFalse(HusbandryLocator.isConfirmedGhost(true, "farm", null)); + assertTrue(HusbandryLocator.inScannedWorld(null, List.of("farm"))); assertTrue(HusbandryLocator.inScannedWorld(" ", List.of("farm"))); assertFalse(HusbandryLocator.inScannedWorld("farm", null)); HusbandryAnimal unnamed = new HusbandryAnimal(UUID.randomUUID(), "", ""); assertTrue(HusbandryLocator.describe(unnamed, null).startsWith("(unnamed) (unknown)")); assertTrue(HusbandryLocator.ghostLog(unnamed, List.of()).endsWith("last seen unknown")); } From a1a7b46806638e7ab3bebe8fb35db55e50eb4e50 Mon Sep 17 00:00:00 2001 From: XxFran10xX <318299142+XxFran10xX@users.noreply.github.com> Date: Sat, 31 Oct 2026 12:21:29 +0100 Subject: [PATCH 2/2] test: skip player files the server set aside as corrupted Co-Authored-By: Claude Opus 5.5 --- .../cooking/husbandry/HusbandryEntityScanTest.java | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/src/test/java/net/tfminecraft/cooking/husbandry/HusbandryEntityScanTest.java b/src/test/java/net/tfminecraft/cooking/husbandry/HusbandryEntityScanTest.java index ecda8f7..20233b1 100644 --- a/src/test/java/net/tfminecraft/cooking/husbandry/HusbandryEntityScanTest.java +++ b/src/test/java/net/tfminecraft/cooking/husbandry/HusbandryEntityScanTest.java @@ -164,13 +164,15 @@ void unreadablePlayerFilesMakeTheScanIncomplete(@TempDir Path dir) throws IOExce } @Test - void leftoverTempPlayerFilesAreNotRead(@TempDir Path dir) throws IOException { + void onlyRealPlayerFilesAreRead(@TempDir Path dir) throws IOException { File playerdata = dir.resolve("playerdata").toFile(); playerdata.mkdirs(); Files.write(new File(playerdata, RIDER + ".dat").toPath(), gzip(player(entity(HORSE, 5.5, 64.0, 5.5)))); // An interrupted vanilla save leaves -.dat with only a gzip header. Files.write(new File(playerdata, RIDER + "-7214243180096765426.dat").toPath(), new byte[] {0x1F, (byte) 0x8B, 8, 0, 0, 0, 0, 0, 0, (byte) 0xFF}); + // A player file the server could not load is kept as _corrupted_.dat. + Files.write(new File(playerdata, RIDER + "_corrupted_2026-10-31_11-12-59.dat").toPath(), new byte[] {1, 2, 3}); Files.write(new File(playerdata, "notes.dat").toPath(), new byte[] {1, 2, 3}); HusbandryEntityScan.Result result = HusbandryEntityScan.scan(