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 @@ -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;
Expand All @@ -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<UUID, Found> found, Set<UUID> ridden, boolean complete) {}
record Result(Map<UUID, Found> found, Set<UUID> ridden, List<String> 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 <uuid>.dat}. An interrupted save leaves a truncated temp file
* named {@code <uuid>-<number>.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;
Expand All @@ -48,17 +62,17 @@ static Result scan(List<WorldDir> worlds, Set<UUID> targets) {
static Result scan(List<WorldDir> worlds, File playerdata, Set<UUID> targets) {
Map<UUID, Found> found = new HashMap<>();
Set<UUID> ridden = new HashSet<>();
boolean complete = true;
List<String> 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()) {
continue;
}
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) {
Expand All @@ -68,22 +82,22 @@ static Result scan(List<WorldDir> worlds, File playerdata, Set<UUID> 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<UUID> targets, Set<UUID> ridden) {
private static void scanPlayers(File playerdata, Set<UUID> targets, Set<UUID> ridden, List<String> 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)))
Expand All @@ -92,50 +106,58 @@ private static boolean scanPlayers(File playerdata, Set<UUID> targets, Set<UUID>
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<UUID> targets, Map<UUID, Found> 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<UUID> targets,
Map<UUID, Found> found, List<String> 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)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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 {

Expand Down Expand Up @@ -97,6 +99,7 @@ private static void apply(
int dropped = 0;
int missing = 0;
int held = 0;
int unsure = 0;
List<String> scannedWorlds = worlds.stream().map(HusbandryEntityScan.WorldDir::world).toList();
for (Map.Entry<UUID, Long> target : targets.entrySet()) {
UUID uuid = target.getKey();
Expand All @@ -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 {
Expand Down Expand Up @@ -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<String> scannedWorlds) {
if (!scanComplete) {
return false;
static final int LOGGED_PROBLEMS = 10;

static String unreadableLog(List<String> 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<String> scannedWorlds) {
if (storedWorld == null || storedWorld.isBlank()) {
return true;
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -163,6 +163,51 @@ void unreadablePlayerFilesMakeTheScanIncomplete(@TempDir Path dir) throws IOExce
assertTrue(result.ridden().isEmpty());
}

@Test
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 <uuid>-<number>.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 <uuid>_corrupted_<date>.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(
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<String> 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(
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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<String> 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
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -55,7 +55,7 @@ class HusbandryMaintenanceRegressionTest {
};
Logger logger = Bukkit.getLogger(); logger.addHandler(handler);
try (MockedStatic<HusbandrySnapshots> 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); }
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -97,21 +97,21 @@

@Test void damageRulesDistinguishOwnersStaffStrangersMobsProjectilesAndEnvironment() throws Exception {
var listener = new HusbandryDamageListener(); Cow cow = f.spawn(Cow.class);
var unmanaged = new EntityDamageEvent(cow, EntityDamageEvent.DamageCause.FALL, 2); listener.onDamage(unmanaged); assertFalse(unmanaged.isCancelled());

Check warning on line 100 in src/test/java/net/tfminecraft/cooking/husbandry/HusbandryRemainingCoverageTest.java

View workflow job for this annotation

GitHub Actions / build

EntityDamageEvent(org.bukkit.entity.Entity,org.bukkit.event.entity.EntityDamageEvent.DamageCause,double) in org.bukkit.event.entity.EntityDamageEvent has been deprecated and marked for removal
HusbandryAnimal animal = f.record(cow); f.own(animal, f.player);
for (String key : List.of("owner", "other-players", "mobs", "environment")) f.config.set("damage." + key, false); f.reload();
assertTrue(attack(listener, cow, f.player)); assertTrue(attack(listener, cow, f.neighbour)); assertTrue(attack(listener, cow, f.spawn(Cow.class)));
f.neighbour.setOp(true); assertFalse(attack(listener, cow, f.neighbour));
Arrow arrow = mock(Arrow.class); when(arrow.getShooter()).thenReturn(f.player); assertTrue(attack(listener, cow, arrow)); when(arrow.getShooter()).thenReturn(null); assertFalse(attack(listener, cow, arrow));
assertFalse(attack(listener, cow, f.spawn(ItemDisplay.class)));
var fall = new EntityDamageEvent(cow, EntityDamageEvent.DamageCause.FALL, 2); listener.onDamage(fall); assertTrue(fall.isCancelled());

Check warning on line 107 in src/test/java/net/tfminecraft/cooking/husbandry/HusbandryRemainingCoverageTest.java

View workflow job for this annotation

GitHub Actions / build

EntityDamageEvent(org.bukkit.entity.Entity,org.bukkit.event.entity.EntityDamageEvent.DamageCause,double) in org.bukkit.event.entity.EntityDamageEvent has been deprecated and marked for removal
var custom = new EntityDamageEvent(cow, EntityDamageEvent.DamageCause.CUSTOM, 2); listener.onDamage(custom); assertFalse(custom.isCancelled());

Check warning on line 108 in src/test/java/net/tfminecraft/cooking/husbandry/HusbandryRemainingCoverageTest.java

View workflow job for this annotation

GitHub Actions / build

EntityDamageEvent(org.bukkit.entity.Entity,org.bukkit.event.entity.EntityDamageEvent.DamageCause,double) in org.bukkit.event.entity.EntityDamageEvent has been deprecated and marked for removal
for (String key : List.of("owner", "other-players", "mobs", "environment")) f.config.set("damage." + key, true); f.reload();
f.neighbour.setOp(false); assertFalse(attack(listener, cow, f.player)); assertFalse(attack(listener, cow, f.neighbour)); assertFalse(attack(listener, cow, f.spawn(Cow.class)));
when(f.plugin.getHusbandryRepository()).thenReturn(null); assertFalse(attack(listener, cow, f.player));
}
boolean attack(HusbandryDamageListener listener, Entity victim, Entity attacker) {
var event = new EntityDamageByEntityEvent(attacker, victim, EntityDamageEvent.DamageCause.ENTITY_ATTACK, 2); listener.onDamage(event); return event.isCancelled();

Check warning on line 114 in src/test/java/net/tfminecraft/cooking/husbandry/HusbandryRemainingCoverageTest.java

View workflow job for this annotation

GitHub Actions / build

EntityDamageByEntityEvent(org.bukkit.entity.Entity,org.bukkit.entity.Entity,org.bukkit.event.entity.EntityDamageEvent.DamageCause,double) in org.bukkit.event.entity.EntityDamageByEntityEvent has been deprecated and marked for removal
}

@Test void deathCleansRecordsReplacesMatureRoastsAndPreservesVanillaWool() throws Exception {
Expand Down Expand Up @@ -409,7 +409,7 @@
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");
}
Expand Down Expand Up @@ -467,7 +467,7 @@
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");
Expand Down
Loading
Loading