diff --git a/src/main/java/net/tfminecraft/cooking/husbandry/HusbandryConfig.java b/src/main/java/net/tfminecraft/cooking/husbandry/HusbandryConfig.java index 6373360..2356607 100644 --- a/src/main/java/net/tfminecraft/cooking/husbandry/HusbandryConfig.java +++ b/src/main/java/net/tfminecraft/cooking/husbandry/HusbandryConfig.java @@ -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 amountBands = List.of(); private static double mountSpeedMinPct = 0.40; private static double mountSpeedGeneticsPct = 0.30; @@ -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; diff --git a/src/main/java/net/tfminecraft/cooking/husbandry/HusbandryEntityScan.java b/src/main/java/net/tfminecraft/cooking/husbandry/HusbandryEntityScan.java index 7fb29e6..3a8792e 100644 --- a/src/main/java/net/tfminecraft/cooking/husbandry/HusbandryEntityScan.java +++ b/src/main/java/net/tfminecraft/cooking/husbandry/HusbandryEntityScan.java @@ -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; @@ -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 { @@ -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 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 found, Set ridden, boolean complete) {} private static final Pattern REGION = Pattern.compile("r\\.(-?\\d{1,7})\\.(-?\\d{1,7})\\.mca"); private static final int SECTOR = 4096; @@ -37,10 +42,15 @@ record Result(Map found, boolean complete) {} private HusbandryEntityScan() {} static Result scan(List worlds, Set targets) { + return scan(worlds, null, targets); + } + + static Result scan(List worlds, File playerdata, Set targets) { Map found = new HashMap<>(); + Set 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()) { @@ -61,7 +71,31 @@ static Result scan(List worlds, Set 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 targets, Set 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 vehicle = new HashMap<>(); + readPlayer(in, targets, vehicle); + ridden.addAll(vehicle.keySet()); + } catch (IOException | RuntimeException ex) { + complete = false; + } + } + return complete; } private static boolean scanRegion( @@ -179,6 +213,31 @@ private static void readEntityChunk(DataInputStream in, String world, Set } } + private static void readPlayer(DataInputStream in, Set targets, Map 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 targets, Map found) throws IOException { byte element = in.readByte(); diff --git a/src/main/java/net/tfminecraft/cooking/husbandry/HusbandryLifecycleListener.java b/src/main/java/net/tfminecraft/cooking/husbandry/HusbandryLifecycleListener.java index 64bf189..10fa467 100644 --- a/src/main/java/net/tfminecraft/cooking/husbandry/HusbandryLifecycleListener.java +++ b/src/main/java/net/tfminecraft/cooking/husbandry/HusbandryLifecycleListener.java @@ -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; @@ -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) { @@ -100,10 +122,17 @@ public static void flushLoadedForDisable() { } long now = System.currentTimeMillis(); List toSave = new ArrayList<>(snapshot.size()); + Map 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); @@ -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) { @@ -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)); + } + } } } diff --git a/src/main/java/net/tfminecraft/cooking/husbandry/HusbandryLoader.java b/src/main/java/net/tfminecraft/cooking/husbandry/HusbandryLoader.java index 5b7c796..5715b34 100644 --- a/src/main/java/net/tfminecraft/cooking/husbandry/HusbandryLoader.java +++ b/src/main/java/net/tfminecraft/cooking/husbandry/HusbandryLoader.java @@ -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)); HusbandryConfig.setProfessionExp( config.getString("profession", "farming"), parseExpBracket(config.getConfigurationSection("exp"), 6, 8)); @@ -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)); } } diff --git a/src/main/java/net/tfminecraft/cooking/husbandry/HusbandryLocator.java b/src/main/java/net/tfminecraft/cooking/husbandry/HusbandryLocator.java index f0629ce..818e05f 100644 --- a/src/main/java/net/tfminecraft/cooking/husbandry/HusbandryLocator.java +++ b/src/main/java/net/tfminecraft/cooking/husbandry/HusbandryLocator.java @@ -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 { @@ -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(); @@ -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)); } @@ -85,6 +93,7 @@ private static void apply( return; } int located = 0; + int restoring = 0; int dropped = 0; int missing = 0; List scannedWorlds = worlds.stream().map(HusbandryEntityScan.WorldDir::world).toList(); @@ -98,6 +107,11 @@ 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(); @@ -105,6 +119,19 @@ private static void apply( 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); @@ -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).")); } } @@ -167,19 +194,23 @@ static String deleteGhost(HusbandryRepository repository, HusbandryAnimal animal } static String ghostLog(HusbandryAnimal animal, List 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 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) { diff --git a/src/main/java/net/tfminecraft/cooking/husbandry/HusbandryRepository.java b/src/main/java/net/tfminecraft/cooking/husbandry/HusbandryRepository.java index 3037fd2..af56457 100644 --- a/src/main/java/net/tfminecraft/cooking/husbandry/HusbandryRepository.java +++ b/src/main/java/net/tfminecraft/cooking/husbandry/HusbandryRepository.java @@ -8,6 +8,7 @@ import java.util.ArrayList; import java.util.Collection; import java.util.List; +import java.util.Map; import java.util.Optional; import java.util.Random; import java.util.UUID; @@ -56,6 +57,15 @@ FOREIGN KEY (animal_uuid) REFERENCES animals(uuid) ON DELETE CASCADE ) """; + private static final String CREATE_SNAPSHOTS = """ + CREATE TABLE IF NOT EXISTS snapshots ( + animal_uuid TEXT PRIMARY KEY, + data BLOB NOT NULL, + saved_at INTEGER NOT NULL, + FOREIGN KEY (animal_uuid) REFERENCES animals(uuid) ON DELETE CASCADE + ) + """; + private static final String INDEX_OWNERS_PLAYER = """ CREATE INDEX IF NOT EXISTS owners_by_player ON owners(player_uuid) @@ -108,6 +118,14 @@ ON CONFLICT(animal_uuid, player_uuid) DO UPDATE SET role = excluded.role private static final String DELETE_OWNER = "DELETE FROM owners WHERE animal_uuid = ? AND player_uuid = ?"; private static final String COUNT_PLAYER = "SELECT COUNT(*) FROM owners WHERE player_uuid = ?"; private static final String LIST_OWNERS = "SELECT animal_uuid, player_uuid, role FROM owners WHERE animal_uuid = ?"; + // Skips animals whose row is already gone, so a late snapshot cannot trip the foreign key. + private static final String UPSERT_SNAPSHOT = """ + INSERT INTO snapshots (animal_uuid, data, saved_at) + SELECT ?, ?, ? WHERE EXISTS (SELECT 1 FROM animals WHERE uuid = ?) + ON CONFLICT(animal_uuid) DO UPDATE SET data = excluded.data, saved_at = excluded.saved_at + """; + private static final String SELECT_SNAPSHOT = "SELECT data, saved_at FROM snapshots WHERE animal_uuid = ?"; + private static final String DELETE_SNAPSHOT = "DELETE FROM snapshots WHERE animal_uuid = ?"; private final SqliteDatabase database; @@ -139,6 +157,7 @@ private void initSchema() { database.execute(CREATE_ANIMALS); database.execute(CREATE_OWNERS); database.execute(INDEX_OWNERS_PLAYER); + database.execute(CREATE_SNAPSHOTS); migrateSchema(); } @@ -355,6 +374,44 @@ WHERE EXISTS (SELECT 1 FROM owners o WHERE o.animal_uuid = a.uuid) HusbandryRepository::mapAnimal); } + /** Stores the serialized entities in one transaction; animals without a row are skipped. */ + public void upsertSnapshots(Map snapshots, long savedAt) { + if (snapshots == null || snapshots.isEmpty()) { + return; + } + database.runTransaction(connection -> { + try (PreparedStatement statement = connection.prepareStatement(UPSERT_SNAPSHOT)) { + for (Map.Entry snapshot : snapshots.entrySet()) { + if (snapshot.getKey() == null || snapshot.getValue() == null) { + continue; + } + String uuid = snapshot.getKey().toString(); + bindParams(statement, uuid, snapshot.getValue(), savedAt, uuid); + statement.executeUpdate(); + } + } catch (SQLException e) { + throw new SqliteDatabaseException("Failed to upsert snapshots", e); + } + }); + } + + public Optional getSnapshot(UUID uuid) { + if (uuid == null) { + return Optional.empty(); + } + return queryOne( + SELECT_SNAPSHOT, + result -> new HusbandrySnapshot(result.getBytes("data"), result.getLong("saved_at")), + uuid.toString()); + } + + public void deleteSnapshot(UUID uuid) { + if (uuid == null) { + return; + } + executeUpdate(DELETE_SNAPSHOT, uuid.toString()); + } + public void checkpointWal(boolean truncate) { String mode = truncate ? "TRUNCATE" : "PASSIVE"; database.execute("PRAGMA wal_checkpoint(" + mode + ")"); diff --git a/src/main/java/net/tfminecraft/cooking/husbandry/HusbandrySnapshot.java b/src/main/java/net/tfminecraft/cooking/husbandry/HusbandrySnapshot.java new file mode 100644 index 0000000..95bfdba --- /dev/null +++ b/src/main/java/net/tfminecraft/cooking/husbandry/HusbandrySnapshot.java @@ -0,0 +1,4 @@ +package net.tfminecraft.cooking.husbandry; + +/** A serialized copy of an owned animal's entity, taken while it was loaded. */ +public record HusbandrySnapshot(byte[] data, long savedAt) {} diff --git a/src/main/java/net/tfminecraft/cooking/husbandry/HusbandrySnapshots.java b/src/main/java/net/tfminecraft/cooking/husbandry/HusbandrySnapshots.java new file mode 100644 index 0000000..4377f48 --- /dev/null +++ b/src/main/java/net/tfminecraft/cooking/husbandry/HusbandrySnapshots.java @@ -0,0 +1,134 @@ +package net.tfminecraft.cooking.husbandry; + +import java.time.Instant; +import java.util.List; +import java.util.Map; +import java.util.Optional; +import java.util.UUID; + +import org.bukkit.Bukkit; +import org.bukkit.Location; +import org.bukkit.World; +import org.bukkit.entity.Entity; +import org.bukkit.event.entity.CreatureSpawnEvent; +import org.bukkit.event.entity.EntityRemoveEvent; + +import io.papermc.paper.entity.EntitySerializationFlag; +import net.tfminecraft.cooking.Cooking; +import net.tfminecraft.tlibs.database.SqliteDatabaseException; + +/** + * Keeps a serialized copy of every loaded owned animal so one that a crash or forced stop + * drops from the world save can be spawned again, instead of being deleted as a ghost. + */ +public final class HusbandrySnapshots { + + private HusbandrySnapshots() {} + + public static boolean shouldCapture(HusbandryAnimal animal) { + return animal != null && animal.state() == HusbandryAnimalState.OWNED; + } + + /** Returns the entity bytes, or null when Paper refuses to serialize it. */ + @SuppressWarnings("deprecation") + public static byte[] capture(Entity entity, boolean force) { + if (entity == null) { + return null; + } + try { + return force + ? Bukkit.getUnsafe().serializeEntity(entity, EntitySerializationFlag.FORCE) + : Bukkit.getUnsafe().serializeEntity(entity); + } catch (RuntimeException ex) { + return null; + } + } + + public static void save(HusbandryRepository repository, Map snapshots) { + if (repository == null || snapshots.isEmpty()) { + return; + } + try { + repository.upsertSnapshots(snapshots, System.currentTimeMillis()); + } catch (SqliteDatabaseException ex) { + Bukkit.getLogger().severe("[Cooking] Failed to save husbandry snapshots: " + ex.getMessage()); + } + } + + /** + * Chunk unloads and riders logging out leave the animal in a save file, so its snapshot stays. + * Any other removal is deliberate, and restoring the animal later would bring it back. + */ + public static boolean keepsSnapshot(EntityRemoveEvent.Cause cause) { + return cause == EntityRemoveEvent.Cause.UNLOAD || cause == EntityRemoveEvent.Cause.PLAYER_QUIT; + } + + /** + * Spawns a lost animal from its snapshot at the place it was captured. + * Returns false when there is nothing to restore from, so the caller falls back to dropping it. + */ + @SuppressWarnings("deprecation") + static boolean restore(HusbandryRepository repository, HusbandryAnimal animal, List owners) { + if (!HusbandryConfig.restoreLostAnimals() || repository == null || animal == null + || Cooking.plugin == null) { + return false; + } + World world = animal.world() == null ? null : Bukkit.getWorld(animal.world()); + if (world == null) { + return false; + } + Optional snapshot = repository.getSnapshot(animal.uuid()); + if (snapshot.isEmpty()) { + return false; + } + Entity entity; + try { + entity = Bukkit.getUnsafe().deserializeEntity(snapshot.get().data(), world, true); + } catch (RuntimeException ex) { + Bukkit.getLogger().severe("[Cooking] Could not read the snapshot of " + animal.uuid() + + ": " + ex.getMessage()); + return false; + } + if (entity == null || !entity.getUniqueId().equals(animal.uuid())) { + return false; + } + Location at = entity.getLocation(); + at.setWorld(world); + world.getChunkAtAsync(at.getBlockX() >> 4, at.getBlockZ() >> 4).whenComplete((chunk, error) -> { + if (Cooking.plugin == null || !Cooking.plugin.isEnabled()) { + return; + } + Bukkit.getScheduler().runTask(Cooking.plugin, () -> { + if (error != null) { + Bukkit.getLogger().severe("[Cooking] Could not load the chunk to restore " + animal.uuid() + + ": " + error.getMessage()); + HusbandryLocator.markMissing(animal.uuid()); + return; + } + finish(animal, owners, entity, at, snapshot.get().savedAt()); + }); + }); + return true; + } + + static void finish(HusbandryAnimal animal, List owners, Entity entity, Location at, long savedAt) { + if (Bukkit.getEntity(animal.uuid()) != null) { + // It came back with its chunk after all, so spawning the copy would duplicate it. + return; + } + if (!entity.spawnAt(at, CreatureSpawnEvent.SpawnReason.CUSTOM)) { + Bukkit.getLogger().warning("[Cooking] Could not respawn lost animal " + animal.uuid() + + "; it stays missing and is retried at the next start."); + HusbandryLocator.markMissing(animal.uuid()); + return; + } + HusbandryLifecycleListener.handleLoad(entity); + Bukkit.getLogger().warning(restoreLog(animal, owners, at, savedAt)); + } + + static String restoreLog(HusbandryAnimal animal, List owners, Location at, long savedAt) { + return "[Cooking] Restored lost animal " + HusbandryLocator.describe(animal, owners) + + " at " + at.getWorld().getName() + " " + at.getBlockX() + ", " + at.getBlockY() + ", " + + at.getBlockZ() + " from its snapshot of " + Instant.ofEpochMilli(savedAt); + } +} diff --git a/src/main/java/net/tfminecraft/cooking/husbandry/HusbandryTickTask.java b/src/main/java/net/tfminecraft/cooking/husbandry/HusbandryTickTask.java index a73bfcf..91c6fd9 100644 --- a/src/main/java/net/tfminecraft/cooking/husbandry/HusbandryTickTask.java +++ b/src/main/java/net/tfminecraft/cooking/husbandry/HusbandryTickTask.java @@ -1,7 +1,10 @@ package net.tfminecraft.cooking.husbandry; import java.util.ArrayList; +import java.util.HashMap; import java.util.List; +import java.util.Map; +import java.util.UUID; import org.bukkit.Bukkit; import org.bukkit.entity.Entity; @@ -41,6 +44,7 @@ private static void tick() { } long now = System.currentTimeMillis(); List dirty = new ArrayList<>(); + Map snapshots = new HashMap<>(); for (HusbandryAnimal animal : HusbandryEntities.snapshotLoaded()) { Entity entity = Bukkit.getEntity(animal.uuid()); if (!(entity instanceof LivingEntity) || entity.isDead()) { @@ -58,6 +62,12 @@ private static void tick() { HusbandryEggs.tryLay(living, animal, now); dirty.add(animal); HusbandryStateDisplay.sync(living, animal); + if (HusbandrySnapshots.shouldCapture(animal)) { + byte[] snapshot = HusbandrySnapshots.capture(living, false); + if (snapshot != null) { + snapshots.put(animal.uuid(), snapshot); + } + } } if (dirty.isEmpty()) { return; @@ -67,5 +77,6 @@ private static void tick() { } catch (SqliteDatabaseException ex) { Bukkit.getLogger().severe("[Cooking] Failed to persist husbandry tick: " + ex.getMessage()); } + HusbandrySnapshots.save(repository, snapshots); } } diff --git a/src/main/resources/husbandry.yml b/src/main/resources/husbandry.yml index 47f4bf5..3401d62 100644 --- a/src/main/resources/husbandry.yml +++ b/src/main/resources/husbandry.yml @@ -41,6 +41,8 @@ max-genetics: 1000 min-roast-cuts: 1 # Bump this string to wild-reset animals with a missing or old id (genetics + care). stats-revision: "1" +# Respawn an owned animal from its last snapshot when a crash or forced stop loses it from the world save. +restore-lost-animals: true breeding: # Bigger = larger gene bump per generation. diff --git a/src/test/java/net/tfminecraft/cooking/husbandry/HusbandryEntityScanTest.java b/src/test/java/net/tfminecraft/cooking/husbandry/HusbandryEntityScanTest.java index 9044044..90f0af0 100644 --- a/src/test/java/net/tfminecraft/cooking/husbandry/HusbandryEntityScanTest.java +++ b/src/test/java/net/tfminecraft/cooking/husbandry/HusbandryEntityScanTest.java @@ -131,6 +131,38 @@ void outOfRangeChunkHeadersAreUnreadable(@TempDir Path dir) throws IOException { assertTrue(result.found().isEmpty()); } + @Test + void animalsUnderALoggedOutRiderCountAsRidden(@TempDir Path dir) throws IOException { + File entities = dir.resolve("entities").toFile(); + entities.mkdirs(); + 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)))); + // Paper's backup copy is stale and must not count. + Files.write(new File(playerdata, RIDER + ".dat_old").toPath(), gzip(player(entity(COW, 1.0, 64.0, 1.0)))); + + HusbandryEntityScan.Result result = HusbandryEntityScan.scan( + List.of(new HusbandryEntityScan.WorldDir("TFMC_Map", entities)), playerdata, Set.of(HORSE, COW)); + + assertTrue(result.complete()); + assertEquals(Set.of(HORSE), result.ridden()); + assertTrue(result.found().isEmpty()); + } + + @Test + void unreadablePlayerFilesMakeTheScanIncomplete(@TempDir Path dir) throws IOException { + 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", dir.resolve("entities").toFile())), + playerdata, Set.of(HORSE)); + + assertFalse(result.complete()); + assertTrue(result.ridden().isEmpty()); + } + @Test void missingFolderIsSkipped(@TempDir Path dir) { HusbandryEntityScan.Result result = HusbandryEntityScan.scan( @@ -190,6 +222,31 @@ private static Body entity(UUID uuid, double x, double y, double z, Body... pass }; } + private static byte[] player(Body vehicle) throws IOException { + ByteArrayOutputStream bytes = new ByteArrayOutputStream(); + DataOutputStream out = new DataOutputStream(bytes); + out.writeByte(10); + out.writeUTF(""); + out.writeByte(8); + out.writeUTF("Dimension"); + out.writeUTF("minecraft:overworld"); + out.writeByte(10); + out.writeUTF("RootVehicle"); + out.writeByte(11); + out.writeUTF("Attach"); + out.writeInt(4); + out.writeInt(1); + out.writeInt(2); + out.writeInt(3); + out.writeInt(4); + out.writeByte(10); + out.writeUTF("Entity"); + vehicle.write(out); + out.writeByte(0); + out.writeByte(0); + return bytes.toByteArray(); + } + private static byte[] chunk(Body... entities) throws IOException { ByteArrayOutputStream bytes = new ByteArrayOutputStream(); DataOutputStream out = new DataOutputStream(bytes); diff --git a/src/test/java/net/tfminecraft/cooking/husbandry/HusbandryRepositoryTest.java b/src/test/java/net/tfminecraft/cooking/husbandry/HusbandryRepositoryTest.java index 1fe20cf..1b0b782 100644 --- a/src/test/java/net/tfminecraft/cooking/husbandry/HusbandryRepositoryTest.java +++ b/src/test/java/net/tfminecraft/cooking/husbandry/HusbandryRepositoryTest.java @@ -262,6 +262,42 @@ private static String roleOf(List rows, UUID animalUuid) { .role(); } + @Test + void snapshotsRoundTripAndGoWithTheirAnimal(@TempDir Path tempDir) { + File dbFile = tempDir.resolve("husbandry.db").toFile(); + HusbandryRepository repository = HusbandryRepository.open(dbFile); + try { + HusbandryAnimal kept = animal(UUID.randomUUID(), "HORSE", "Drake Maye", 200); + HusbandryAnimal dropped = animal(UUID.randomUUID(), "HORSE", "gregor", 200); + UUID noRow = UUID.randomUUID(); + repository.upsertAnimals(List.of(kept, dropped)); + + repository.upsertSnapshots( + java.util.Map.of(kept.uuid(), new byte[] {1, 2, 3}, dropped.uuid(), new byte[] {4}, noRow, + new byte[] {5}), + 100L); + HusbandrySnapshot stored = repository.getSnapshot(kept.uuid()).orElseThrow(); + assertTrue(Arrays.equals(new byte[] {1, 2, 3}, stored.data())); + assertEquals(100L, stored.savedAt()); + assertTrue(repository.getSnapshot(noRow).isEmpty()); + + repository.upsertSnapshots(java.util.Map.of(kept.uuid(), new byte[] {9}), 200L); + stored = repository.getSnapshot(kept.uuid()).orElseThrow(); + assertTrue(Arrays.equals(new byte[] {9}, stored.data())); + assertEquals(200L, stored.savedAt()); + + repository.deleteAnimal(dropped.uuid()); + assertTrue(repository.getSnapshot(dropped.uuid()).isEmpty()); + + repository.deleteSnapshot(kept.uuid()); + assertTrue(repository.getSnapshot(kept.uuid()).isEmpty()); + assertTrue(repository.exists(kept.uuid())); + assertTrue(repository.getSnapshot(null).isEmpty()); + } finally { + repository.close(); + } + } + private static HusbandryAnimal animal(UUID uuid, String type, String name, int care) { HusbandryAnimal animal = new HusbandryAnimal(uuid, type, name); animal.setCare(care); diff --git a/src/test/java/net/tfminecraft/cooking/husbandry/HusbandrySnapshotsTest.java b/src/test/java/net/tfminecraft/cooking/husbandry/HusbandrySnapshotsTest.java new file mode 100644 index 0000000..9d194de --- /dev/null +++ b/src/test/java/net/tfminecraft/cooking/husbandry/HusbandrySnapshotsTest.java @@ -0,0 +1,251 @@ +package net.tfminecraft.cooking.husbandry; + +import static org.junit.jupiter.api.Assertions.assertArrayEquals; +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertNull; +import static org.junit.jupiter.api.Assertions.assertTrue; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.anyInt; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.mockStatic; +import static org.mockito.Mockito.never; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +import java.util.List; +import java.util.Optional; +import java.util.UUID; +import java.util.concurrent.CompletableFuture; +import java.util.logging.Logger; + +import org.bukkit.Bukkit; +import org.bukkit.Chunk; +import org.bukkit.Location; +import org.bukkit.UnsafeValues; +import org.bukkit.World; +import org.bukkit.entity.Horse; +import org.bukkit.event.entity.CreatureSpawnEvent; +import org.bukkit.event.entity.EntityRemoveEvent; +import org.bukkit.scheduler.BukkitScheduler; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.mockito.MockedStatic; + +import io.papermc.paper.entity.EntitySerializationFlag; +import net.tfminecraft.cooking.Cooking; + +class HusbandrySnapshotsTest { + + private static final UUID HORSE = UUID.fromString("fe3e69ee-6182-4036-bfcb-450779909327"); + private static final UUID OWNER = UUID.fromString("0615a817-8cb4-4aef-95f7-f6c9bf7611b8"); + + private Cooking previousPlugin; + private Cooking plugin; + + @BeforeEach + void setUp() { + previousPlugin = Cooking.plugin; + plugin = mock(Cooking.class); + when(plugin.isEnabled()).thenReturn(true); + Cooking.plugin = plugin; + HusbandryConfig.setRestoreLostAnimals(true); + } + + @AfterEach + void tearDown() { + Cooking.plugin = previousPlugin; + HusbandryConfig.setRestoreLostAnimals(true); + HusbandryLocator.markFound(HORSE); + } + + @Test + void onlyUnloadsAndLoggedOutRidersKeepTheSnapshot() { + for (EntityRemoveEvent.Cause cause : EntityRemoveEvent.Cause.values()) { + boolean keeps = cause == EntityRemoveEvent.Cause.UNLOAD || cause == EntityRemoveEvent.Cause.PLAYER_QUIT; + assertEquals(keeps, HusbandrySnapshots.keepsSnapshot(cause), cause.name()); + } + } + + @Test + void onlyOwnedAnimalsAreCaptured() { + HusbandryAnimal animal = new HusbandryAnimal(HORSE, "HORSE", "Drake Maye"); + animal.setState(HusbandryAnimalState.OWNED); + assertTrue(HusbandrySnapshots.shouldCapture(animal)); + animal.setState(HusbandryAnimalState.UNTAMED); + assertFalse(HusbandrySnapshots.shouldCapture(animal)); + assertFalse(HusbandrySnapshots.shouldCapture(null)); + } + + @Test + void captureForcesOnlyWhenAskedAndSwallowsRefusals() { + Horse horse = mock(Horse.class); + UnsafeValues unsafe = mock(UnsafeValues.class); + when(unsafe.serializeEntity(horse)).thenReturn(new byte[] {1}); + when(unsafe.serializeEntity(horse, EntitySerializationFlag.FORCE)).thenReturn(new byte[] {2}); + try (MockedStatic bukkit = mockStatic(Bukkit.class)) { + bukkit.when(Bukkit::getUnsafe).thenReturn(unsafe); + + assertArrayEquals(new byte[] {1}, HusbandrySnapshots.capture(horse, false)); + assertArrayEquals(new byte[] {2}, HusbandrySnapshots.capture(horse, true)); + assertNull(HusbandrySnapshots.capture(null, false)); + + when(unsafe.serializeEntity(horse)).thenThrow(new IllegalArgumentException("not persistent")); + assertNull(HusbandrySnapshots.capture(horse, false)); + } + } + + @Test + void restoreSpawnsTheSnapshotWhereItWasTaken() { + HusbandryAnimal animal = lostHorse(); + World world = mock(World.class); + when(world.getName()).thenReturn("TFMC_Map"); + when(world.getChunkAtAsync(anyInt(), anyInt())) + .thenReturn(CompletableFuture.completedFuture(mock(Chunk.class))); + Horse restored = mock(Horse.class); + when(restored.getUniqueId()).thenReturn(HORSE); + when(restored.getLocation()).thenReturn(new Location(null, 4353.0, 355.0, 3601.2)); + when(restored.spawnAt(any(Location.class), any(CreatureSpawnEvent.SpawnReason.class))).thenReturn(true); + UnsafeValues unsafe = mock(UnsafeValues.class); + when(unsafe.deserializeEntity(new byte[] {7}, world, true)).thenReturn(restored); + HusbandryRepository repository = mock(HusbandryRepository.class); + when(repository.getSnapshot(HORSE)).thenReturn(Optional.of(new HusbandrySnapshot(new byte[] {7}, 0L))); + + try (MockedStatic bukkit = serverWith(world, unsafe); + MockedStatic lifecycle = mockStatic(HusbandryLifecycleListener.class)) { + assertTrue(HusbandrySnapshots.restore(repository, animal, owners())); + + verify(world).getChunkAtAsync(4353 >> 4, 3601 >> 4); + verify(restored).spawnAt(new Location(world, 4353.0, 355.0, 3601.2), + CreatureSpawnEvent.SpawnReason.CUSTOM); + lifecycle.verify(() -> HusbandryLifecycleListener.handleLoad(restored)); + } + } + + @Test + void restoreFallsBackWhenThereIsNothingToSpawn() { + HusbandryAnimal animal = lostHorse(); + World world = mock(World.class); + HusbandryRepository repository = mock(HusbandryRepository.class); + when(repository.getSnapshot(HORSE)).thenReturn(Optional.empty()); + try (MockedStatic bukkit = serverWith(world, mock(UnsafeValues.class))) { + assertFalse(HusbandrySnapshots.restore(repository, animal, owners())); + + when(repository.getSnapshot(HORSE)).thenReturn(Optional.of(new HusbandrySnapshot(new byte[] {7}, 0L))); + HusbandryConfig.setRestoreLostAnimals(false); + assertFalse(HusbandrySnapshots.restore(repository, animal, owners())); + + HusbandryConfig.setRestoreLostAnimals(true); + bukkit.when(() -> Bukkit.getWorld("TFMC_Map")).thenReturn(null); + assertFalse(HusbandrySnapshots.restore(repository, animal, owners())); + } + verify(world, never()).getChunkAtAsync(anyInt(), anyInt()); + } + + @Test + void restoreRefusesASnapshotOfAnotherEntity() { + HusbandryAnimal animal = lostHorse(); + World world = mock(World.class); + Horse other = mock(Horse.class); + when(other.getUniqueId()).thenReturn(UUID.randomUUID()); + UnsafeValues unsafe = mock(UnsafeValues.class); + when(unsafe.deserializeEntity(new byte[] {7}, world, true)).thenReturn(other); + HusbandryRepository repository = mock(HusbandryRepository.class); + when(repository.getSnapshot(HORSE)).thenReturn(Optional.of(new HusbandrySnapshot(new byte[] {7}, 0L))); + try (MockedStatic bukkit = serverWith(world, unsafe)) { + assertFalse(HusbandrySnapshots.restore(repository, animal, owners())); + + when(unsafe.deserializeEntity(new byte[] {7}, world, true)).thenThrow(new IllegalStateException("bad")); + assertFalse(HusbandrySnapshots.restore(repository, animal, owners())); + } + verify(world, never()).getChunkAtAsync(anyInt(), anyInt()); + } + + @Test + void anAnimalThatCameBackIsNotDuplicated() { + HusbandryAnimal animal = lostHorse(); + Horse copy = mock(Horse.class); + try (MockedStatic bukkit = mockStatic(Bukkit.class); + MockedStatic lifecycle = mockStatic(HusbandryLifecycleListener.class)) { + bukkit.when(() -> Bukkit.getEntity(HORSE)).thenReturn(mock(Horse.class)); + + HusbandrySnapshots.finish(animal, owners(), copy, new Location(null, 0, 0, 0), 0L); + + verify(copy, never()).spawnAt(any(Location.class), any(CreatureSpawnEvent.SpawnReason.class)); + lifecycle.verifyNoInteractions(); + } + } + + @Test + void aBlockedSpawnLeavesTheAnimalMissing() { + HusbandryAnimal animal = lostHorse(); + Horse copy = mock(Horse.class); + when(copy.spawnAt(any(Location.class), any(CreatureSpawnEvent.SpawnReason.class))).thenReturn(false); + try (MockedStatic bukkit = mockStatic(Bukkit.class); + MockedStatic lifecycle = mockStatic(HusbandryLifecycleListener.class)) { + bukkit.when(Bukkit::getLogger).thenReturn(Logger.getLogger("test")); + + HusbandrySnapshots.finish(animal, owners(), copy, new Location(null, 0, 0, 0), 0L); + + assertTrue(HusbandryLocator.isMissing(HORSE)); + lifecycle.verifyNoInteractions(); + } + } + + @Test + void deliberateRemovalOfATrackedAnimalDropsItsSnapshot() { + HusbandryRepository repository = mock(HusbandryRepository.class); + UUID stranger = UUID.randomUUID(); + try (MockedStatic entities = mockStatic(HusbandryEntities.class)) { + entities.when(HusbandryEntities::repository).thenReturn(repository); + entities.when(() -> HusbandryEntities.getLoaded(HORSE)).thenReturn(Optional.of(lostHorse())); + entities.when(() -> HusbandryEntities.getLoaded(stranger)).thenReturn(Optional.empty()); + + HusbandryLifecycleListener.handleRemove(HORSE, EntityRemoveEvent.Cause.UNLOAD); + HusbandryLifecycleListener.handleRemove(HORSE, EntityRemoveEvent.Cause.PLAYER_QUIT); + verify(repository, never()).deleteSnapshot(HORSE); + + HusbandryLifecycleListener.handleRemove(HORSE, EntityRemoveEvent.Cause.PLUGIN); + verify(repository).deleteSnapshot(HORSE); + + HusbandryLifecycleListener.handleRemove(stranger, EntityRemoveEvent.Cause.DISCARD); + verify(repository, never()).deleteSnapshot(stranger); + } + } + + @Test + void restoreLogNamesTheAnimalOwnersPlaceAndSnapshotTime() { + World world = mock(World.class); + when(world.getName()).thenReturn("TFMC_Map"); + String line = HusbandrySnapshots.restoreLog( + lostHorse(), owners(), new Location(world, 4353.0, 355.0, 3601.2), 1_790_000_000_000L); + assertEquals("[Cooking] Restored lost animal Drake Maye (HORSE) " + HORSE + " owners=" + OWNER + + " (owner) at TFMC_Map 4353, 355, 3601 from its snapshot of 2026-09-21T14:13:20Z", line); + } + + private MockedStatic serverWith(World world, UnsafeValues unsafe) { + MockedStatic bukkit = mockStatic(Bukkit.class); + BukkitScheduler scheduler = mock(BukkitScheduler.class); + when(scheduler.runTask(any(org.bukkit.plugin.Plugin.class), any(Runnable.class))).thenAnswer(call -> { + call.getArgument(1, Runnable.class).run(); + return null; + }); + bukkit.when(() -> Bukkit.getWorld("TFMC_Map")).thenReturn(world); + bukkit.when(Bukkit::getUnsafe).thenReturn(unsafe); + bukkit.when(Bukkit::getScheduler).thenReturn(scheduler); + bukkit.when(Bukkit::getLogger).thenReturn(Logger.getLogger("test")); + return bukkit; + } + + private static HusbandryAnimal lostHorse() { + HusbandryAnimal animal = new HusbandryAnimal(HORSE, "HORSE", "Drake Maye"); + animal.setState(HusbandryAnimalState.OWNED); + animal.setLastLocation("TFMC_Map", 4352, 355, 3601); + return animal; + } + + private static List owners() { + return List.of(new HusbandryOwner(HORSE, OWNER, "owner")); + } +}