From 38886cc9347b8990221a09a2488ec0c841f8974f Mon Sep 17 00:00:00 2001 From: Ryan Barlow <7389646+ryanbarlow97@users.noreply.github.com> Date: Tue, 29 Sep 2026 22:13:17 +0000 Subject: [PATCH 1/2] fix: restore owned animals lost from the world save A forced stop can kill Paper before it saves chunks, so an owned animal that moved since the last save disappears. The startup scan then deleted its row as a ghost, which also emptied /animals. Keep a serialized copy of each loaded owned animal (every minute, on unload, and at disable). When the scan confirms an owned animal is gone, respawn it from that copy with its UUID, name, coat, gear and Cooking record. Animals saved under a logged-out rider (playerdata RootVehicle) now count as present. Deliberate removals drop the copy; deaths delete it with the row. Co-Authored-By: Claude Opus 5.5 (1M context) --- .../cooking/husbandry/HusbandryConfig.java | 9 + .../husbandry/HusbandryEntityScan.java | 69 ++++- .../husbandry/HusbandryLifecycleListener.java | 36 +++ .../cooking/husbandry/HusbandryLoader.java | 1 + .../cooking/husbandry/HusbandryLocator.java | 49 +++- .../husbandry/HusbandryRepository.java | 57 ++++ .../cooking/husbandry/HusbandrySnapshot.java | 4 + .../cooking/husbandry/HusbandrySnapshots.java | 134 ++++++++++ .../cooking/husbandry/HusbandryTickTask.java | 11 + src/main/resources/husbandry.yml | 2 + .../husbandry/HusbandryEntityScanTest.java | 57 ++++ .../husbandry/HusbandryRepositoryTest.java | 36 +++ .../husbandry/HusbandrySnapshotsTest.java | 251 ++++++++++++++++++ 13 files changed, 702 insertions(+), 14 deletions(-) create mode 100644 src/main/java/net/tfminecraft/cooking/husbandry/HusbandrySnapshot.java create mode 100644 src/main/java/net/tfminecraft/cooking/husbandry/HusbandrySnapshots.java create mode 100644 src/test/java/net/tfminecraft/cooking/husbandry/HusbandrySnapshotsTest.java 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..20bb1fa 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)); 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")); + } +} From ce56b4790f5fb15f83935ea5db1c1c1a75a25fdf Mon Sep 17 00:00:00 2001 From: Ryan Barlow <7389646+ryanbarlow97@users.noreply.github.com> Date: Tue, 29 Sep 2026 22:24:30 +0000 Subject: [PATCH 2/2] fix: reset restore-lost-animals when husbandry.yml fails to load Co-Authored-By: Claude Opus 5.5 (1M context) --- .../java/net/tfminecraft/cooking/husbandry/HusbandryLoader.java | 1 + 1 file changed, 1 insertion(+) diff --git a/src/main/java/net/tfminecraft/cooking/husbandry/HusbandryLoader.java b/src/main/java/net/tfminecraft/cooking/husbandry/HusbandryLoader.java index 20bb1fa..5715b34 100644 --- a/src/main/java/net/tfminecraft/cooking/husbandry/HusbandryLoader.java +++ b/src/main/java/net/tfminecraft/cooking/husbandry/HusbandryLoader.java @@ -330,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)); } }