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 @@ -49,6 +49,7 @@ public final class HusbandryConfig {
private static double geneticSlowdownDivisor = 1;
private static double careInfluence = 0.02;
private static String statsRevision = "1";
private static boolean restoreLostAnimals = true;
private static List<HusbandryAmountBand> amountBands = List.of();
private static double mountSpeedMinPct = 0.40;
private static double mountSpeedGeneticsPct = 0.30;
Expand Down Expand Up @@ -157,6 +158,14 @@ public static void setStatsRevision(String revision) {
statsRevision = revision == null ? "" : revision.trim();
}

public static void setRestoreLostAnimals(boolean enabled) {
restoreLostAnimals = enabled;
}

public static boolean restoreLostAnimals() {
return restoreLostAnimals;
}

public static void setProfessionExp(String profession, HusbandryExpBracket bracket) {
professionId = profession == null ? "" : profession.trim();
defaultExp = bracket == null ? HusbandryExpBracket.of(6, 8) : bracket;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@
import java.io.InputStream;
import java.nio.file.Files;
import java.util.HashMap;
import java.util.HashSet;
import java.util.List;
import java.util.Map;
import java.util.Set;
Expand All @@ -17,7 +18,8 @@
import java.util.zip.InflaterInputStream;

/**
* Reads saved entity chunks ({@code entities/r.X.Z.mca}) to find where unloaded animals are.
* Reads saved entity chunks ({@code entities/r.X.Z.mca}) to find where unloaded animals are,
* and player files for animals saved with a rider who logged out ({@code RootVehicle}).
* Runs off the main thread; it only reads files.
*/
final class HusbandryEntityScan {
Expand All @@ -26,8 +28,11 @@ record WorldDir(String world, File entities) {}

record Found(String world, int x, int y, int z) {}

/** {@code complete} is false when some chunk could not be read, so a missing animal may still exist. */
record Result(Map<UUID, Found> found, boolean complete) {}
/**
* {@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.
*/
record Result(Map<UUID, Found> found, Set<UUID> ridden, boolean complete) {}

private static final Pattern REGION = Pattern.compile("r\\.(-?\\d{1,7})\\.(-?\\d{1,7})\\.mca");
private static final int SECTOR = 4096;
Expand All @@ -37,10 +42,15 @@ record Result(Map<UUID, Found> found, boolean complete) {}
private HusbandryEntityScan() {}

static Result scan(List<WorldDir> worlds, Set<UUID> targets) {
return scan(worlds, null, 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;
if (targets.isEmpty()) {
return new Result(found, true);
return new Result(found, ridden, true);
}
for (WorldDir world : worlds) {
if (!world.entities().exists()) {
Expand All @@ -61,7 +71,31 @@ static Result scan(List<WorldDir> worlds, Set<UUID> targets) {
complete &= scanRegion(world, file, regionX, regionZ, targets, found);
}
}
return new Result(found, complete);
complete &= scanPlayers(playerdata, targets, ridden);
return new Result(found, ridden, complete);
}

private static boolean scanPlayers(File playerdata, Set<UUID> targets, Set<UUID> ridden) {
if (playerdata == null || !playerdata.exists()) {
return true;
}
File[] files = playerdata.listFiles((dir, name) -> name.endsWith(".dat"));
if (files == null) {
return false;
}
boolean complete = true;
for (File file : files) {
try (DataInputStream in = new DataInputStream(new ByteArrayInputStream(
new GZIPInputStream(new ByteArrayInputStream(readBounded(file, MAX_CHUNK_BYTES)))
.readNBytes(MAX_CHUNK_BYTES)))) {
Map<UUID, Found> vehicle = new HashMap<>();
readPlayer(in, targets, vehicle);
ridden.addAll(vehicle.keySet());
} catch (IOException | RuntimeException ex) {
complete = false;
}
}
return complete;
}

private static boolean scanRegion(
Expand Down Expand Up @@ -179,6 +213,31 @@ private static void readEntityChunk(DataInputStream in, String world, Set<UUID>
}
}

private static void readPlayer(DataInputStream in, Set<UUID> targets, Map<UUID, Found> found)
throws IOException {
if (in.readByte() != COMPOUND) {
return;
}
in.readUTF();
byte type;
while ((type = in.readByte()) != END) {
String key = in.readUTF();
if (type == COMPOUND && key.equals("RootVehicle")) {
byte inner;
while ((inner = in.readByte()) != END) {
String innerKey = in.readUTF();
if (inner == COMPOUND && innerKey.equals("Entity")) {
readEntity(in, "", targets, found);
} else {
skip(in, inner);
}
}
} else {
skip(in, type);
}
}
}

private static void readEntityList(DataInputStream in, String world, Set<UUID> targets, Map<UUID, Found> found)
throws IOException {
byte element = in.readByte();
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,9 @@
import org.bukkit.entity.Entity;
import org.bukkit.entity.LivingEntity;
import org.bukkit.event.EventHandler;
import org.bukkit.event.EventPriority;
import org.bukkit.event.Listener;
import org.bukkit.event.entity.EntityRemoveEvent;
import org.bukkit.event.world.EntitiesLoadEvent;
import org.bukkit.event.world.EntitiesUnloadEvent;

Expand All @@ -35,6 +37,26 @@ public void onEntitiesUnload(EntitiesUnloadEvent event) {
}
}

@EventHandler(priority = EventPriority.MONITOR)
public void onEntityRemove(EntityRemoveEvent event) {
handleRemove(event.getEntity().getUniqueId(), event.getCause());
}

static void handleRemove(UUID uuid, EntityRemoveEvent.Cause cause) {
if (HusbandrySnapshots.keepsSnapshot(cause) || HusbandryEntities.getLoaded(uuid).isEmpty()) {
return;
}
HusbandryRepository repository = HusbandryEntities.repository();
if (repository == null) {
return;
}
try {
repository.deleteSnapshot(uuid);
} catch (SqliteDatabaseException ex) {
Bukkit.getLogger().severe("[Cooking] Failed to drop the snapshot of " + uuid + ": " + ex.getMessage());
}
}

public static void applyStatsRevision() {
HusbandryRepository repository = HusbandryEntities.repository();
if (repository == null) {
Expand Down Expand Up @@ -100,10 +122,17 @@ public static void flushLoadedForDisable() {
}
long now = System.currentTimeMillis();
List<HusbandryAnimal> toSave = new ArrayList<>(snapshot.size());
Map<UUID, byte[]> copies = new HashMap<>();
for (HusbandryAnimal animal : snapshot) {
Entity entity = Bukkit.getEntity(animal.uuid());
if (entity != null) {
HusbandryLocation.remember(animal, entity);
if (HusbandrySnapshots.shouldCapture(animal)) {
byte[] copy = HusbandrySnapshots.capture(entity, false);
if (copy != null) {
copies.put(animal.uuid(), copy);
}
}
}
animal.setUnloadedAt(now);
toSave.add(animal);
Expand All @@ -113,6 +142,7 @@ public static void flushLoadedForDisable() {
} catch (SqliteDatabaseException ex) {
Bukkit.getLogger().severe("[Cooking] Failed to flush husbandry animals on disable: " + ex.getMessage());
}
HusbandrySnapshots.save(repository, copies);
}

static void handleLoad(Entity entity) {
Expand Down Expand Up @@ -185,5 +215,11 @@ static void handleUnload(Entity entity) {
HusbandryLocation.remember(animal, entity);
animal.setUnloadedAt(System.currentTimeMillis());
repository.upsertAnimal(animal);
if (HusbandrySnapshots.shouldCapture(animal)) {
byte[] copy = HusbandrySnapshots.capture(entity, true);
if (copy != null) {
HusbandrySnapshots.save(repository, Map.of(uuid, copy));
}
}
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -82,6 +82,7 @@ public void load(File file) {
config.getDouble("breeding.genetic-slowdown-divisor", 0.4),
config.getDouble("breeding.care-influence", 0.02));
HusbandryConfig.setStatsRevision(config.getString("stats-revision", "1"));
HusbandryConfig.setRestoreLostAnimals(config.getBoolean("restore-lost-animals", true));
Comment thread
coderabbitai[bot] marked this conversation as resolved.
HusbandryConfig.setProfessionExp(
config.getString("profession", "farming"),
parseExpBracket(config.getConfigurationSection("exp"), 6, 8));
Expand Down Expand Up @@ -329,6 +330,7 @@ private static void applyDefaults() {
HusbandryConfig.setMountSpeedShares(0.40, 0.30, 0.20);
HusbandryConfig.setBreeding(0.4, 0.4, 0.02);
HusbandryConfig.setStatsRevision("1");
HusbandryConfig.setRestoreLostAnimals(true);
HusbandryConfig.setProfessionExp("farming", HusbandryExpBracket.of(6, 8));
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -22,7 +22,8 @@
/**
* Finds owned animals sitting in unloaded chunks by reading the saved entity chunks,
* 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.
* 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.
*/
public final class HusbandryLocator {

Expand All @@ -40,6 +41,12 @@ static void markFound(UUID uuid) {
}
}

static void markMissing(UUID uuid) {
if (uuid != null) {
MISSING.add(uuid);
}
}

/** Scans once in the background. Call on the main thread after loaded worlds have resumed. */
public static void scanUnloaded() {
HusbandryRepository repository = HusbandryEntities.repository();
Expand All @@ -59,8 +66,9 @@ public static void scanUnloaded() {
for (World world : Bukkit.getWorlds()) {
worlds.add(new HusbandryEntityScan.WorldDir(world.getName(), entitiesFolder(world)));
}
File playerdata = new File(Bukkit.getWorlds().get(0).getWorldFolder(), "playerdata");
Bukkit.getScheduler().runTaskAsynchronously(Cooking.plugin, () -> {
HusbandryEntityScan.Result result = HusbandryEntityScan.scan(worlds, targets.keySet());
HusbandryEntityScan.Result result = HusbandryEntityScan.scan(worlds, playerdata, targets.keySet());
if (Cooking.plugin != null && Cooking.plugin.isEnabled()) {
Bukkit.getScheduler().runTask(Cooking.plugin, () -> apply(targets, result, worlds));
}
Expand All @@ -85,6 +93,7 @@ private static void apply(
return;
}
int located = 0;
int restoring = 0;
int dropped = 0;
int missing = 0;
List<String> scannedWorlds = worlds.stream().map(HusbandryEntityScan.WorldDir::world).toList();
Expand All @@ -98,13 +107,31 @@ private static void apply(
// Loaded or unloaded again during the scan; the stored location is newer.
continue;
}
if (result.ridden().contains(uuid)) {
// Saved with a rider who logged out; it comes back when they join.
MISSING.remove(uuid);
continue;
}
HusbandryEntityScan.Found found = result.found().get(uuid);
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)) {
continue;
}
try {
if (HusbandrySnapshots.restore(repository, animal, repository.listOwners(uuid))) {
MISSING.remove(uuid);
restoring++;
continue;
}
} catch (SqliteDatabaseException ex) {
Bukkit.getLogger().severe("[Cooking] Failed to read the snapshot of " + uuid
+ ": " + ex.getMessage());
MISSING.add(uuid);
missing++;
continue;
}
try {
String line = deleteGhost(repository, animal);
Bukkit.getLogger().warning(line);
Expand All @@ -130,12 +157,12 @@ private static void apply(
Bukkit.getLogger().severe("[Cooking] Failed to save husbandry animal location: " + ex.getMessage());
}
}
if (located > 0 || dropped > 0 || missing > 0 || !result.complete()) {
if (located > 0 || restoring > 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
Bukkit.getLogger().info("[Cooking] Animal scan: updated " + located + " locations, restoring "
+ restoring + " lost animals from snapshots, dropped " + dropped + " ghost animals" + failed
+ (result.complete() ? "." : " (some chunks could not be read, none dropped)."));
}
}
Expand Down Expand Up @@ -167,19 +194,23 @@ static String deleteGhost(HusbandryRepository repository, HusbandryAnimal animal
}

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";
return "[Cooking] Dropped ghost animal " + describe(animal, owners) + " last seen " + place;
}

/** Name, type, uuid and owners, as the ghost and restore log lines print them. */
static String describe(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 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;
return name + " (" + type + ") " + animal.uuid() + " owners=" + ownerText;
}

private static boolean sameLocation(HusbandryAnimal animal, HusbandryEntityScan.Found found) {
Expand Down
Loading