From a8abef7d7e5366ebe2a1bdac7773e1b5085e665b Mon Sep 17 00:00:00 2001 From: Ryan Barlow <7389646+ryanbarlow97@users.noreply.github.com> Date: Tue, 13 Oct 2026 21:27:24 +0000 Subject: [PATCH 1/4] feat: bind husbandry ownership and estates to characters --- .gitignore | 2 + pom.xml | 2 +- .../java/net/tfminecraft/cooking/Cooking.java | 1 + .../husbandry/HusbandryAnimalsCommand.java | 21 +- .../cooking/husbandry/HusbandryEstate.java | 185 ++++++++++++ .../cooking/husbandry/HusbandryIdentity.java | 56 ++++ .../husbandry/HusbandryInspectGui.java | 8 +- .../husbandry/HusbandryInspectHolder.java | 8 +- .../husbandry/HusbandryInspectListener.java | 4 + .../cooking/husbandry/HusbandryLocator.java | 2 +- .../cooking/husbandry/HusbandryMigration.java | 24 ++ .../husbandry/HusbandryMountListener.java | 44 +++ .../cooking/husbandry/HusbandryOwner.java | 17 +- .../husbandry/HusbandryOwnershipService.java | 55 ++-- .../husbandry/HusbandryOwnershipStore.java | 274 ++++++++++++++++++ .../husbandry/HusbandryRepository.java | 97 ++++--- .../husbandry/HusbandryTamingListener.java | 4 + .../cooking/CookingLifecycleTest.java | 1 + .../HusbandryCharacterAuthorityTest.java | 242 ++++++++++++++++ .../husbandry/HusbandryEstateTest.java | 156 ++++++++++ .../HusbandryInspectionCoverageTest.java | 10 +- .../HusbandryListenersCoverageTest.java | 9 +- .../husbandry/HusbandryLocatorTest.java | 8 +- .../HusbandryOwnershipMigrationTest.java | 203 +++++++++++++ .../HusbandryRemainingCoverageTest.java | 6 +- .../husbandry/HusbandryRepositoryTest.java | 8 +- .../HusbandryRuntimeCoverageTest.java | 13 +- .../husbandry/HusbandrySnapshotsTest.java | 2 +- tools/husbandry-ownership-rollback.py | 64 ++++ tools/test_husbandry_ownership_rollback.py | 72 +++++ 30 files changed, 1501 insertions(+), 97 deletions(-) create mode 100644 src/main/java/net/tfminecraft/cooking/husbandry/HusbandryEstate.java create mode 100644 src/main/java/net/tfminecraft/cooking/husbandry/HusbandryIdentity.java create mode 100644 src/main/java/net/tfminecraft/cooking/husbandry/HusbandryMigration.java create mode 100644 src/main/java/net/tfminecraft/cooking/husbandry/HusbandryOwnershipStore.java create mode 100644 src/test/java/net/tfminecraft/cooking/husbandry/HusbandryCharacterAuthorityTest.java create mode 100644 src/test/java/net/tfminecraft/cooking/husbandry/HusbandryEstateTest.java create mode 100644 src/test/java/net/tfminecraft/cooking/husbandry/HusbandryOwnershipMigrationTest.java create mode 100644 tools/husbandry-ownership-rollback.py create mode 100644 tools/test_husbandry_ownership_rollback.py diff --git a/.gitignore b/.gitignore index c046410..32620c2 100644 --- a/.gitignore +++ b/.gitignore @@ -62,3 +62,5 @@ Desktop.ini # Technical documentation is maintained in TF-Minecraft/Docs /docs/ + +/tools/__pycache__/ diff --git a/pom.xml b/pom.xml index 3228671..835c402 100644 --- a/pom.xml +++ b/pom.xml @@ -15,7 +15,7 @@ 0.2.0 2.16.0 4.0.0 - 2.4.1 + 2.6.0 diff --git a/src/main/java/net/tfminecraft/cooking/Cooking.java b/src/main/java/net/tfminecraft/cooking/Cooking.java index 299d907..ff94c83 100644 --- a/src/main/java/net/tfminecraft/cooking/Cooking.java +++ b/src/main/java/net/tfminecraft/cooking/Cooking.java @@ -154,6 +154,7 @@ public void onEnable() { TLibs.getItemAPI().registerPathHandler("c", CookingPathHandler.INSTANCE); openHusbandryDatabase(); HusbandryLifecycleListener.applyStatsRevision(); + net.tfminecraft.cooking.husbandry.HusbandryIdentity.start(this, husbandryRepository); registerListeners(); cookingManager.start(); diff --git a/src/main/java/net/tfminecraft/cooking/husbandry/HusbandryAnimalsCommand.java b/src/main/java/net/tfminecraft/cooking/husbandry/HusbandryAnimalsCommand.java index 374e2ac..50e9db4 100644 --- a/src/main/java/net/tfminecraft/cooking/husbandry/HusbandryAnimalsCommand.java +++ b/src/main/java/net/tfminecraft/cooking/husbandry/HusbandryAnimalsCommand.java @@ -34,6 +34,7 @@ public boolean onCommand(CommandSender sender, Command command, String label, St } boolean self = args.length == 0; UUID targetId; + net.tfminecraft.tlibs.character.CharacterRef targetCharacter = null; String ownerName; if (self) { if (!(sender instanceof Player player)) { @@ -41,7 +42,12 @@ public boolean onCommand(CommandSender sender, Command command, String label, St return true; } targetId = player.getUniqueId(); - ownerName = player.getName(); + targetCharacter = HusbandryIdentity.session(player).map(session -> session.ref()).orElse(null); + if (targetCharacter == null) { + sender.sendMessage(Component.text("Choose a character before listing animals.", NamedTextColor.RED)); + return true; + } + ownerName = HusbandryIdentity.name(targetCharacter); } else { OfflinePlayer offline = Bukkit.getOfflinePlayerIfCached(args[0]); if (offline == null) { @@ -52,7 +58,7 @@ public boolean onCommand(CommandSender sender, Command command, String label, St ownerName = offline.getName() == null ? args[0] : offline.getName(); } List rows = new ArrayList<>(); - for (HusbandryOwned owned : repository.listForPlayer(targetId)) { + for (HusbandryOwned owned : self ? repository.listForCharacter(targetCharacter) : repository.listForPlayer(targetId)) { HusbandryAnimal animal = HusbandryEntities.getLoaded(owned.animal().uuid()).orElse(owned.animal()); Entity live = Bukkit.getEntity(animal.uuid()); if (live != null) { @@ -60,9 +66,14 @@ public boolean onCommand(CommandSender sender, Command command, String label, St } rows.add(new HusbandryOwned(animal, owned.role())); } - for (Component line : HusbandryRoster.render( - ownerName, self, rows, HusbandryConfig.maxAnimals(), System.currentTimeMillis(), - HusbandryLocator::isMissing)) { + List lines = HusbandryRoster.render(ownerName, self, rows, HusbandryConfig.maxAnimals(), + System.currentTimeMillis(), HusbandryLocator::isMissing); + if (self) { + lines.set(0, Component.text("Your character's animals (" + rows.size() + ")", NamedTextColor.GOLD)); + lines.add(1, Component.text("Account animal places: " + repository.countForPlayer(targetId) + + "/" + HusbandryConfig.maxAnimals(), NamedTextColor.GRAY)); + } + for (Component line : lines) { sender.sendMessage(line); } return true; diff --git a/src/main/java/net/tfminecraft/cooking/husbandry/HusbandryEstate.java b/src/main/java/net/tfminecraft/cooking/husbandry/HusbandryEstate.java new file mode 100644 index 0000000..583009c --- /dev/null +++ b/src/main/java/net/tfminecraft/cooking/husbandry/HusbandryEstate.java @@ -0,0 +1,185 @@ +package net.tfminecraft.cooking.husbandry; + +import java.sql.Connection; +import java.sql.SQLException; +import java.util.HashMap; +import java.util.List; +import java.util.Map; +import java.util.UUID; +import java.util.function.Predicate; +import java.util.function.Supplier; +import java.util.logging.Logger; + +import org.bukkit.plugin.Plugin; +import org.bukkit.plugin.ServicePriority; +import net.tfminecraft.tlibs.character.CharacterRef; +import net.tfminecraft.tlibs.character.estate.EstateDecision; +import net.tfminecraft.tlibs.character.estate.EstateParticipant; +import net.tfminecraft.tlibs.character.estate.Estates; +import net.tfminecraft.tlibs.character.estate.Holdings; +import net.tfminecraft.tlibs.character.estate.Outcome; +import net.tfminecraft.tlibs.database.SqliteDatabaseException; + +import static net.tfminecraft.cooking.husbandry.HusbandryOwnershipStore.*; + +/** A whole estate's slots, collision merges and receipts commit together; no capacity rejection. */ +final class HusbandryEstate implements EstateParticipant { + static final String ID = "cooking-animals"; + private final HusbandryRepository repository; + private final Logger logger; + Predicate guard = Estates::guardHolds; + Supplier> decided = Estates::decided; + interface Reporter { boolean report(UUID death, String id, long revision, Outcome outcome); } + Reporter reporter = Estates::report; + private final Map> reported = new HashMap<>(); + + HusbandryEstate(HusbandryRepository repository, Logger logger) { + this.repository = repository; + this.logger = logger; + + } + + static void start(Plugin plugin, HusbandryRepository repository) { + HusbandryEstate estate = new HusbandryEstate(repository, plugin.getLogger()); + plugin.getServer().getServicesManager().register(EstateParticipant.class, estate, plugin, ServicePriority.Normal); + plugin.getServer().getScheduler().runTaskTimer(plugin, estate::reconcile, 2, 20L * 60 * 60); + } + + @Override public String id() { return ID; } + + @Override public Outcome settle(EstateDecision decision) { + if (!guard.test(decision)) return Outcome.retry("Animal estate guard does not hold"); + var db = repository.ownershipDatabase(); + try { + if (decision.property().character().isEmpty()) return receipts(decision); + CharacterRef to = decision.property().character().orElseThrow(); + db.runTransaction(connection -> { + try { + if (scalar(connection, "SELECT COUNT(*) FROM husbandry_estate_intents WHERE status != 'done' AND (death_id = ? OR from_character = ?)", + decision.deathId().toString(), decision.testator().characterId().toString()) != 0) { + throw new Held("Incomplete animal estate intent requires staff review"); + } + List receipts = rows(connection, + "SELECT decision FROM husbandry_estate_intents WHERE death_id = ? AND revision = ?", + decision.deathId().toString(), decision.revision()); + if (!receipts.isEmpty()) { + if (!decision.toString().equals(receipts.get(0)[0])) throw new Held("Decision conflicts with stored animal receipt"); + return; + } + List source = rows(connection, + "SELECT animal_uuid, account_uuid, role FROM character_owners WHERE character_uuid = ?", + decision.testator().characterId().toString()); + update(connection, "INSERT INTO husbandry_estate_intents VALUES (?, ?, ?, ?, ?, ?, ?, 'prepared')", + decision.deathId().toString(), decision.revision(), decision.toString(), + decision.testator().ownerId().toString(), decision.testator().characterId().toString(), + to.ownerId().toString(), to.characterId().toString()); + for (String[] row : source) { + if (!row[1].equals(decision.testator().ownerId().toString()) || !recognized(row[2])) { + throw new Held("Conflicting testator slot for animal " + row[0]); + } + String[] recipient = slot(connection, row[0], to.characterId().toString()); + if (recipient != null && (!recipient[0].equals(to.ownerId().toString()) || !recognized(recipient[1]))) { + throw new Held("Conflicting recipient slot for animal " + row[0]); + } + String role = recipient == null ? row[2] : stronger(row[2], recipient[1]); + if (!guard.test(decision)) throw new GuardChanged(); + archiveSlot(connection, decision, row[0], decision.testator().characterId(), "source"); + if (recipient != null) archiveSlot(connection, decision, row[0], to.characterId(), "recipient"); + put(connection, row[0], to, role); + if (!to.equals(decision.testator())) update(connection, + "DELETE FROM character_owners WHERE animal_uuid = ? AND character_uuid = ? AND account_uuid = ?", + row[0], decision.testator().characterId().toString(), decision.testator().ownerId().toString()); + update(connection, "INSERT INTO husbandry_estate_slots VALUES (?, ?, ?, ?, ?, ?)", + decision.deathId().toString(), decision.revision(), row[0], row[2], + recipient == null ? null : recipient[1], role); + } + if (!guard.test(decision)) throw new GuardChanged(); + update(connection, "UPDATE husbandry_estate_intents SET status = 'done' WHERE death_id = ? AND revision = ?", + decision.deathId().toString(), decision.revision()); + } catch (SQLException ex) { + throw new SqliteDatabaseException("Could not commit animal estate", ex); + } + }); + return receipts(decision); + } catch (Held ex) { + return Outcome.blocked(ex.getMessage()); + } catch (GuardChanged ex) { + return Outcome.retry("Animal estate changed; transaction rolled back"); + } catch (RuntimeException | SQLException ex) { + logger.warning("Animal estate rolled back: " + ex.getMessage()); + return Outcome.retry("Could not save animal estate: " + ex.getMessage()); + } + } + + /** Completed transfers remain visible even when a later property revision is frozen or waiting. */ + private Outcome receipts(EstateDecision decision) throws SQLException { + var db = repository.ownershipDatabase(); + synchronized (db) { + Connection connection = db.getConnection(); + if (scalar(connection, "SELECT COUNT(*) FROM husbandry_estate_intents WHERE status != 'done' AND (death_id = ? OR from_character = ?)", + decision.deathId().toString(), decision.testator().characterId().toString()) != 0) { + throw new Held("Incomplete animal estate intent requires staff review"); + } + List saved = rows(connection, "SELECT decision FROM husbandry_estate_intents WHERE death_id = ? AND revision = ?", + decision.deathId().toString(), decision.revision()); + if (!saved.isEmpty() && !decision.toString().equals(saved.getFirst()[0])) throw new Held("Decision conflicts with stored animal receipt"); + List moved = rows(connection, + "SELECT DISTINCT animal_uuid FROM husbandry_estate_slots WHERE death_id = ? ORDER BY animal_uuid", + decision.deathId().toString()).stream().map(row -> row[0]).toList(); + return moved.isEmpty() ? Outcome.done() : Outcome.done(Map.of("animals", moved.size()), moved); + } + } + + /** Independent raw rows retain unknown nullable/BLOB metadata even after the source slot is deleted. */ + private static void archiveSlot(Connection connection, EstateDecision decision, String animal, UUID character, + String position) throws SQLException { + update(connection, "CREATE TABLE IF NOT EXISTS husbandry_estate_owner_archive AS SELECT * FROM character_owners WHERE 0"); + List columns = rows(connection, "PRAGMA table_info(character_owners)").stream().map(row -> row[1]).toList(); + List saved = rows(connection, "PRAGMA table_info(husbandry_estate_owner_archive)").stream().map(row -> row[1]).toList(); + for (String metadata : List.of("estate_death_id", "estate_revision", "estate_position")) { + if (columns.contains(metadata)) throw new Held("Owner schema uses reserved estate archive column " + metadata); + if (!saved.contains(metadata)) update(connection, "ALTER TABLE husbandry_estate_owner_archive ADD COLUMN " + quoted(metadata)); + } + for (String column : columns) { + if (!saved.contains(column)) update(connection, "ALTER TABLE husbandry_estate_owner_archive ADD COLUMN " + quoted(column)); + } + String fields = columns.stream().map(HusbandryEstate::quoted).collect(java.util.stream.Collectors.joining(",")); + update(connection, "INSERT INTO husbandry_estate_owner_archive (" + fields + + ",estate_death_id,estate_revision,estate_position) SELECT " + fields + + ",?,?,? FROM character_owners WHERE animal_uuid = ? AND character_uuid = ?", + decision.deathId().toString(), decision.revision(), position, animal, character.toString()); + } + + private static String quoted(String column) { return "\"" + column.replace("\"", "\"\"") + "\""; } + + @Override public Holdings describe(UUID characterId) { + var db = repository.ownershipDatabase(); + try { + synchronized (db) { + List labels = rows(db.getConnection(), + "SELECT animal_uuid FROM character_owners WHERE character_uuid = ? ORDER BY animal_uuid", characterId.toString()) + .stream().map(row -> "Animal " + row[0]).toList(); + return labels.isEmpty() ? Holdings.NONE : new Holdings(Map.of("animals", labels.size()), labels); + } + } catch (SQLException ex) { + throw new SqliteDatabaseException("Could not describe animal holdings", ex); + } + } + + void reconcile() { + for (EstateDecision decision : decided.get()) { + try { + if (!guard.test(decision)) continue; + Outcome outcome = settle(decision); + var report = Map.entry(decision.revision(), outcome); + if (!report.equals(reported.get(decision.deathId())) + && reporter.report(decision.deathId(), ID, decision.revision(), outcome)) reported.put(decision.deathId(), report); + } catch (RuntimeException ex) { + logger.warning("Could not reconcile animal estate " + decision.deathId() + ": " + ex.getMessage()); + } + } + } + + private static final class Held extends RuntimeException { Held(String reason) { super(reason); } } + private static final class GuardChanged extends RuntimeException { } +} diff --git a/src/main/java/net/tfminecraft/cooking/husbandry/HusbandryIdentity.java b/src/main/java/net/tfminecraft/cooking/husbandry/HusbandryIdentity.java new file mode 100644 index 0000000..c8d4fb2 --- /dev/null +++ b/src/main/java/net/tfminecraft/cooking/husbandry/HusbandryIdentity.java @@ -0,0 +1,56 @@ +package net.tfminecraft.cooking.husbandry; + +import java.util.Optional; +import java.util.UUID; +import org.bukkit.entity.Player; +import org.bukkit.plugin.Plugin; +import net.tfminecraft.tlibs.character.CharacterRef; +import net.tfminecraft.tlibs.character.CharacterSession; +import net.tfminecraft.tlibs.character.Characters; + +/** New heir and estate entry points are linked only after a capability probe. */ +public final class HusbandryIdentity { + private HusbandryIdentity() {} + + public static Optional session(Player player) { + return player == null ? Optional.empty() : Characters.currentSession(player).filter(session -> current(session, player)); + } + + public static boolean current(CharacterSession session, Player player) { + return session != null && player != null && session.ref().ownerId().equals(player.getUniqueId()) + && Characters.isCurrent(session); + } + + public static String name(CharacterRef ref) { + return Characters.info(ref.characterId()).filter(info -> info.ref().equals(ref)) + .map(info -> info.name()).orElse(ref.characterId().toString()); + } + + public static boolean supports(ClassLoader loader, boolean estates) { + try { + Class characters = Class.forName("net.tfminecraft.tlibs.character.Characters", false, loader); + characters.getMethod("legacyHeirsReady"); + characters.getMethod("legacyHeir", UUID.class); + if (estates) { + for (String name : new String[]{"Estates", "EstateParticipant", "EstateDecision", "Outcome", "Holdings"}) { + Class.forName("net.tfminecraft.tlibs.character.estate." + name, false, loader); + } + } + return true; + } catch (ReflectiveOperationException | LinkageError ex) { + return false; + } + } + + public static void start(Plugin plugin, HusbandryRepository repository) { + if (repository == null) return; + ClassLoader loader = HusbandryIdentity.class.getClassLoader(); + if (!supports(loader, false)) { + plugin.getLogger().warning("Reviewed animal heirs require TLibs 2.6.0; old owner slots remain held."); + return; + } + HusbandryMigration.start(plugin, repository); + if (supports(loader, true)) HusbandryEstate.start(plugin, repository); + else plugin.getLogger().warning("TLibs has no estate API; animal slots stay with dead characters."); + } +} diff --git a/src/main/java/net/tfminecraft/cooking/husbandry/HusbandryInspectGui.java b/src/main/java/net/tfminecraft/cooking/husbandry/HusbandryInspectGui.java index ad008a9..1de8271 100644 --- a/src/main/java/net/tfminecraft/cooking/husbandry/HusbandryInspectGui.java +++ b/src/main/java/net/tfminecraft/cooking/husbandry/HusbandryInspectGui.java @@ -6,7 +6,6 @@ import org.bukkit.Bukkit; import org.bukkit.Material; -import org.bukkit.OfflinePlayer; import org.bukkit.entity.EntityType; import org.bukkit.entity.LivingEntity; import org.bukkit.entity.Player; @@ -37,7 +36,7 @@ public static void open(Player player, LivingEntity entity, HusbandryAnimal anim return; } boolean mount = HusbandryMounts.isMount(entity); - HusbandryInspectHolder holder = new HusbandryInspectHolder(animal.uuid()); + HusbandryInspectHolder holder = new HusbandryInspectHolder(animal.uuid(), HusbandryIdentity.session(player).orElse(null)); String displayName = animal.name(); if (displayName == null || displayName.isBlank() || "???".equals(displayName.trim())) { displayName = HusbandryEntities.displayName(entity.getType()); @@ -139,10 +138,11 @@ private static String decayLine(HusbandryAnimal animal) { private static ItemStack ownersItem(UUID animalUuid) { List lore = new ArrayList<>(); for (HusbandryOwner owner : HusbandryOwnershipService.listOwners(animalUuid)) { - OfflinePlayer offline = Bukkit.getOfflinePlayer(owner.playerUuid()); - String name = offline.getName() == null ? owner.playerUuid().toString() : offline.getName(); + String name = HusbandryIdentity.name(owner.ref()); lore.add("§7" + owner.role() + ": " + name); } + HusbandryRepository repository = HusbandryEntities.repository(); + if (repository != null && repository.hasHeldOwner(animalUuid)) lore.add("§7Unresolved owner slot (staff review)"); if (lore.isEmpty()) { lore.add("§7None (untamed)"); } diff --git a/src/main/java/net/tfminecraft/cooking/husbandry/HusbandryInspectHolder.java b/src/main/java/net/tfminecraft/cooking/husbandry/HusbandryInspectHolder.java index 059d4e6..f3a1c3b 100644 --- a/src/main/java/net/tfminecraft/cooking/husbandry/HusbandryInspectHolder.java +++ b/src/main/java/net/tfminecraft/cooking/husbandry/HusbandryInspectHolder.java @@ -9,11 +9,17 @@ public final class HusbandryInspectHolder implements InventoryHolder { private final UUID animalUuid; private Inventory inventory; + private final net.tfminecraft.tlibs.character.CharacterSession session; - public HusbandryInspectHolder(UUID animalUuid) { + public HusbandryInspectHolder(UUID animalUuid, net.tfminecraft.tlibs.character.CharacterSession session) { this.animalUuid = animalUuid; + this.session = session; } + public boolean isCurrent(org.bukkit.entity.Player player) { return HusbandryIdentity.current(session, player); } + + public boolean belongsTo(net.tfminecraft.tlibs.character.CharacterSession outgoing) { return outgoing.equals(session); } + public UUID animalUuid() { return animalUuid; } diff --git a/src/main/java/net/tfminecraft/cooking/husbandry/HusbandryInspectListener.java b/src/main/java/net/tfminecraft/cooking/husbandry/HusbandryInspectListener.java index afd6ebe..0dd19d6 100644 --- a/src/main/java/net/tfminecraft/cooking/husbandry/HusbandryInspectListener.java +++ b/src/main/java/net/tfminecraft/cooking/husbandry/HusbandryInspectListener.java @@ -80,6 +80,10 @@ public void onClick(InventoryClickEvent event) { if (!(event.getWhoClicked() instanceof Player player)) { return; } + if (!inspect.isCurrent(player)) { + player.closeInventory(); + return; + } ItemStack clicked = event.getCurrentItem(); if (clicked == null || clicked.getType() != Material.WRITABLE_BOOK) { return; diff --git a/src/main/java/net/tfminecraft/cooking/husbandry/HusbandryLocator.java b/src/main/java/net/tfminecraft/cooking/husbandry/HusbandryLocator.java index 818e05f..7c7d6f9 100644 --- a/src/main/java/net/tfminecraft/cooking/husbandry/HusbandryLocator.java +++ b/src/main/java/net/tfminecraft/cooking/husbandry/HusbandryLocator.java @@ -186,7 +186,7 @@ static String deleteGhost(HusbandryRepository repository, HusbandryAnimal animal List owners = repository.listOwners(animal.uuid()); repository.deleteAnimal(animal.uuid()); for (HusbandryOwner owner : owners) { - repository.deleteOwner(animal.uuid(), owner.playerUuid()); + repository.deleteCharacterOwner(animal.uuid(), owner.characterUuid()); } HusbandryEntities.evict(animal.uuid()); MISSING.remove(animal.uuid()); diff --git a/src/main/java/net/tfminecraft/cooking/husbandry/HusbandryMigration.java b/src/main/java/net/tfminecraft/cooking/husbandry/HusbandryMigration.java new file mode 100644 index 0000000..4cbab4b --- /dev/null +++ b/src/main/java/net/tfminecraft/cooking/husbandry/HusbandryMigration.java @@ -0,0 +1,24 @@ +package net.tfminecraft.cooking.husbandry; + +import org.bukkit.plugin.Plugin; +import net.tfminecraft.tlibs.character.Characters; + +/** Kept out of the old-API classloading path. Every pass uses the same archived plan. */ +final class HusbandryMigration { + private HusbandryMigration() {} + static void start(Plugin plugin, HusbandryRepository repository) { + plugin.getServer().getScheduler().runTaskTimer(plugin, () -> reconcile(plugin, repository), 1, 20L * 60 * 60); + } + static void reconcile(Plugin plugin, HusbandryRepository repository) { + try { + if (!repository.migrateOwners(Characters::legacyHeirsReady, Characters::legacyHeir)) { + plugin.getLogger().warning("Animal ownership waits for reviewed heirs; legacy slots remain held."); + } else if (repository.heldOwners() != 0) { + plugin.getLogger().warning("Animal ownership holds " + repository.heldOwners() + + " legacy slots for staff; see husbandry_owner_plan in Data/husbandry.db."); + } + } catch (RuntimeException ex) { + plugin.getLogger().warning("Animal ownership migration rolled back; retrying: " + ex.getMessage()); + } + } +} diff --git a/src/main/java/net/tfminecraft/cooking/husbandry/HusbandryMountListener.java b/src/main/java/net/tfminecraft/cooking/husbandry/HusbandryMountListener.java index 4f17136..d5dadb5 100644 --- a/src/main/java/net/tfminecraft/cooking/husbandry/HusbandryMountListener.java +++ b/src/main/java/net/tfminecraft/cooking/husbandry/HusbandryMountListener.java @@ -21,6 +21,11 @@ public void onMount(EntityMountEvent event) { if (!(event.getEntity() instanceof Player player)) { return; } + if (HusbandryEntities.repository() == null || HusbandryIdentity.session(player).isEmpty()) { + event.setCancelled(true); + player.sendMessage("§cYour character's animal records are not ready."); + return; + } if (mount instanceof LivingEntity living) { HusbandryAnimal animal = HusbandryMounts.enrollIfNeeded(living); if (animal != null) { @@ -39,6 +44,45 @@ public void onMount(EntityMountEvent event) { player.sendMessage("§cThis is not your animal."); } + @EventHandler + public void onCharacterEnd(net.tfminecraft.tlibs.character.CharacterSessionEndEvent event) { + // The event's session is already noncurrent; clean up the outgoing body directly. + Player player = event.getPlayer(); + if (!event.getSession().ref().ownerId().equals(player.getUniqueId())) return; + Entity vehicle = player.getVehicle(); + if (vehicle != null && HusbandryMounts.isMount(vehicle)) player.leaveVehicle(); + org.bukkit.inventory.Inventory open = player.getOpenInventory().getTopInventory(); + if (open != null && open.getHolder() instanceof HusbandryInspectHolder holder + && holder.belongsTo(event.getSession())) player.closeInventory(); + HusbandryClaimHint.forget(player.getUniqueId()); + } + + @EventHandler + public void onCharacterStart(net.tfminecraft.tlibs.character.CharacterSessionStartEvent event) { + if (!HusbandryIdentity.current(event.getSession(), event.getPlayer())) return; + Entity vehicle = event.getPlayer().getVehicle(); + if (vehicle != null && HusbandryOwnershipService.hasAnyOwner(vehicle.getUniqueId()) + && !HusbandryOwnershipService.isOwner(event.getPlayer(), vehicle.getUniqueId()) + && !HusbandryOwnershipService.isStaff(event.getPlayer())) event.getPlayer().leaveVehicle(); + } + + /** Vanilla sitting, feeding and mount-inventory interactions cannot use an account's tame owner. */ + @EventHandler(priority = EventPriority.HIGH, ignoreCancelled = true) + public void protectInteract(org.bukkit.event.player.PlayerInteractEntityEvent event) { + if (event.getRightClicked() instanceof org.bukkit.entity.Tameable + && HusbandryOwnershipService.hasAnyOwner(event.getRightClicked().getUniqueId()) + && !HusbandryOwnershipService.isOwner(event.getPlayer(), event.getRightClicked().getUniqueId()) + && !HusbandryOwnershipService.isStaff(event.getPlayer())) event.setCancelled(true); + } + + @EventHandler(priority = EventPriority.HIGH, ignoreCancelled = true) + public void protectTame(EntityTameEvent event) { + if (event.getOwner() instanceof Player player + && HusbandryOwnershipService.hasAnyOwner(event.getEntity().getUniqueId()) + && !HusbandryOwnershipService.isOwner(player, event.getEntity().getUniqueId()) + && !HusbandryOwnershipService.isStaff(player)) event.setCancelled(true); + } + @EventHandler(priority = EventPriority.MONITOR, ignoreCancelled = true) public void onTame(EntityTameEvent event) { if (event.getOwner() instanceof Player player) { diff --git a/src/main/java/net/tfminecraft/cooking/husbandry/HusbandryOwner.java b/src/main/java/net/tfminecraft/cooking/husbandry/HusbandryOwner.java index 4b34ec1..df751c6 100644 --- a/src/main/java/net/tfminecraft/cooking/husbandry/HusbandryOwner.java +++ b/src/main/java/net/tfminecraft/cooking/husbandry/HusbandryOwner.java @@ -1,18 +1,25 @@ package net.tfminecraft.cooking.husbandry; -import java.util.Locale; import java.util.UUID; public final class HusbandryOwner { private final UUID animalUuid; private final UUID playerUuid; + private final UUID characterUuid; private final String role; + /** Legacy metadata remains representable, but cannot grant or mutate character ownership. */ + @Deprecated public HusbandryOwner(UUID animalUuid, UUID playerUuid, String role) { + this(animalUuid, playerUuid, null, role); + } + + public HusbandryOwner(UUID animalUuid, UUID playerUuid, UUID characterUuid, String role) { this.animalUuid = animalUuid; this.playerUuid = playerUuid; - this.role = role == null || role.isBlank() ? "owner" : role.toLowerCase(Locale.ROOT); + this.characterUuid = characterUuid; + this.role = role; } public UUID animalUuid() { @@ -23,6 +30,12 @@ public UUID playerUuid() { return playerUuid; } + public UUID characterUuid() { return characterUuid; } + + public net.tfminecraft.tlibs.character.CharacterRef ref() { + return new net.tfminecraft.tlibs.character.CharacterRef(playerUuid, characterUuid); + } + public String role() { return role; } diff --git a/src/main/java/net/tfminecraft/cooking/husbandry/HusbandryOwnershipService.java b/src/main/java/net/tfminecraft/cooking/husbandry/HusbandryOwnershipService.java index 9b2da8c..66e1e5c 100644 --- a/src/main/java/net/tfminecraft/cooking/husbandry/HusbandryOwnershipService.java +++ b/src/main/java/net/tfminecraft/cooking/husbandry/HusbandryOwnershipService.java @@ -15,7 +15,9 @@ public enum Result { ALREADY_OWNER, NOT_OWNER, TARGET_AT_CAP, - ALREADY_TARGET_OWNER + ALREADY_TARGET_OWNER, + CHARACTER_REQUIRED, + HELD_OWNER } private HusbandryOwnershipService() {} @@ -25,7 +27,7 @@ public static boolean isStaff(Player player) { } public static boolean canAccept(Player player) { - if (player == null) { + if (player == null || HusbandryIdentity.session(player).isEmpty()) { return false; } if (isStaff(player)) { @@ -38,21 +40,23 @@ public static boolean canAccept(Player player) { return repository.countForPlayer(player.getUniqueId()) < HusbandryConfig.maxAnimals(); } + private static boolean canAccept(Player player, UUID animal) { + HusbandryRepository repository = HusbandryEntities.repository(); + return repository != null && (repository.accountHasAnimal(player.getUniqueId(), animal) || canAccept(player)); + } + public static boolean isOwner(Player player, UUID animalUuid) { - return player != null && isOwner(player.getUniqueId(), animalUuid); + return HusbandryIdentity.session(player).map(session -> isOwner(session.ref(), animalUuid)).orElse(false); } - public static boolean isOwner(UUID playerUuid, UUID animalUuid) { + /** Account-only callers cannot acquire a character's permission. */ + @Deprecated + public static boolean isOwner(UUID account, UUID animal) { return false; } + + public static boolean isOwner(net.tfminecraft.tlibs.character.CharacterRef ref, UUID animalUuid) { HusbandryRepository repository = HusbandryEntities.repository(); - if (repository == null || playerUuid == null || animalUuid == null) { - return false; - } - for (HusbandryOwner owner : repository.listOwners(animalUuid)) { - if (playerUuid.equals(owner.playerUuid())) { - return true; - } - } - return false; + if (repository == null || ref == null || animalUuid == null) return false; + return repository.listOwners(animalUuid).stream().anyMatch(owner -> ref.equals(owner.ref())); } public static boolean hasAnyOwner(UUID animalUuid) { @@ -60,7 +64,7 @@ public static boolean hasAnyOwner(UUID animalUuid) { if (repository == null || animalUuid == null) { return false; } - return !repository.listOwners(animalUuid).isEmpty(); + return repository.hasAnyOwner(animalUuid); } public static List listOwners(UUID animalUuid) { @@ -76,6 +80,9 @@ public static Result claimOwner(Player player, HusbandryAnimal animal, LivingEnt if (repository == null || player == null || animal == null) { return Result.NO_REPOSITORY; } + var session = HusbandryIdentity.session(player).orElse(null); + if (session == null) return Result.CHARACTER_REQUIRED; + if (repository.isEstateHeld(animal.uuid())) return Result.HELD_OWNER; if (isOwner(player, animal.uuid())) { applyName(animal, entity, name); repository.upsertAnimal(animal); @@ -89,8 +96,8 @@ public static Result claimOwner(Player player, HusbandryAnimal animal, LivingEnt } applyName(animal, entity, name); animal.setState(HusbandryAnimalState.OWNED); - repository.upsertAnimal(animal); - repository.upsertOwner(new HusbandryOwner(animal.uuid(), player.getUniqueId(), "owner")); + repository.saveAnimalAndOwner(animal, + new HusbandryOwner(animal.uuid(), player.getUniqueId(), session.ref().characterId(), "owner")); return Result.OK; } @@ -99,22 +106,27 @@ public static Result addCoOwner(Player actor, Player target, HusbandryAnimal ani if (repository == null || actor == null || target == null || animal == null) { return Result.NO_REPOSITORY; } + var actorSession = HusbandryIdentity.session(actor).orElse(null); + var targetSession = HusbandryIdentity.session(target).orElse(null); + if (actorSession == null || targetSession == null) return Result.CHARACTER_REQUIRED; + if (repository.isEstateHeld(animal.uuid())) return Result.HELD_OWNER; if (!hasAnyOwner(animal.uuid())) { return Result.NOT_OWNER; } - boolean selfClaim = actor.getUniqueId().equals(target.getUniqueId()); + boolean selfClaim = actorSession.ref().equals(targetSession.ref()); + if (selfClaim && repository.hasHeldOwner(animal.uuid())) return Result.HELD_OWNER; if (!selfClaim && !isOwner(actor, animal.uuid())) { return Result.NOT_OWNER; } if (isOwner(target, animal.uuid())) { return Result.ALREADY_TARGET_OWNER; } - if (!canAccept(target)) { + if (!canAccept(target, animal.uuid())) { return Result.TARGET_AT_CAP; } animal.setState(HusbandryAnimalState.OWNED); - repository.upsertAnimal(animal); - repository.upsertOwner(new HusbandryOwner(animal.uuid(), target.getUniqueId(), "coowner")); + repository.saveAnimalAndOwner(animal, + new HusbandryOwner(animal.uuid(), target.getUniqueId(), targetSession.ref().characterId(), "coowner")); return Result.OK; } @@ -123,10 +135,11 @@ public static Result removeSelf(Player player, HusbandryAnimal animal) { if (repository == null || player == null || animal == null) { return Result.NO_REPOSITORY; } + if (repository.isEstateHeld(animal.uuid())) return Result.HELD_OWNER; if (!isOwner(player, animal.uuid())) { return Result.NOT_OWNER; } - repository.deleteOwner(animal.uuid(), player.getUniqueId()); + repository.deleteCharacterOwner(animal.uuid(), HusbandryIdentity.session(player).orElseThrow().ref().characterId()); if (!hasAnyOwner(animal.uuid())) { animal.setState(HusbandryAnimalState.UNTAMED); repository.upsertAnimal(animal); diff --git a/src/main/java/net/tfminecraft/cooking/husbandry/HusbandryOwnershipStore.java b/src/main/java/net/tfminecraft/cooking/husbandry/HusbandryOwnershipStore.java new file mode 100644 index 0000000..93f964f --- /dev/null +++ b/src/main/java/net/tfminecraft/cooking/husbandry/HusbandryOwnershipStore.java @@ -0,0 +1,274 @@ +package net.tfminecraft.cooking.husbandry; + +import java.sql.Connection; +import java.sql.PreparedStatement; +import java.sql.ResultSet; +import java.sql.SQLException; +import java.util.ArrayList; +import java.util.List; +import java.util.Optional; +import java.util.UUID; +import java.util.function.BooleanSupplier; +import java.util.function.Function; + +import net.tfminecraft.tlibs.character.CharacterRef; +import net.tfminecraft.tlibs.database.SqliteDatabase; +import net.tfminecraft.tlibs.database.SqliteDatabaseException; + +/** Character slots; the account remains a routing identity and a distinct-animal capacity key. */ +final class HusbandryOwnershipStore { + private final SqliteDatabase database; + + HusbandryOwnershipStore(SqliteDatabase database) { + this.database = database; + database.execute(""" + CREATE TABLE IF NOT EXISTS character_owners ( + animal_uuid TEXT NOT NULL, account_uuid TEXT NOT NULL, + character_uuid TEXT NOT NULL, role TEXT NOT NULL, + PRIMARY KEY(animal_uuid, character_uuid), + FOREIGN KEY(animal_uuid) REFERENCES animals(uuid) ON DELETE CASCADE) + """); + database.execute(""" + CREATE TABLE IF NOT EXISTS husbandry_owner_plan ( + animal_uuid TEXT NOT NULL, account_uuid TEXT NOT NULL, + character_uuid TEXT, role TEXT, status TEXT NOT NULL, reason TEXT NOT NULL, + PRIMARY KEY(animal_uuid, account_uuid)) + """); + database.execute("CREATE TABLE IF NOT EXISTS husbandry_owner_meta (key TEXT PRIMARY KEY, value TEXT NOT NULL)"); + database.execute(""" + CREATE TABLE IF NOT EXISTS husbandry_estate_intents ( + death_id TEXT NOT NULL, revision INTEGER NOT NULL, decision TEXT NOT NULL, + from_account TEXT NOT NULL, from_character TEXT NOT NULL, + to_account TEXT NOT NULL, to_character TEXT NOT NULL, status TEXT NOT NULL, + PRIMARY KEY(death_id, revision)) + """); + database.execute(""" + CREATE TABLE IF NOT EXISTS husbandry_estate_slots ( + death_id TEXT NOT NULL, revision INTEGER NOT NULL, animal_uuid TEXT NOT NULL, + source_role TEXT NOT NULL, recipient_before TEXT, recipient_after TEXT NOT NULL, + PRIMARY KEY(death_id, revision, animal_uuid)) + """); + } + + /** One consistent SQLite snapshot, archive, reviewed plan and publication in one FULL transaction. */ + boolean migrate(BooleanSupplier ready, Function> heirs) { + if (completed()) return true; + if (!ready.getAsBoolean()) return false; + database.runTransaction(connection -> { + try { + // CTAS preserves every legacy owner column and has no cascading foreign keys. + update(connection, "CREATE TABLE husbandry_owner_archive AS SELECT * FROM owners"); + if (scalar(connection, "SELECT COUNT(*) FROM owners") + != scalar(connection, "SELECT COUNT(*) FROM husbandry_owner_archive")) { + throw new SQLException("Owner archive verification failed"); + } + for (String action : List.of("INSERT", "UPDATE", "DELETE")) { + update(connection, "CREATE TRIGGER husbandry_archive_" + action + + " BEFORE " + action + " ON husbandry_owner_archive BEGIN " + + "SELECT RAISE(ABORT, 'Immutable husbandry ownership archive'); END"); + } + List source = rows(connection, + "SELECT animal_uuid, player_uuid, role FROM husbandry_owner_archive"); + for (String[] row : source) { + CharacterRef destination = null; + String reason = "No reviewed heir"; + try { + UUID animal = UUID.fromString(row[0]); + UUID account = UUID.fromString(row[1]); + destination = heirs.apply(account).filter(ref -> ref.ownerId().equals(account)).orElse(null); + if (!recognized(row[2])) { + destination = null; + reason = "Unknown owner role"; + } else if (scalar(connection, "SELECT COUNT(*) FROM animals WHERE uuid = ?", animal.toString()) == 0) { + destination = null; + reason = "Animal row missing"; + } + } catch (IllegalArgumentException ex) { + reason = "Malformed legacy identity"; + } + if (destination != null) { + String[] existing = slot(connection, row[0], destination.characterId().toString()); + if (existing != null && (!destination.ownerId().toString().equals(existing[0]) + || !recognized(existing[1]))) { + destination = null; + reason = "Conflicting character slot"; + } else { + put(connection, row[0], destination, + existing == null ? row[2] : stronger(row[2], existing[1])); + } + } + update(connection, "INSERT INTO husbandry_owner_plan VALUES (?, ?, ?, ?, ?, ?)", + row[0], row[1], destination == null ? null : destination.characterId().toString(), row[2], + destination == null ? "held" : "resolved", destination == null ? reason : ""); + } + update(connection, "INSERT INTO husbandry_owner_meta VALUES ('completed', '1')"); + } catch (SQLException ex) { + throw new SqliteDatabaseException("Could not migrate husbandry owners", ex); + } + }); + return true; + } + + boolean completed() { + return count("SELECT COUNT(*) FROM husbandry_owner_meta WHERE key = 'completed'") != 0; + } + + void put(HusbandryOwner owner) { + if (owner == null || owner.animalUuid() == null || owner.playerUuid() == null) return; + if (owner.characterUuid() == null) throw new IllegalArgumentException("Character ownership is required"); + if (!recognized(owner.role())) throw new IllegalArgumentException("Unrecognized owner role"); + if (estateHeld(owner.animalUuid())) throw new IllegalStateException("Animal estate intent requires staff review"); + database.runTransaction(connection -> { + try { + String[] before = slot(connection, owner.animalUuid().toString(), owner.characterUuid().toString()); + if (before != null && (!owner.playerUuid().toString().equals(before[0]) || !recognized(before[1]))) { + throw new IllegalArgumentException("Conflicting owner slot"); + } + put(connection, owner.animalUuid().toString(), owner.ref(), owner.role()); + } catch (SQLException ex) { + throw new SqliteDatabaseException("Could not write character owner", ex); + } + }); + } + + void remove(UUID animal, UUID character) { + if (animal == null || character == null) return; + if (estateHeld(animal)) throw new IllegalStateException("Animal estate intent requires staff review"); + database.executeUpdate( + "DELETE FROM character_owners WHERE animal_uuid = ? AND character_uuid = ?", animal.toString(), character.toString()); + } + + List owners(UUID animal) { + if (animal == null) return List.of(); + synchronized (database) { + try { + List result = new ArrayList<>(); + for (String[] row : rows(database.getConnection(), + "SELECT account_uuid, character_uuid, role FROM character_owners WHERE animal_uuid = ?", animal.toString())) { + // Unknown/malformed current slots remain durable and block unowned claims. + try { + if (recognized(row[2])) result.add(new HusbandryOwner(animal, + UUID.fromString(row[0]), UUID.fromString(row[1]), row[2])); + } catch (IllegalArgumentException ignored) { } + } + return result; + } catch (SQLException ex) { + throw new SqliteDatabaseException("Could not read character owners", ex); + } + } + } + + boolean hasAny(UUID animal) { + return animal != null && count(""" + SELECT COUNT(*) FROM ( + SELECT animal_uuid FROM character_owners WHERE animal_uuid = ? + UNION SELECT o.animal_uuid FROM owners o LEFT JOIN husbandry_owner_plan p + ON p.animal_uuid = o.animal_uuid AND p.account_uuid = o.player_uuid + WHERE o.animal_uuid = ? AND (p.status IS NULL OR p.status = 'held')) + """, animal.toString(), animal.toString()) != 0; + } + + boolean held(UUID animal) { + if (animal == null) return false; + if (count("SELECT COUNT(*) FROM character_owners WHERE animal_uuid = ?", animal.toString()) != owners(animal).size()) return true; + return count(""" + SELECT COUNT(*) FROM owners o LEFT JOIN husbandry_owner_plan p + ON p.animal_uuid = o.animal_uuid AND p.account_uuid = o.player_uuid + WHERE o.animal_uuid = ? AND (p.status IS NULL OR p.status = 'held') + """, animal.toString()) != 0; + } + + int countAccount(UUID account) { + if (account == null) return 0; + return count(""" + SELECT COUNT(*) FROM ( + SELECT animal_uuid FROM character_owners WHERE account_uuid = ? + UNION SELECT o.animal_uuid FROM owners o LEFT JOIN husbandry_owner_plan p + ON p.animal_uuid = o.animal_uuid AND p.account_uuid = o.player_uuid + WHERE o.player_uuid = ? AND (p.status IS NULL OR p.status = 'held')) + """, account.toString(), account.toString()); + } + + boolean accountHas(UUID account, UUID animal) { + return account != null && animal != null && count(""" + SELECT COUNT(*) FROM ( + SELECT animal_uuid FROM character_owners WHERE animal_uuid = ? AND account_uuid = ? + UNION SELECT o.animal_uuid FROM owners o LEFT JOIN husbandry_owner_plan p + ON p.animal_uuid = o.animal_uuid AND p.account_uuid = o.player_uuid + WHERE o.animal_uuid = ? AND o.player_uuid = ? AND (p.status IS NULL OR p.status = 'held')) + """, animal.toString(), account.toString(), animal.toString(), account.toString()) != 0; + } + + boolean estateHeld(UUID animal) { + return animal != null && count(""" + SELECT COUNT(*) FROM husbandry_estate_intents i + WHERE i.status != 'done' AND ( + EXISTS (SELECT 1 FROM husbandry_estate_slots s WHERE s.death_id = i.death_id + AND s.revision = i.revision AND s.animal_uuid = ?) + OR EXISTS (SELECT 1 FROM character_owners o WHERE o.animal_uuid = ? + AND o.character_uuid = i.from_character)) + """, animal.toString(), animal.toString()) != 0; + } + + int heldCount() { + return count("SELECT COUNT(*) FROM husbandry_owner_plan WHERE status = 'held'"); + } + + int count(String sql, Object... parameters) { + synchronized (database) { + try { return scalar(database.getConnection(), sql, parameters); } + catch (SQLException ex) { throw new SqliteDatabaseException("Could not count owner slots", ex); } + } + } + + static boolean recognized(String role) { return "owner".equals(role) || "coowner".equals(role); } + static String stronger(String left, String right) { return "owner".equals(left) || "owner".equals(right) ? "owner" : "coowner"; } + + static void put(Connection connection, String animal, CharacterRef ref, String role) throws SQLException { + update(connection, """ + INSERT INTO character_owners (animal_uuid, account_uuid, character_uuid, role) VALUES (?, ?, ?, ?) + ON CONFLICT(animal_uuid, character_uuid) DO UPDATE SET role = excluded.role + """, animal, ref.ownerId().toString(), ref.characterId().toString(), role); + } + + static String[] slot(Connection connection, String animal, String character) throws SQLException { + List rows = rows(connection, + "SELECT account_uuid, role FROM character_owners WHERE animal_uuid = ? AND character_uuid = ?", animal, character); + return rows.isEmpty() ? null : rows.get(0); + } + + static int update(Connection connection, String sql, Object... parameters) throws SQLException { + try (PreparedStatement statement = statement(connection, sql, parameters)) { return statement.executeUpdate(); } + } + + static int scalar(Connection connection, String sql, Object... parameters) throws SQLException { + try (PreparedStatement statement = statement(connection, sql, parameters); ResultSet result = statement.executeQuery()) { + result.next(); + return result.getInt(1); + } + } + + static List rows(Connection connection, String sql, Object... parameters) throws SQLException { + try (PreparedStatement statement = statement(connection, sql, parameters); ResultSet result = statement.executeQuery()) { + List rows = new ArrayList<>(); + int columns = result.getMetaData().getColumnCount(); + while (result.next()) { + String[] row = new String[columns]; + for (int i = 0; i < columns; i++) row[i] = result.getString(i + 1); + rows.add(row); + } + return rows; + } + } + + private static PreparedStatement statement(Connection connection, String sql, Object... parameters) throws SQLException { + PreparedStatement statement = connection.prepareStatement(sql); + try { + for (int i = 0; i < parameters.length; i++) statement.setObject(i + 1, parameters[i]); + return statement; + } catch (SQLException ex) { + statement.close(); + throw ex; + } + } +} diff --git a/src/main/java/net/tfminecraft/cooking/husbandry/HusbandryRepository.java b/src/main/java/net/tfminecraft/cooking/husbandry/HusbandryRepository.java index 248bdca..b1a37ba 100644 --- a/src/main/java/net/tfminecraft/cooking/husbandry/HusbandryRepository.java +++ b/src/main/java/net/tfminecraft/cooking/husbandry/HusbandryRepository.java @@ -111,13 +111,6 @@ ON CONFLICT(uuid) DO UPDATE SET private static final String SELECT_ANIMAL = "SELECT * FROM animals WHERE uuid = ?"; private static final String EXISTS_ANIMAL = "SELECT 1 FROM animals WHERE uuid = ? LIMIT 1"; private static final String DELETE_ANIMAL = "DELETE FROM animals WHERE uuid = ?"; - private static final String UPSERT_OWNER = """ - INSERT INTO owners (animal_uuid, player_uuid, role) VALUES (?, ?, ?) - 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) @@ -128,6 +121,7 @@ ON CONFLICT(animal_uuid) DO UPDATE SET data = excluded.data, saved_at = excluded private static final String DELETE_SNAPSHOT = "DELETE FROM snapshots WHERE animal_uuid = ?"; private final SqliteDatabase database; + private HusbandryOwnershipStore ownership; private HusbandryRepository(SqliteDatabase database) { this.database = database; @@ -153,12 +147,14 @@ public static HusbandryRepository open(File dbFile) { private void initSchema() { database.execute("PRAGMA journal_mode=WAL"); database.execute("PRAGMA foreign_keys=ON"); + database.execute("PRAGMA synchronous=FULL"); database.execute("PRAGMA busy_timeout=5000"); database.execute(CREATE_ANIMALS); database.execute(CREATE_OWNERS); database.execute(INDEX_OWNERS_PLAYER); database.execute(CREATE_SNAPSHOTS); migrateSchema(); + ownership = new HusbandryOwnershipStore(database); } private void migrateSchema() { @@ -310,42 +306,59 @@ public void deleteAnimal(UUID uuid) { executeUpdate(DELETE_ANIMAL, uuid.toString()); } - public void upsertOwner(HusbandryOwner owner) { - if (owner == null || owner.animalUuid() == null || owner.playerUuid() == null) { - return; + public void upsertOwner(HusbandryOwner owner) { ownership.put(owner); } + + public void saveAnimalAndOwner(HusbandryAnimal animal, HusbandryOwner owner) { + if (!animal.uuid().equals(owner.animalUuid()) || !HusbandryOwnershipStore.recognized(owner.role())) { + throw new IllegalArgumentException("Invalid animal ownership publication"); } - executeUpdate( - UPSERT_OWNER, - owner.animalUuid().toString(), - owner.playerUuid().toString(), - owner.role()); + if (ownership.estateHeld(animal.uuid())) throw new IllegalStateException("Animal estate intent requires staff review"); + database.runTransaction(connection -> { + try (PreparedStatement statement = connection.prepareStatement(UPSERT_ANIMAL)) { + String[] before = HusbandryOwnershipStore.slot(connection, animal.uuid().toString(), owner.characterUuid().toString()); + if (before != null && (!owner.playerUuid().toString().equals(before[0]) + || !HusbandryOwnershipStore.recognized(before[1]))) throw new IllegalArgumentException("Conflicting owner slot"); + bindAnimal(statement, animal); + statement.executeUpdate(); + HusbandryOwnershipStore.put(connection, animal.uuid().toString(), owner.ref(), owner.role()); + } catch (SQLException ex) { + throw new SqliteDatabaseException("Could not publish animal ownership", ex); + } + }); } - public void deleteOwner(UUID animalUuid, UUID playerUuid) { - if (animalUuid == null || playerUuid == null) { - return; - } - executeUpdate(DELETE_OWNER, animalUuid.toString(), playerUuid.toString()); + /** The second UUID names a character, never an account. */ + public void deleteCharacterOwner(UUID animalUuid, UUID characterUuid) { ownership.remove(animalUuid, characterUuid); } + + /** An account is ambiguous once several of its characters can share the same animal. */ + @Deprecated + public void deleteOwner(UUID animalUuid, UUID accountUuid) { + throw new IllegalArgumentException("Use an explicit character owner"); } - public int countForPlayer(UUID playerUuid) { - if (playerUuid == null) { - return 0; - } - return queryOne(COUNT_PLAYER, result -> result.getInt(1), playerUuid.toString()).orElse(0); + public int countForPlayer(UUID playerUuid) { return ownership.countAccount(playerUuid); } + + public List listOwners(UUID animalUuid) { return ownership.owners(animalUuid); } + + public boolean hasAnyOwner(UUID animalUuid) { return ownership.hasAny(animalUuid); } + public boolean hasHeldOwner(UUID animalUuid) { return ownership.held(animalUuid); } + public boolean accountHasAnimal(UUID account, UUID animal) { return ownership.accountHas(account, animal); } + public boolean isEstateHeld(UUID animal) { return ownership.estateHeld(animal); } + public int heldOwners() { return ownership.heldCount(); } + public boolean migrateOwners(java.util.function.BooleanSupplier ready, + java.util.function.Function> heirs) { + return ownership.migrate(ready, heirs); } + SqliteDatabase ownershipDatabase() { return database; } - public List listOwners(UUID animalUuid) { - if (animalUuid == null) { - return List.of(); - } - return queryList( - LIST_OWNERS, - result -> new HusbandryOwner( - UUID.fromString(result.getString("animal_uuid")), - UUID.fromString(result.getString("player_uuid")), - result.getString("role")), - animalUuid.toString()); + public List listForCharacter(net.tfminecraft.tlibs.character.CharacterRef ref) { + if (ref == null) return List.of(); + return queryList(""" + SELECT a.*, o.role AS owner_role FROM animals a + INNER JOIN character_owners o ON o.animal_uuid = a.uuid + WHERE o.account_uuid = ? AND o.character_uuid = ? AND o.role IN ('owner','coowner') + """, result -> new HusbandryOwned(mapAnimal(result), result.getString("owner_role")), + ref.ownerId().toString(), ref.characterId().toString()); } public List listForPlayer(UUID playerUuid) { @@ -354,10 +367,11 @@ public List listForPlayer(UUID playerUuid) { } return queryList( """ - SELECT a.*, o.role AS owner_role + SELECT a.*, MAX(o.role) AS owner_role FROM animals a - INNER JOIN owners o ON o.animal_uuid = a.uuid - WHERE o.player_uuid = ? + INNER JOIN character_owners o ON o.animal_uuid = a.uuid + WHERE o.account_uuid = ? AND o.role IN ('owner','coowner') + GROUP BY a.uuid """, result -> new HusbandryOwned(mapAnimal(result), result.getString("owner_role")), playerUuid.toString()); @@ -368,7 +382,10 @@ public List listOwnedAnimals() { """ SELECT a.* FROM animals a - WHERE EXISTS (SELECT 1 FROM owners o WHERE o.animal_uuid = a.uuid) + WHERE EXISTS (SELECT 1 FROM character_owners o WHERE o.animal_uuid = a.uuid) + OR EXISTS (SELECT 1 FROM owners o LEFT JOIN husbandry_owner_plan p + ON p.animal_uuid = o.animal_uuid AND p.account_uuid = o.player_uuid + WHERE o.animal_uuid = a.uuid AND (p.status IS NULL OR p.status = 'held')) """, HusbandryRepository::mapAnimal); } diff --git a/src/main/java/net/tfminecraft/cooking/husbandry/HusbandryTamingListener.java b/src/main/java/net/tfminecraft/cooking/husbandry/HusbandryTamingListener.java index c8559cb..aae0fcb 100644 --- a/src/main/java/net/tfminecraft/cooking/husbandry/HusbandryTamingListener.java +++ b/src/main/java/net/tfminecraft/cooking/husbandry/HusbandryTamingListener.java @@ -126,6 +126,8 @@ private static void handleTame(Player player, LivingEntity entity, ItemStack han HusbandryOwnershipService.Result result = HusbandryOwnershipService.claimOwner(player, animal, entity, name); switch (result) { + case HELD_OWNER -> player.sendMessage("§cThis animal has an unresolved estate; ask staff."); + case CHARACTER_REQUIRED -> player.sendMessage("§cChoose a character before owning animals."); case AT_CAP -> player.sendMessage("§cYou already own too many animals."); case NOT_OWNER -> player.sendMessage("§cThis animal already has an owner."); case OK -> { @@ -194,6 +196,8 @@ private static void handleCoOwnOnPlayer(Player actor, Player target, ItemStack h HusbandryOwnershipService.Result result = HusbandryOwnershipService.addCoOwner(actor, target, animal); switch (result) { + case CHARACTER_REQUIRED -> actor.sendMessage("§cBoth players must choose a character first."); + case HELD_OWNER -> actor.sendMessage("§cThis animal has an unresolved owner; ask staff."); case NOT_OWNER -> actor.sendMessage("§cThis is not your animal."); case ALREADY_TARGET_OWNER -> actor.sendMessage("§eAlready an owner of this animal."); case TARGET_AT_CAP -> actor.sendMessage("§cThat player already owns too many animals."); diff --git a/src/test/java/net/tfminecraft/cooking/CookingLifecycleTest.java b/src/test/java/net/tfminecraft/cooking/CookingLifecycleTest.java index c946510..c402201 100644 --- a/src/test/java/net/tfminecraft/cooking/CookingLifecycleTest.java +++ b/src/test/java/net/tfminecraft/cooking/CookingLifecycleTest.java @@ -94,6 +94,7 @@ class CookingLifecycleTest { locator = staticMock(HusbandryLocator.class); liquidAging = staticMock(LiquidContainerAging.class); churnAging = staticMock(ButterChurnAging.class); + staticMock(net.tfminecraft.cooking.husbandry.HusbandryIdentity.class); staticMock(CustomFishingCatalog.class); staticMock(CropCustomCropsBridge.class); staticMock(CustomFishingBridge.class); diff --git a/src/test/java/net/tfminecraft/cooking/husbandry/HusbandryCharacterAuthorityTest.java b/src/test/java/net/tfminecraft/cooking/husbandry/HusbandryCharacterAuthorityTest.java new file mode 100644 index 0000000..9ca112b --- /dev/null +++ b/src/test/java/net/tfminecraft/cooking/husbandry/HusbandryCharacterAuthorityTest.java @@ -0,0 +1,242 @@ +package net.tfminecraft.cooking.husbandry; + +import static org.junit.jupiter.api.Assertions.*; +import static org.mockito.Mockito.*; +import static org.mockito.ArgumentMatchers.*; +import java.nio.file.Path; +import java.time.Instant; +import java.util.*; +import java.util.logging.Logger; +import org.bukkit.Material; +import org.bukkit.entity.*; +import org.bukkit.event.entity.*; +import org.bukkit.event.inventory.*; +import org.bukkit.event.player.PlayerInteractEntityEvent; +import org.bukkit.inventory.*; +import org.bukkit.plugin.ServicePriority; +import org.junit.jupiter.api.*; +import org.junit.jupiter.api.io.TempDir; +import net.tfminecraft.tlibs.character.*; +import net.tfminecraft.tlibs.character.estate.*; +import static net.tfminecraft.cooking.husbandry.HusbandryOwnershipStore.*; + +class HusbandryCharacterAuthorityTest { + @TempDir Path directory; + HusbandryRuntimeCoverageTest.Farm farm; + @BeforeEach void open() throws Exception { farm = new HusbandryRuntimeCoverageTest.Farm(directory); } + @AfterEach void close() throws Exception { farm.close(); } + + @Test void switchingDoesNotGrantOwnersMenusTameOrMountRightsAndOutgoingSessionDismounts() throws Exception { + var f = farm; var horse = f.spawn(Horse.class); var animal = f.record(horse); f.own(animal, f.player); + CharacterSession outgoing = HusbandryIdentity.session(f.player).orElseThrow(); + HusbandryInspectGui.open(f.player, horse, animal); + var holder = (HusbandryInspectHolder)f.player.getOpenInventory().getTopInventory().getHolder(); + assertTrue(holder.isCurrent(f.player)); assertTrue(holder.belongsTo(outgoing)); + assertFalse(HusbandryIdentity.current(null, f.player)); assertFalse(HusbandryIdentity.current(outgoing, null)); + assertFalse(HusbandryIdentity.current(outgoing, f.neighbour)); + horse.addPassenger(f.player); assertEquals(horse, f.player.getVehicle()); + f.characters.set(f.player, null); + new HusbandryMountListener().onCharacterEnd(new CharacterSessionEndEvent(f.player, outgoing, CharacterSessionEndEvent.Reason.SWITCH)); + assertNull(f.player.getVehicle()); assertNull(f.player.getOpenInventory().getTopInventory()); + f.characters.activate(f.player, UUID.randomUUID()); + assertFalse(HusbandryOwnershipService.isOwner(f.player, animal.uuid())); assertFalse(holder.isCurrent(f.player)); + var mount = new EntityMountEvent(f.player, horse); new HusbandryMountListener().onMount(mount); assertTrue(mount.isCancelled()); + var tame = new EntityTameEvent(horse, f.player); new HusbandryMountListener().protectTame(tame); assertTrue(tame.isCancelled()); + f.player.openInventory(holder.getInventory()); + var click = new InventoryClickEvent(f.player.getOpenInventory(), InventoryType.SlotType.CONTAINER, 8, ClickType.LEFT, InventoryAction.PICKUP_ALL); + new HusbandryInspectListener().onClick(click); assertTrue(click.isCancelled()); + assertEquals(1, f.repository.listOwners(animal.uuid()).size()); + var other = HusbandryIdentity.session(f.player).orElseThrow(); + new HusbandryMountListener().onCharacterEnd(new CharacterSessionEndEvent(f.neighbour, other, CharacterSessionEndEvent.Reason.QUIT)); + new HusbandryMountListener().onCharacterEnd(new CharacterSessionEndEvent(f.player, other, CharacterSessionEndEvent.Reason.QUIT)); + new HusbandryMountListener().protectTame(new EntityTameEvent(horse, f.server.getOfflinePlayer(UUID.randomUUID()))); + f.player.setOp(true); var staffTame = new EntityTameEvent(horse, f.player); new HusbandryMountListener().protectTame(staffTame); assertFalse(staffTame.isCancelled()); + } + + @Test void resolvedCoownerRetainsRightsHeldSlotBlocksTokensAndDistinctAccountCapCannotBeMultiplied() throws Exception { + var f = farm; var animal = f.record(f.spawn(Cow.class)); + update(f.repository.ownershipDatabase().getConnection(), "INSERT INTO owners VALUES (?, ?, 'owner')", animal.uuid().toString(), f.player.getUniqueId().toString()); + animal.setState(HusbandryAnimalState.OWNED); f.repository.upsertAnimal(animal); + f.repository.upsertOwner(new HusbandryOwner(animal.uuid(), f.neighbour.getUniqueId(), f.neighbour.getUniqueId(), "coowner")); + assertTrue(HusbandryOwnershipService.isOwner(f.neighbour, animal.uuid())); + assertEquals(HusbandryOwnershipService.Result.HELD_OWNER, HusbandryOwnershipService.addCoOwner(f.player, f.player, animal)); + assertEquals(HusbandryOwnershipService.Result.OK, HusbandryOwnershipService.removeSelf(f.neighbour, animal)); + assertTrue(HusbandryOwnershipService.hasAnyOwner(animal.uuid())); + assertEquals(HusbandryAnimalState.OWNED, animal.state()); // the held slot still owns the animal + f.own(animal, f.player); var a = HusbandryIdentity.session(f.player).orElseThrow(); + var extra = f.ownedOffline("Second"); assertFalse(HusbandryOwnershipService.canAccept(f.player)); + UUID characterB = UUID.randomUUID(); f.characters.activate(f.player, characterB); + assertFalse(HusbandryOwnershipService.canAccept(f.player)); + f.repository.upsertOwner(new HusbandryOwner(animal.uuid(), f.neighbour.getUniqueId(), f.neighbour.getUniqueId(), "coowner")); + // Explicit resolved grant of another character's slot on the SAME animal does not consume capacity twice. + assertEquals(HusbandryOwnershipService.Result.OK, HusbandryOwnershipService.addCoOwner(f.neighbour, f.player, animal)); + assertTrue(HusbandryOwnershipService.isOwner(f.player, animal.uuid())); assertEquals(2, f.repository.countForPlayer(f.player.getUniqueId())); + assertEquals(1, f.repository.listForCharacter(new CharacterRef(f.player.getUniqueId(), characterB)).size()); + f.characters.set(f.player, null); + assertFalse(HusbandryOwnershipService.canAccept(f.player)); + assertEquals(HusbandryOwnershipService.Result.CHARACTER_REQUIRED, HusbandryOwnershipService.claimOwner(f.player, animal, null, "Late")); + assertEquals(HusbandryOwnershipService.Result.CHARACTER_REQUIRED, HusbandryOwnershipService.addCoOwner(f.neighbour, f.player, animal)); + assertEquals(HusbandryOwnershipService.Result.CHARACTER_REQUIRED, HusbandryOwnershipService.addCoOwner(f.player, f.neighbour, animal)); + var noSessionMount = new EntityMountEvent(f.player, f.spawn(Horse.class)); new HusbandryMountListener().onMount(noSessionMount); assertTrue(noSessionMount.isCancelled()); + } + + @Test void strictEstateFacadeRejectsRevivalRedeathOwnerMismatchAndNewRevision() throws Exception { + var f = farm; var source = HusbandryIdentity.session(f.player).orElseThrow().ref(); var recipient = HusbandryIdentity.session(f.neighbour).orElseThrow().ref(); + var animal = f.record(f.spawn(Cow.class)); f.own(animal, f.player); + f.own(f.record(f.spawn(Cow.class)), f.neighbour); + f.config.set("max-animals", 1); f.reload(); + assertFalse(HusbandryOwnershipService.canAccept(f.neighbour)); + UUID death = UUID.randomUUID(); var decision = new EstateDecision(death, source, Optional.of(Instant.EPOCH), Instant.EPOCH, + Optional.of(recipient), Optional.empty(), Optional.empty(), Recipient.FROZEN, Recipient.character(recipient), Recipient.FROZEN, Recipient.SENDER, 1); + var service = mock(EstateService.class); f.server.getServicesManager().register(EstateService.class, service, f.plugin, ServicePriority.Normal); + when(service.decision(death)).thenReturn(Optional.of(decision)); when(service.stateOf(source.characterId())).thenReturn(Optional.of(EstateState.DECIDED)); + when(f.characters.provider.deathId(source.characterId())).thenReturn(Optional.of(death)); + when(f.characters.provider.info(source.characterId())).thenReturn(Optional.of(new CharacterInfo(source, "A", CharacterStatus.ALIVE, null, Set.of(), 0, null))); + var estate = new HusbandryEstate(f.repository, Logger.getAnonymousLogger()); assertEquals(Outcome.Kind.RETRY, estate.settle(decision).kind()); + when(f.characters.provider.info(source.characterId())).thenReturn(Optional.of(new CharacterInfo(source, "A", CharacterStatus.DEAD, null, Set.of(), 0, null))); + when(f.characters.provider.deathId(source.characterId())).thenReturn(Optional.of(UUID.randomUUID())); assertEquals(Outcome.Kind.RETRY, estate.settle(decision).kind()); + when(f.characters.provider.deathId(source.characterId())).thenReturn(Optional.of(death)); when(service.decision(death)).thenReturn(Optional.empty()); assertEquals(Outcome.Kind.RETRY, estate.settle(decision).kind()); + when(service.decision(death)).thenReturn(Optional.of(decision)); + when(f.characters.provider.info(source.characterId())).thenReturn(Optional.of(new CharacterInfo(new CharacterRef(recipient.ownerId(), source.characterId()), "Conflict", CharacterStatus.DEAD, null, Set.of(), 0, null))); + assertEquals(Outcome.Kind.RETRY, estate.settle(decision).kind()); + when(f.characters.provider.info(source.characterId())).thenReturn(Optional.of(new CharacterInfo(source, "A", CharacterStatus.DEAD, null, Set.of(), 0, null))); + assertEquals(Outcome.Kind.DONE, estate.settle(decision).kind()); assertEquals(recipient, f.repository.listOwners(animal.uuid()).getFirst().ref()); + assertEquals(2, f.repository.countForPlayer(recipient.ownerId())); + assertTrue(f.repository.exists(animal.uuid())); assertFalse(HusbandryOwnershipService.canAccept(f.neighbour)); + when(service.decided()).thenReturn(List.of(decision)); estate.reconcile(); verify(service).report(eq(death), eq("cooking-animals"), eq(1L), any()); + } + + @Test void migrationReconcileAndCapabilityProbeHandleAbsentOldAndFailingProviders() throws Exception { + var f = farm; assertTrue(HusbandryIdentity.supports(HusbandryIdentity.class.getClassLoader(), true)); + ClassLoader absent = new ClassLoader(HusbandryIdentity.class.getClassLoader()) { + @Override protected Class loadClass(String name, boolean resolve) throws ClassNotFoundException { + if (name.equals("net.tfminecraft.tlibs.character.Characters")) throw new ClassNotFoundException("Missing old API"); + return super.loadClass(name, resolve); + } + }; + assertFalse(HusbandryIdentity.supports(absent, false)); + ClassLoader old = new ClassLoader(HusbandryIdentity.class.getClassLoader()) { + @Override protected Class loadClass(String name, boolean resolve) throws ClassNotFoundException { + if (name.startsWith("net.tfminecraft.tlibs.character.estate.")) throw new ClassNotFoundException("Old TLibs"); + return super.loadClass(name, resolve); + } + }; + assertFalse(HusbandryIdentity.supports(old, true)); assertTrue(HusbandryIdentity.supports(old, false)); + assertEquals(0, HusbandryIdentity.session(null).stream().count()); + HusbandryIdentity.start(f.plugin, null); + var plugin = org.mockbukkit.mockbukkit.MockBukkit.createMockPlugin("AnimalEstateFixture"); + HusbandryIdentity.start(plugin, f.repository); + f.server.getScheduler().performTicks(2); + assertEquals("cooking-animals", f.server.getServicesManager().load(EstateParticipant.class).id()); + when(f.characters.provider.legacyHeirsReady()).thenReturn(true); + when(f.characters.provider.legacyHeir(any())).thenReturn(Optional.empty()); + var held = f.ownedOffline("Unknown"); update(f.repository.ownershipDatabase().getConnection(), "INSERT INTO owners VALUES (?, ?, 'owner')", held.uuid().toString(), UUID.randomUUID().toString()); + HusbandryMigration.reconcile(plugin, f.repository); assertEquals(1, f.repository.heldOwners()); + doThrow(new IllegalStateException("Filesystem unavailable")).when(f.repository).migrateOwners(any(), any()); + assertDoesNotThrow(() -> HusbandryMigration.reconcile(plugin, f.repository)); + } + + @Test void noSessionTamingTokensAndRosterFailClosedWithoutConsumingItems() throws Exception { + var f = farm; var cow = f.spawn(Cow.class); var animal = f.record(cow); + f.player.getInventory().setItemInMainHand(new ItemStack(Material.NAME_TAG, 2)); + HusbandryItems.setTameName(f.player.getInventory().getItemInMainHand(), "A's animal"); + f.characters.set(f.player, null); + var listener = new HusbandryTamingListener(); + listener.onInteractEntity(new PlayerInteractEntityEvent(f.player, cow, EquipmentSlot.HAND)); + assertEquals(2, f.player.getInventory().getItemInMainHand().getAmount()); + assertFalse(f.repository.hasAnyOwner(animal.uuid())); + new HusbandryAnimalsCommand().onCommand(f.player, mock(org.bukkit.command.Command.class), "animals", new String[0]); + update(f.repository.ownershipDatabase().getConnection(), "INSERT INTO owners VALUES (?, ?, 'owner')", animal.uuid().toString(), f.player.getUniqueId().toString()); + var token = new ItemStack(Material.PAPER, 2); HusbandryItems.setLinkedAnimal(token, animal.uuid().toString(), "Animal"); + f.player.getInventory().setItemInMainHand(token); + listener.onInteractEntity(new PlayerInteractEntityEvent(f.player, f.neighbour, EquipmentSlot.HAND)); + assertEquals(2, token.getAmount()); + f.characters.activate(f.player, f.player.getUniqueId()); + listener.onInteractEntity(new PlayerInteractEntityEvent(f.player, f.neighbour, EquipmentSlot.HAND)); + assertEquals(2, token.getAmount()); assertTrue(f.repository.hasHeldOwner(animal.uuid())); + } + + @Test void oldApiStartDefersMigrationAndEstateRegistration() { + var f = farm; var plugin = org.mockbukkit.mockbukkit.MockBukkit.createMockPlugin("OldAnimalApi"); + try (var bridge = mockStatic(HusbandryIdentity.class, CALLS_REAL_METHODS)) { + bridge.when(() -> HusbandryIdentity.supports(any(), eq(false))).thenReturn(false); + HusbandryIdentity.start(plugin, f.repository); + assertNull(f.server.getServicesManager().load(EstateParticipant.class)); + bridge.when(() -> HusbandryIdentity.supports(any(), eq(false))).thenReturn(true); + bridge.when(() -> HusbandryIdentity.supports(any(), eq(true))).thenReturn(false); + HusbandryIdentity.start(plugin, f.repository); + assertNull(f.server.getServicesManager().load(EstateParticipant.class)); + } + } + + @Test void stalePreparedEstateHoldsOwnershipWritersWithoutRevokingResolvedCoownerRights() throws Exception { + var f = farm; var cow = f.spawn(Cow.class); var animal = f.record(cow); f.own(animal, f.player); + f.repository.upsertOwner(new HusbandryOwner(animal.uuid(), f.neighbour.getUniqueId(), f.neighbour.getUniqueId(), "coowner")); + update(f.repository.ownershipDatabase().getConnection(), "INSERT INTO husbandry_estate_intents VALUES (?,1,'old decision',?,?,?,?,'prepared')", + UUID.randomUUID().toString(), f.player.getUniqueId().toString(), f.player.getUniqueId().toString(), + f.neighbour.getUniqueId().toString(), f.neighbour.getUniqueId().toString()); + assertTrue(f.repository.isEstateHeld(animal.uuid())); assertFalse(f.repository.isEstateHeld(null)); + assertTrue(HusbandryOwnershipService.isOwner(f.neighbour, animal.uuid())); + assertEquals(HusbandryOwnershipService.Result.HELD_OWNER, HusbandryOwnershipService.claimOwner(f.player, animal, cow, "Not rewritten")); + assertEquals(HusbandryOwnershipService.Result.HELD_OWNER, HusbandryOwnershipService.addCoOwner(f.player, f.neighbour, animal)); + assertEquals(HusbandryOwnershipService.Result.HELD_OWNER, HusbandryOwnershipService.removeSelf(f.neighbour, animal)); + var owner = new HusbandryOwner(animal.uuid(), f.player.getUniqueId(), f.player.getUniqueId(), "owner"); + assertThrows(IllegalStateException.class, () -> f.repository.upsertOwner(owner)); + assertThrows(IllegalStateException.class, () -> f.repository.saveAnimalAndOwner(animal, owner)); + assertThrows(IllegalStateException.class, () -> f.repository.deleteCharacterOwner(animal.uuid(), owner.characterUuid())); + f.player.getInventory().setItemInMainHand(new ItemStack(Material.NAME_TAG,2)); HusbandryItems.setTameName(f.player.getInventory().getItemInMainHand(), "Held"); + new HusbandryTamingListener().onInteractEntity(new PlayerInteractEntityEvent(f.player,cow,EquipmentSlot.HAND)); + assertEquals(2,f.player.getInventory().getItemInMainHand().getAmount()); + assertEquals(2,f.repository.listOwners(animal.uuid()).size()); + update(f.repository.ownershipDatabase().getConnection(), "INSERT INTO owners VALUES (?,?,'owner')", animal.uuid().toString(), UUID.randomUUID().toString()); + HusbandryInspectGui.open(f.neighbour,cow,animal); + assertTrue(f.neighbour.getOpenInventory().getTopInventory().getItem(2).getItemMeta().getLore().stream().anyMatch(line->line.contains("Unresolved"))); + } + + @Test void vanillaTameableInteractionsCannotUseAccountOwnerWhilePublicLivestockInteractionStays() { + var f = farm; var horse = f.spawn(Horse.class); var animal = f.record(horse); f.own(animal, f.player); + var listener = new HusbandryMountListener(); + var owner = new PlayerInteractEntityEvent(f.player,horse,EquipmentSlot.HAND); listener.protectInteract(owner); assertFalse(owner.isCancelled()); + f.characters.activate(f.player,UUID.randomUUID()); + var alternate = new PlayerInteractEntityEvent(f.player,horse,EquipmentSlot.HAND); listener.protectInteract(alternate); assertTrue(alternate.isCancelled()); + f.player.setOp(true); var staff = new PlayerInteractEntityEvent(f.player,horse,EquipmentSlot.HAND); listener.protectInteract(staff); assertFalse(staff.isCancelled()); + f.player.setOp(false); var wild = new PlayerInteractEntityEvent(f.player,f.spawn(Horse.class),EquipmentSlot.HAND); listener.protectInteract(wild); assertFalse(wild.isCancelled()); + var cow=f.spawn(Cow.class); var livestock=f.record(cow); f.own(livestock,f.neighbour); + var publicHarvest=new PlayerInteractEntityEvent(f.player,cow,EquipmentSlot.HAND);listener.protectInteract(publicHarvest);assertFalse(publicHarvest.isCancelled()); + } + + @Test void providerReturnedStaleAndWrongAccountSessionsCannotGrantActionOrMenuAuthority() throws Exception { + var f=farm;var horse=f.spawn(Horse.class);var animal=f.record(horse);f.own(animal,f.player); + var before=HusbandryIdentity.session(f.player).orElseThrow(); + when(f.characters.provider.currentSession(f.player)).thenReturn(Optional.of(before)); + when(f.characters.provider.isCurrent(before)).thenReturn(false); + assertTrue(HusbandryIdentity.session(f.player).isEmpty());assertFalse(HusbandryOwnershipService.isOwner(f.player,animal.uuid())); + assertEquals(HusbandryOwnershipService.Result.CHARACTER_REQUIRED,HusbandryOwnershipService.claimOwner(f.player,animal,horse,"Stale")); + HusbandryInspectGui.open(f.player,horse,animal); + var holder=(HusbandryInspectHolder)f.player.getOpenInventory().getTopInventory().getHolder();assertFalse(holder.isCurrent(f.player)); + var click=new InventoryClickEvent(f.player.getOpenInventory(),InventoryType.SlotType.CONTAINER,8,ClickType.LEFT,InventoryAction.PICKUP_ALL); + new HusbandryInspectListener().onClick(click);assertEquals(1,f.repository.listOwners(animal.uuid()).size()); + var staleMount=new EntityMountEvent(f.player,horse);new HusbandryMountListener().onMount(staleMount);assertTrue(staleMount.isCancelled()); + var wrong=HusbandryIdentity.session(f.neighbour).orElseThrow(); + when(f.characters.provider.currentSession(f.player)).thenReturn(Optional.of(wrong)); + assertTrue(f.characters.provider.isCurrent(wrong));assertTrue(HusbandryIdentity.session(f.player).isEmpty()); + assertFalse(HusbandryOwnershipService.canAccept(f.player));assertFalse(HusbandryOwnershipService.isOwner(f.player,animal.uuid())); + assertEquals(HusbandryOwnershipService.Result.CHARACTER_REQUIRED,HusbandryOwnershipService.claimOwner(f.player,animal,horse,"Wrong owner")); + var wrongMount=new EntityMountEvent(f.player,horse);new HusbandryMountListener().onMount(wrongMount);assertTrue(wrongMount.isCancelled()); + new HusbandryAnimalsCommand().onCommand(f.player,null,"animals",new String[0]); + assertEquals(before.ref(),f.repository.listOwners(animal.uuid()).getFirst().ref()); + } + + @Test void sessionStartEjectsOnlyUnauthorizedCurrentRiderAndOwnerTamingStaysAllowed() throws Exception { + var f = farm; var horse = f.spawn(Horse.class); var animal = f.record(horse); f.own(animal, f.player); + horse.addPassenger(f.player); var owner = HusbandryIdentity.session(f.player).orElseThrow(); + var listener = new HusbandryMountListener(); listener.onCharacterStart(new CharacterSessionStartEvent(f.player, owner)); assertEquals(horse, f.player.getVehicle()); + var tame = new EntityTameEvent(horse, f.player); listener.protectTame(tame); assertFalse(tame.isCancelled()); + f.characters.activate(f.player, UUID.randomUUID()); listener.onCharacterStart(new CharacterSessionStartEvent(f.player, owner)); assertEquals(horse, f.player.getVehicle()); + listener.onCharacterStart(new CharacterSessionStartEvent(f.player, HusbandryIdentity.session(f.player).orElseThrow())); assertNull(f.player.getVehicle()); + listener.onCharacterStart(new CharacterSessionStartEvent(f.player, HusbandryIdentity.session(f.player).orElseThrow())); + horse.addPassenger(f.player); f.player.setOp(true); listener.onCharacterStart(new CharacterSessionStartEvent(f.player, HusbandryIdentity.session(f.player).orElseThrow())); assertEquals(horse, f.player.getVehicle()); + var unowned = f.spawn(Horse.class); listener.protectTame(new EntityTameEvent(unowned, f.neighbour)); + f.player.leaveVehicle(); unowned.addPassenger(f.player); listener.onCharacterStart(new CharacterSessionStartEvent(f.player, HusbandryIdentity.session(f.player).orElseThrow())); assertEquals(unowned, f.player.getVehicle()); + } +} diff --git a/src/test/java/net/tfminecraft/cooking/husbandry/HusbandryEstateTest.java b/src/test/java/net/tfminecraft/cooking/husbandry/HusbandryEstateTest.java new file mode 100644 index 0000000..ed0c592 --- /dev/null +++ b/src/test/java/net/tfminecraft/cooking/husbandry/HusbandryEstateTest.java @@ -0,0 +1,156 @@ +package net.tfminecraft.cooking.husbandry; + +import static org.junit.jupiter.api.Assertions.*; +import static org.mockito.Mockito.*; +import static org.mockito.ArgumentMatchers.*; +import java.nio.file.Path; +import java.time.Instant; +import java.util.*; +import java.util.concurrent.atomic.AtomicInteger; +import java.util.logging.Logger; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.io.TempDir; +import net.tfminecraft.tlibs.character.CharacterRef; +import net.tfminecraft.tlibs.character.estate.*; +import static net.tfminecraft.cooking.husbandry.HusbandryOwnershipStore.*; + +class HusbandryEstateTest { + @TempDir Path directory; + final CharacterRef testator = new CharacterRef(UUID.randomUUID(), UUID.randomUUID()); + final CharacterRef heir = new CharacterRef(UUID.randomUUID(), UUID.randomUUID()); + final CharacterRef coowner = new CharacterRef(UUID.randomUUID(), UUID.randomUUID()); + final UUID death = UUID.randomUUID(); + EstateDecision decision(long revision, Recipient property) { + return new EstateDecision(death, testator, Optional.of(Instant.EPOCH), Instant.EPOCH, + Optional.of(heir), Optional.empty(), Optional.empty(), Recipient.FROZEN, property, + Recipient.FROZEN, Recipient.SENDER, revision); + } + HusbandryAnimal own(HusbandryRepository repo, CharacterRef ref, String role) { + var animal = new HusbandryAnimal(UUID.randomUUID(), "COW", "Daisy"); + animal.setCare(61); animal.setGenetics(871); animal.setHungrySince(9L); animal.setState(HusbandryAnimalState.OWNED); + repo.saveAnimalAndOwner(animal, new HusbandryOwner(animal.uuid(), ref.ownerId(), ref.characterId(), role)); + return animal; + } + HusbandryEstate estate(HusbandryRepository repo) { + var estate = new HusbandryEstate(repo, Logger.getAnonymousLogger()); estate.guard = decision -> true; return estate; + } + @Test void slotTransferPreservesCoownersRoleCollisionsAnimalPayloadAndCumulativeReceipts() throws Exception { + try (var fixture = new HusbandryOwnershipMigrationTest.Repository(directory.resolve("farm.db"))) { + var repo = fixture.value; var animal = own(repo, testator, "coowner"); + repo.upsertOwner(new HusbandryOwner(animal.uuid(), coowner.ownerId(), coowner.characterId(), "coowner")); + repo.upsertOwner(new HusbandryOwner(animal.uuid(), heir.ownerId(), heir.characterId(), "owner")); + repo.upsertSnapshots(Map.of(animal.uuid(), new byte[]{1,7,8}), 9); + var estate = estate(repo); var decision = decision(1, Recipient.character(heir)); + assertEquals(Map.of("animals", 1), estate.describe(testator.characterId()).counts()); + assertEquals(Holdings.NONE, estate.describe(UUID.randomUUID())); + var answer = (Outcome.Done)estate.settle(decision); + assertEquals(List.of(animal.uuid().toString()), answer.movedIds()); + assertEquals(2, repo.listOwners(animal.uuid()).size()); + assertEquals("owner", repo.listOwners(animal.uuid()).stream().filter(o -> o.ref().equals(heir)).findFirst().orElseThrow().role()); + assertTrue(repo.listOwners(animal.uuid()).stream().anyMatch(o -> o.ref().equals(coowner))); + assertFalse(repo.listOwners(animal.uuid()).stream().anyMatch(o -> o.ref().equals(testator))); + assertEquals(61, repo.getAnimal(animal.uuid()).orElseThrow().care()); assertArrayEquals(new byte[]{1,7,8}, repo.getSnapshot(animal.uuid()).orElseThrow().data()); + assertEquals(answer, estate.settle(decision)); + assertEquals(answer, estate.settle(decision(7, Recipient.FROZEN))); + assertEquals(answer, estate.settle(decision(8, Recipient.WAITING))); + var late = own(repo, testator, "owner"); + assertEquals(Outcome.Kind.DONE, estate.settle(decision(2, Recipient.character(heir))).kind()); + assertEquals(2, ((Outcome.Done)estate.settle(decision(2, Recipient.character(heir)))).movedIds().size()); + assertEquals(Holdings.NONE, estate.describe(testator.characterId())); + assertEquals(2, repo.countForPlayer(heir.ownerId())); + } + try (var fixture = new HusbandryOwnershipMigrationTest.Repository(directory.resolve("farm.db"))) { + assertEquals(2, ((Outcome.Done)estate(fixture.value).settle(decision(2, Recipient.character(heir)))).movedIds().size()); + } + } + @Test void frozenWaitingStaleGuardRevivalAndDeathIdentityCannotPublish() { + try (var fixture = new HusbandryOwnershipMigrationTest.Repository(directory.resolve("farm.db"))) { + var repo = fixture.value; var animal = own(repo, testator, "owner"); var estate = estate(repo); + assertEquals(Outcome.done(), estate.settle(decision(1, Recipient.FROZEN))); + assertEquals(Outcome.done(), estate.settle(decision(2, Recipient.WAITING))); + estate.guard = d -> false; + assertEquals(Outcome.Kind.RETRY, estate.settle(decision(3, Recipient.character(heir))).kind()); + assertEquals(testator, repo.listOwners(animal.uuid()).getFirst().ref()); + estate.guard = new java.util.function.Predicate<>() { int checks; public boolean test(EstateDecision d) { return ++checks < 3; } }; + assertEquals(Outcome.Kind.RETRY, estate.settle(decision(3, Recipient.character(heir))).kind()); + assertEquals(testator, repo.listOwners(animal.uuid()).getFirst().ref()); + estate.guard = d -> true; assertEquals(Outcome.Kind.DONE, estate.settle(decision(4, Recipient.character(testator))).kind()); + assertEquals(testator, repo.listOwners(animal.uuid()).getFirst().ref()); + } + } + @Test void faultAfterFirstSlotRollsBackWholeEstateThenRetryMovesExactlyOnce() throws Exception { + try (var fixture = new HusbandryOwnershipMigrationTest.Repository(directory.resolve("farm.db"))) { + var repo = fixture.value; var first = own(repo, testator, "owner"); var second = own(repo, testator, "owner"); + var connection = repo.ownershipDatabase().getConnection(); var estate = estate(repo); + update(connection, "CREATE TRIGGER fail_move BEFORE DELETE ON character_owners WHEN OLD.animal_uuid = '" + second.uuid() + "' BEGIN SELECT RAISE(ABORT,'fault'); END"); + assertEquals(Outcome.Kind.RETRY, estate.settle(decision(1, Recipient.character(heir))).kind()); + assertEquals(testator, repo.listOwners(first.uuid()).getFirst().ref()); assertEquals(testator, repo.listOwners(second.uuid()).getFirst().ref()); + assertEquals(0, scalar(connection, "SELECT COUNT(*) FROM husbandry_estate_intents")); + assertEquals(0, scalar(connection, "SELECT COUNT(*) FROM husbandry_estate_slots")); + update(connection, "DROP TRIGGER fail_move"); + assertEquals(2, ((Outcome.Done)estate.settle(decision(1, Recipient.character(heir)))).movedIds().size()); + } + } + @Test void unknownRolesAccountsAndIncompleteOrConflictingIntentsAreBlocked() throws Exception { + try (var fixture = new HusbandryOwnershipMigrationTest.Repository(directory.resolve("farm.db"))) { + var repo = fixture.value; var animal = own(repo, testator, "owner"); var estate = estate(repo); var connection = repo.ownershipDatabase().getConnection(); + update(connection, "UPDATE character_owners SET role = 'unknown'"); + assertEquals(Outcome.Kind.BLOCKED, estate.settle(decision(1, Recipient.character(heir))).kind()); + update(connection, "UPDATE character_owners SET role = 'owner', account_uuid = ?", coowner.ownerId().toString()); + assertEquals(Outcome.Kind.BLOCKED, estate.settle(decision(1, Recipient.character(heir))).kind()); + update(connection, "UPDATE character_owners SET account_uuid = ?", testator.ownerId().toString()); + update(connection, "INSERT INTO character_owners VALUES (?, ?, ?, 'future')", animal.uuid().toString(), heir.ownerId().toString(), heir.characterId().toString()); + assertEquals(Outcome.Kind.BLOCKED, estate.settle(decision(1, Recipient.character(heir))).kind()); + update(connection, "UPDATE character_owners SET role = 'owner', account_uuid = ? WHERE character_uuid = ?", coowner.ownerId().toString(), heir.characterId().toString()); + assertEquals(Outcome.Kind.BLOCKED, estate.settle(decision(1, Recipient.character(heir))).kind()); + update(connection, "DELETE FROM character_owners WHERE character_uuid = ?", heir.characterId().toString()); + assertEquals(Outcome.Kind.DONE, estate.settle(decision(1, Recipient.character(heir))).kind()); + assertEquals(Outcome.Kind.BLOCKED, estate.settle(decision(1, Recipient.character(coowner))).kind()); + assertEquals(Outcome.Kind.BLOCKED, estate.settle(decision(1, Recipient.FROZEN)).kind()); + update(connection, "UPDATE husbandry_estate_intents SET status = 'prepared'"); + assertEquals(Outcome.Kind.BLOCKED, estate.settle(decision(2, Recipient.character(heir))).kind()); + assertEquals(Outcome.Kind.BLOCKED, estate.settle(decision(3, Recipient.FROZEN)).kind()); + } + } + @Test void finalGuardFailureRollsBackEmptyPlanAndReconcileReportsPerRevisionAndContinuesFailures() { + try (var fixture = new HusbandryOwnershipMigrationTest.Repository(directory.resolve("farm.db"))) { + var estate = estate(fixture.value); var decision = decision(1, Recipient.character(heir)); + AtomicInteger checks = new AtomicInteger(); estate.guard = d -> checks.incrementAndGet() == 1; + assertEquals(Outcome.Kind.RETRY, estate.settle(decision).kind()); + estate.guard = d -> true; estate.decided = () -> List.of(decision); + AtomicInteger reports = new AtomicInteger(); estate.reporter = (death,id,revision,outcome) -> { reports.incrementAndGet(); return true; }; + estate.reconcile(); estate.reconcile(); assertEquals(1, reports.get()); + estate.decided = () -> List.of(decision(2, Recipient.FROZEN)); estate.reconcile(); assertEquals(2, reports.get()); + estate.decided = () -> List.of(decision(3, Recipient.FROZEN)); estate.reporter = (death,id,revision,outcome) -> false; estate.reconcile(); + estate.guard = d -> false; estate.reconcile(); + estate.guard = d -> { throw new IllegalStateException("Unavailable identity"); }; assertDoesNotThrow(estate::reconcile); + } + } + @Test void unreadableHoldingsSchemaIsReportedRatherThanAssumedEmpty() throws Exception { + try (var fixture = new HusbandryOwnershipMigrationTest.Repository(directory.resolve("farm.db"))) { + var estate = estate(fixture.value); + update(fixture.value.ownershipDatabase().getConnection(), "DROP TABLE character_owners"); + assertThrows(net.tfminecraft.tlibs.database.SqliteDatabaseException.class, () -> estate.describe(testator.characterId())); + } + } + @Test void unknownNullableAndBlobSlotMetadataSurvivesTransferAndLaterSchemaExpansion() throws Exception { + try (var fixture = new HusbandryOwnershipMigrationTest.Repository(directory.resolve("farm.db"))) { + var repo = fixture.value; var animal = own(repo, testator, "owner"); var connection = repo.ownershipDatabase().getConnection(); + update(connection,"ALTER TABLE character_owners ADD COLUMN unknown TEXT"); + update(connection,"ALTER TABLE character_owners ADD COLUMN payload BLOB"); + update(connection,"UPDATE character_owners SET payload = ?", new byte[]{0,3,-1}); + var estate=estate(repo); assertEquals(Outcome.Kind.DONE,estate.settle(decision(1,Recipient.character(heir))).kind()); + assertNull(rows(connection,"SELECT unknown FROM husbandry_estate_owner_archive").getFirst()[0]); + try(var statement=connection.prepareStatement("SELECT payload FROM husbandry_estate_owner_archive");var result=statement.executeQuery()) { + result.next(); assertArrayEquals(new byte[]{0,3,-1},result.getBytes(1)); + } + update(connection,"ALTER TABLE character_owners ADD COLUMN next_column TEXT DEFAULT 'kept'"); + var late=own(repo,testator,"owner"); + assertEquals(Outcome.Kind.DONE,estate.settle(decision(2,Recipient.character(heir))).kind()); + assertEquals("kept",rows(connection,"SELECT next_column FROM husbandry_estate_owner_archive WHERE animal_uuid=?",late.uuid().toString()).getFirst()[0]); + update(connection,"ALTER TABLE character_owners ADD COLUMN estate_death_id TEXT"); + own(repo,testator,"owner"); + assertEquals(Outcome.Kind.BLOCKED,estate.settle(decision(3,Recipient.character(heir))).kind()); + } + } +} diff --git a/src/test/java/net/tfminecraft/cooking/husbandry/HusbandryInspectionCoverageTest.java b/src/test/java/net/tfminecraft/cooking/husbandry/HusbandryInspectionCoverageTest.java index 2f789dc..ea6b403 100644 --- a/src/test/java/net/tfminecraft/cooking/husbandry/HusbandryInspectionCoverageTest.java +++ b/src/test/java/net/tfminecraft/cooking/husbandry/HusbandryInspectionCoverageTest.java @@ -68,7 +68,7 @@ class HusbandryInspectionCoverageTest { } @Test void ownerNamesFallBackToUuidWhenOfflineProfileHasNoName() { - Cow cow = f.spawn(Cow.class); HusbandryAnimal animal = f.record(cow); UUID owner = UUID.randomUUID(); f.repository.upsertOwner(new HusbandryOwner(animal.uuid(), owner, "coowner")); + Cow cow = f.spawn(Cow.class); HusbandryAnimal animal = f.record(cow); UUID owner = UUID.randomUUID(); f.repository.upsertOwner(new HusbandryOwner(animal.uuid(), owner, owner, "coowner")); OfflinePlayer unnamed = mock(OfflinePlayer.class); try (MockedStatic bukkit = mockStatic(Bukkit.class, CALLS_REAL_METHODS)) { bukkit.when(() -> Bukkit.getOfflinePlayer(owner)).thenReturn(unnamed); HusbandryInspectGui.open(f.player, cow, animal); } assertEquals(List.of("§7coowner: " + owner), lore(f.player.getOpenInventory().getTopInventory(), 2)); @@ -101,7 +101,7 @@ class HusbandryInspectionCoverageTest { InventoryClickEvent unrelated = click(4); listener.onClick(unrelated); assertTrue(unrelated.isCancelled()); assertTrue(HusbandryOwnershipService.isOwner(f.player, animal.uuid())); listener.onClick(click(5)); when(f.plugin.getHusbandryRepository()).thenReturn(null); listener.onClick(click(8)); when(f.plugin.getHusbandryRepository()).thenReturn(f.repository); assertTrue(HusbandryOwnershipService.isOwner(f.player, animal.uuid())); listener.onClick(click(8)); assertFalse(HusbandryOwnershipService.isOwner(f.player, animal.uuid())); assertEquals(HusbandryAnimalState.UNTAMED, f.repository.getAnimal(animal.uuid()).orElseThrow().state()); assertTrue(f.player.nextMessage().contains("no longer own")); - f.own(animal, f.player); HusbandryInspectGui.open(f.player, cow, animal); f.repository.deleteOwner(animal.uuid(), f.player.getUniqueId()); listener.onClick(click(8)); assertTrue(f.player.nextMessage().contains("Could not remove")); + f.own(animal, f.player); HusbandryInspectGui.open(f.player, cow, animal); f.repository.deleteCharacterOwner(animal.uuid(), f.player.getUniqueId()); listener.onClick(click(8)); assertTrue(f.player.nextMessage().contains("Could not remove")); f.own(animal, f.player); HusbandryInspectGui.open(f.player, cow, animal); HusbandryEntities.evict(animal.uuid()); f.repository.deleteAnimal(animal.uuid()); listener.onClick(click(8)); assertNull(f.player.getOpenInventory().getTopInventory()); f.player.openInventory(Bukkit.createInventory(null, 9)); var ordinary = click(0); listener.onClick(ordinary); assertFalse(ordinary.isCancelled()); @@ -115,13 +115,13 @@ class HusbandryInspectionCoverageTest { assertEquals(HusbandryOwnershipService.Result.OK, HusbandryOwnershipService.claimOwner(f.player, b, second, "???")); assertEquals("Cow", b.name()); assertFalse(HusbandryOwnershipService.canAccept(f.player)); assertEquals(HusbandryOwnershipService.Result.AT_CAP, HusbandryOwnershipService.claimOwner(f.player, c, third, "Third")); assertFalse(HusbandryOwnershipService.hasAnyOwner(c.uuid())); f.player.setOp(true); assertTrue(HusbandryOwnershipService.canAccept(f.player)); assertEquals(HusbandryOwnershipService.Result.OK, HusbandryOwnershipService.claimOwner(f.player, c, third, "Third")); assertEquals(3, f.repository.countForPlayer(f.player.getUniqueId())); - assertFalse(HusbandryOwnershipService.isOwner((Player) null, a.uuid())); assertFalse(HusbandryOwnershipService.isOwner((UUID) null, a.uuid())); assertFalse(HusbandryOwnershipService.isOwner(f.player.getUniqueId(), null)); assertFalse(HusbandryOwnershipService.hasAnyOwner(null)); assertFalse(HusbandryOwnershipService.isStaff(null)); + assertFalse(HusbandryOwnershipService.isOwner((Player) null, a.uuid())); assertFalse(HusbandryOwnershipService.isOwner((net.tfminecraft.tlibs.character.CharacterRef) null, a.uuid())); assertFalse(HusbandryOwnershipService.isOwner(new net.tfminecraft.tlibs.character.CharacterRef(f.player.getUniqueId(), f.player.getUniqueId()), null)); assertFalse(HusbandryOwnershipService.hasAnyOwner(null)); assertFalse(HusbandryOwnershipService.isStaff(null)); } @Test void coOwnershipKeepsStateUntilLastOwnerLeavesAndChecksActorAndTargetCaps() { HusbandryAnimal animal = f.record(f.spawn(Cow.class)); assertEquals(HusbandryOwnershipService.Result.NOT_OWNER, HusbandryOwnershipService.addCoOwner(f.player, f.neighbour, animal)); f.own(animal, f.player); - var stranger = f.server.addPlayer("Stranger"); assertEquals(HusbandryOwnershipService.Result.NOT_OWNER, HusbandryOwnershipService.addCoOwner(stranger, f.neighbour, animal)); + var stranger = f.server.addPlayer("Stranger"); f.characters.activate(stranger, stranger.getUniqueId()); assertEquals(HusbandryOwnershipService.Result.NOT_OWNER, HusbandryOwnershipService.addCoOwner(stranger, f.neighbour, animal)); f.own(f.offline("COW", "One"), f.neighbour); f.own(f.offline("COW", "Two"), f.neighbour); assertEquals(HusbandryOwnershipService.Result.TARGET_AT_CAP, HusbandryOwnershipService.addCoOwner(f.player, f.neighbour, animal)); f.neighbour.setOp(true); assertEquals(HusbandryOwnershipService.Result.OK, HusbandryOwnershipService.addCoOwner(f.player, f.neighbour, animal)); assertEquals(HusbandryOwnershipService.Result.ALREADY_TARGET_OWNER, HusbandryOwnershipService.addCoOwner(f.player, f.neighbour, animal)); @@ -161,7 +161,7 @@ class HusbandryInspectionCoverageTest { var own = new EntityMountEvent(f.player, horse); mounts.onMount(own); assertFalse(own.isCancelled()); var other = new EntityMountEvent(f.neighbour, horse); mounts.onMount(other); assertTrue(other.isCancelled()); assertTrue(f.neighbour.nextMessage().contains("not your animal")); f.neighbour.setOp(true); var staff = new EntityMountEvent(f.neighbour, horse); mounts.onMount(staff); assertFalse(staff.isCancelled()); mounts.onTame(new EntityTameEvent(horse, f.player)); mounts.onTame(new EntityTameEvent(horse, Bukkit.getOfflinePlayer(UUID.randomUUID()))); mounts.onQuit(new PlayerQuitEvent(f.player, "bye")); - when(f.plugin.getHusbandryRepository()).thenReturn(null); var unavailable = new EntityMountEvent(f.player, f.spawn(Horse.class)); mounts.onMount(unavailable); assertFalse(unavailable.isCancelled()); + when(f.plugin.getHusbandryRepository()).thenReturn(null); var unavailable = new EntityMountEvent(f.player, f.spawn(Horse.class)); mounts.onMount(unavailable); assertTrue(unavailable.isCancelled()); } @Test void removalIgnoresOtherHumanViewersAndAReplacedButton() { diff --git a/src/test/java/net/tfminecraft/cooking/husbandry/HusbandryListenersCoverageTest.java b/src/test/java/net/tfminecraft/cooking/husbandry/HusbandryListenersCoverageTest.java index 837e0ec..9032556 100644 --- a/src/test/java/net/tfminecraft/cooking/husbandry/HusbandryListenersCoverageTest.java +++ b/src/test/java/net/tfminecraft/cooking/husbandry/HusbandryListenersCoverageTest.java @@ -35,6 +35,7 @@ class HusbandryListenersCoverageTest { @TempDir Path directory; private ServerMock server; + private net.tfminecraft.cooking.testsupport.TestCharacters characters; private WorldMock world; private PlayerMock player, target; private Cooking oldPlugin; @@ -52,6 +53,8 @@ class HusbandryListenersCoverageTest { server = MockBukkit.mock(); world = server.addSimpleWorld("farm"); player = server.addPlayer("Farmer"); target = server.addPlayer("Neighbour"); player.teleport(new Location(world, 0, 65, 0)); target.teleport(new Location(world, 2, 65, 0)); Cooking.plugin = mock(Cooking.class); when(Cooking.plugin.getName()).thenReturn("Cooking"); when(Cooking.plugin.namespace()).thenReturn("cooking"); when(Cooking.plugin.isEnabled()).thenReturn(true); + characters = new net.tfminecraft.cooking.testsupport.TestCharacters(Cooking.plugin); + characters.activate(player, player.getUniqueId()); characters.activate(target, target.getUniqueId()); repository = HusbandryRepository.open(directory.resolve("husbandry.db").toFile()); when(Cooking.plugin.getHusbandryRepository()).thenReturn(repository); HusbandryEntities.clearLoaded(); HusbandryFeedQuality.clear(); config = new YamlConfiguration(); config.loadFromString(""" @@ -94,7 +97,7 @@ class HusbandryListenersCoverageTest { @AfterEach void tearDown() throws Exception { HusbandryEntities.clearLoaded(); HusbandryFeedQuality.clear(); repository.close(); - MockBukkit.unmock(); for (int i = mocks.size() - 1; i >= 0; i--) mocks.get(i).close(); + characters.close(); MockBukkit.unmock(); for (int i = mocks.size() - 1; i >= 0; i--) mocks.get(i).close(); Cooking.plugin = oldPlugin; configSnapshot.close(); } @@ -295,7 +298,7 @@ class HusbandryListenersCoverageTest { @Test void sharingRequiresAnOwnerAndRejectsExistingOwnersAndFullRecipients() { HusbandryAnimal animal = record(spawn(Cow.class)); hold(token(animal.uuid().toString())); taming.onInteractEntity(interact(target)); assertTrue(player.nextMessage().contains("not your animal")); own(animal, player); own(animal, target); taming.onInteractEntity(interact(target)); assertTrue(player.nextMessage().contains("Already an owner")); - repository.deleteOwner(animal.uuid(), target.getUniqueId()); own(record(spawn(Cow.class)), target); own(record(spawn(Cow.class)), target); taming.onInteractEntity(interact(target)); assertTrue(player.nextMessage().contains("too many")); assertEquals(2, player.getInventory().getItemInMainHand().getAmount()); + repository.deleteCharacterOwner(animal.uuid(), target.getUniqueId()); own(record(spawn(Cow.class)), target); own(record(spawn(Cow.class)), target); taming.onInteractEntity(interact(target)); assertTrue(player.nextMessage().contains("too many")); assertEquals(2, player.getInventory().getItemInMainHand().getAmount()); } @Test void sharingToAnotherPlayerPersistsCoOwnershipAndConsumesOneToken() { @@ -331,7 +334,7 @@ private T spawn(Class type) { } private MockedStatic scoped(Class type) { MockedStatic result = mockStatic(type); mocks.add(result); return result; } private HusbandryAnimal record(LivingEntity entity) { HusbandryAnimal animal = new HusbandryAnimal(entity.getUniqueId(), entity.getType().name(), HusbandryEntities.displayName(entity.getType())); repository.upsertAnimal(animal); HusbandryEntities.putLoaded(animal); return animal; } - private void own(HusbandryAnimal animal, Player owner) { repository.upsertOwner(new HusbandryOwner(animal.uuid(), owner.getUniqueId(), "owner")); } + private void own(HusbandryAnimal animal, Player owner) { repository.upsertOwner(new HusbandryOwner(animal.uuid(), owner.getUniqueId(), owner.getUniqueId(), "owner")); } private void hold(ItemStack item) { player.getInventory().setItemInMainHand(item); } private ItemStack namedTag(String name) { ItemStack item = new ItemStack(Material.NAME_TAG, 2); HusbandryItems.setTameName(item, name); return item; } private ItemStack token(String linked) { ItemStack item = new ItemStack(Material.PAPER, 2); if (linked != null) HusbandryItems.setLinkedAnimal(item, linked, null); return item; } diff --git a/src/test/java/net/tfminecraft/cooking/husbandry/HusbandryLocatorTest.java b/src/test/java/net/tfminecraft/cooking/husbandry/HusbandryLocatorTest.java index eb912dc..e8ab22f 100644 --- a/src/test/java/net/tfminecraft/cooking/husbandry/HusbandryLocatorTest.java +++ b/src/test/java/net/tfminecraft/cooking/husbandry/HusbandryLocatorTest.java @@ -36,8 +36,8 @@ void droppingAGhostRemovesTheRowAndOwners(@TempDir Path tempDir) { animal.setLastLocation("TFMC_Map", 4369, 167, 1950); animal.setUnloadedAt(50L); repository.upsertAnimal(animal); - repository.upsertOwner(new HusbandryOwner(animalId, ownerId, "owner")); - repository.upsertOwner(new HusbandryOwner(animalId, coOwnerId, "coowner")); + repository.upsertOwner(new HusbandryOwner(animalId, ownerId, ownerId, "owner")); + repository.upsertOwner(new HusbandryOwner(animalId, coOwnerId, coOwnerId, "coowner")); HusbandryEntities.putLoaded(animal); String line = HusbandryLocator.deleteGhost(repository, animal); @@ -49,8 +49,8 @@ void droppingAGhostRemovesTheRowAndOwners(@TempDir Path tempDir) { assertTrue(HusbandryEntities.getLoaded(animalId).isEmpty()); assertFalse(HusbandryLocator.isMissing(animalId)); String owners = List.of( - new HusbandryOwner(animalId, ownerId, "owner"), - new HusbandryOwner(animalId, coOwnerId, "coowner")) + new HusbandryOwner(animalId, ownerId, ownerId, "owner"), + new HusbandryOwner(animalId, coOwnerId, coOwnerId, "coowner")) .stream() .sorted((left, right) -> left.playerUuid().compareTo(right.playerUuid())) .map(owner -> owner.playerUuid() + " (" + owner.role() + ")") diff --git a/src/test/java/net/tfminecraft/cooking/husbandry/HusbandryOwnershipMigrationTest.java b/src/test/java/net/tfminecraft/cooking/husbandry/HusbandryOwnershipMigrationTest.java new file mode 100644 index 0000000..3a08fb0 --- /dev/null +++ b/src/test/java/net/tfminecraft/cooking/husbandry/HusbandryOwnershipMigrationTest.java @@ -0,0 +1,203 @@ +package net.tfminecraft.cooking.husbandry; + +import static org.junit.jupiter.api.Assertions.*; +import java.nio.file.Path; +import java.sql.Connection; +import java.util.List; +import java.util.Optional; +import java.util.UUID; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.io.TempDir; +import net.tfminecraft.tlibs.character.CharacterRef; +import net.tfminecraft.tlibs.database.SqliteDatabaseException; +import static net.tfminecraft.cooking.husbandry.HusbandryOwnershipStore.*; + +class HusbandryOwnershipMigrationTest { + @TempDir Path directory; + final UUID account = UUID.randomUUID(), otherAccount = UUID.randomUUID(), character = UUID.randomUUID(); + + HusbandryAnimal animal(HusbandryRepository repo) { + HusbandryAnimal animal = new HusbandryAnimal(UUID.randomUUID(), "COW", "Daisy"); + animal.setCare(47); animal.setGenetics(901); animal.setHungrySince(23L); + repo.upsertAnimal(animal); return animal; + } + Connection connection(HusbandryRepository repo) { return repo.ownershipDatabase().getConnection(); } + CharacterRef heir() { return new CharacterRef(account, character); } + void legacy(HusbandryRepository repo, UUID animal, UUID owner, String role) throws Exception { + update(connection(repo), "INSERT INTO owners VALUES (?, ?, ?)", animal.toString(), owner.toString(), role); + } + + @Test void migrationWaitsForReviewedHeirsAndPublishesOnceWithImmutableIndependentArchive() throws Exception { + try (var repo = new Repository(directory.resolve("farm.db"))) { + var r = repo.value; var animal = animal(r); legacy(r, animal.uuid(), account, "owner"); + update(connection(r), "ALTER TABLE owners ADD COLUMN future TEXT"); + update(connection(r), "UPDATE owners SET future = 'preserved'"); + update(connection(r), "ALTER TABLE animals ADD COLUMN future TEXT DEFAULT 'animal-progress'"); + r.upsertSnapshots(java.util.Map.of(animal.uuid(), new byte[]{1,2,3}), 71); + assertFalse(r.migrateOwners(() -> false, id -> { fail("No heir lookup before readiness"); return Optional.empty(); })); + assertTrue(r.hasAnyOwner(animal.uuid())); assertTrue(r.hasHeldOwner(animal.uuid())); + assertEquals(1, r.countForPlayer(account)); assertTrue(r.listOwners(animal.uuid()).isEmpty()); + assertTrue(r.migrateOwners(() -> true, id -> Optional.of(heir()))); + assertEquals(heir(), r.listOwners(animal.uuid()).getFirst().ref()); assertEquals(0, r.heldOwners()); + assertFalse(r.hasHeldOwner(animal.uuid())); assertEquals(1, r.listForCharacter(heir()).size()); + assertTrue(r.listForCharacter(new CharacterRef(account, UUID.randomUUID())).isEmpty()); + assertTrue(r.migrateOwners(() -> { fail("Completed mapping is immutable"); return false; }, id -> Optional.empty())); + animal.setCare(59); r.upsertAnimal(animal); + assertEquals("preserved", rows(connection(r), "SELECT future FROM husbandry_owner_archive").getFirst()[0]); + assertEquals("animal-progress", rows(connection(r), "SELECT future FROM animals").getFirst()[0]); + assertArrayEquals(new byte[]{1,2,3}, r.getSnapshot(animal.uuid()).orElseThrow().data()); + assertThrows(Exception.class, () -> update(connection(r), "DELETE FROM husbandry_owner_archive")); + assertThrows(Exception.class, () -> update(connection(r), "UPDATE husbandry_owner_archive SET future = ''")); + assertThrows(Exception.class, () -> update(connection(r), "INSERT INTO husbandry_owner_archive SELECT * FROM owners")); + r.deleteAnimal(animal.uuid()); + assertEquals(1, scalar(connection(r), "SELECT COUNT(*) FROM husbandry_owner_archive")); + assertEquals(0, scalar(connection(r), "SELECT COUNT(*) FROM owners")); + } + try (var repo = new Repository(directory.resolve("farm.db"))) { + assertTrue(repo.value.migrateOwners(() -> false, id -> Optional.empty())); + assertEquals(1, scalar(connection(repo.value), "SELECT COUNT(*) FROM husbandry_owner_archive")); + } + } + + @Test void heldCoownerPreservesOtherSlotAndAccountCapacityDeduplicatesAnimals() throws Exception { + try (var repo = new Repository(directory.resolve("farm.db"))) { + var r = repo.value; var animal = animal(r); + legacy(r, animal.uuid(), account, "owner"); legacy(r, animal.uuid(), otherAccount, "coowner"); + r.migrateOwners(() -> true, id -> id.equals(account) ? Optional.of(heir()) : Optional.empty()); + assertEquals(1, r.listOwners(animal.uuid()).size()); assertTrue(r.hasHeldOwner(animal.uuid())); + assertEquals(1, r.heldOwners()); assertEquals(1, r.countForPlayer(otherAccount)); + assertTrue(r.accountHasAnimal(otherAccount, animal.uuid())); + r.upsertOwner(new HusbandryOwner(animal.uuid(), account, UUID.randomUUID(), "coowner")); + assertEquals(1, r.countForPlayer(account)); assertEquals(1, r.listForPlayer(account).size()); + for (var owner : r.listOwners(animal.uuid())) r.deleteCharacterOwner(animal.uuid(), owner.characterUuid()); + assertTrue(r.hasAnyOwner(animal.uuid())); assertTrue(r.listOwners(animal.uuid()).isEmpty()); + assertFalse(r.accountHasAnimal(null, animal.uuid())); assertFalse(r.accountHasAnimal(account, null)); + assertFalse(r.hasAnyOwner(null)); assertFalse(r.hasHeldOwner(null)); assertTrue(r.listForCharacter(null).isEmpty()); + } + } + + @Test void faultsBeforePublicationRollBackArchivePlanAndSlotsThenReplayConservesPayload() throws Exception { + try (var repo = new Repository(directory.resolve("farm.db"))) { + var r = repo.value; var animal = animal(r); legacy(r, animal.uuid(), account, "owner"); + update(connection(r), "CREATE TRIGGER fail_publish BEFORE INSERT ON character_owners BEGIN SELECT RAISE(ABORT, 'fault'); END"); + assertThrows(SqliteDatabaseException.class, () -> r.migrateOwners(() -> true, id -> Optional.of(heir()))); + assertEquals(0, scalar(connection(r), "SELECT COUNT(*) FROM sqlite_master WHERE name = 'husbandry_owner_archive'")); + assertEquals(0, scalar(connection(r), "SELECT COUNT(*) FROM husbandry_owner_plan")); + assertEquals(0, scalar(connection(r), "SELECT COUNT(*) FROM character_owners")); + assertEquals(47, r.getAnimal(animal.uuid()).orElseThrow().care()); + update(connection(r), "DROP TRIGGER fail_publish"); r.migrateOwners(() -> true, id -> Optional.of(heir())); + assertEquals(1, r.listOwners(animal.uuid()).size()); + update(connection(r), "CREATE TRIGGER fail_owner BEFORE INSERT ON character_owners BEGIN SELECT RAISE(ABORT, 'fault'); END"); + var next = new HusbandryAnimal(UUID.randomUUID(), "SHEEP", "Not published"); + assertThrows(SqliteDatabaseException.class, () -> r.saveAnimalAndOwner(next, + new HusbandryOwner(next.uuid(), account, character, "owner"))); + assertFalse(r.exists(next.uuid())); + assertThrows(SqliteDatabaseException.class, () -> r.upsertOwner(new HusbandryOwner(animal.uuid(), account, UUID.randomUUID(), "owner"))); + } + } + + @Test void invalidIdentitiesRolesAndSlotCollisionsRemainHeldWithoutNormalization() throws Exception { + try (var repo = new Repository(directory.resolve("farm.db"))) { + var r = repo.value; var unknown = animal(r); legacy(r, unknown.uuid(), account, "future-owner"); + var conflicting = animal(r); legacy(r, conflicting.uuid(), account, "owner"); + r.upsertOwner(new HusbandryOwner(conflicting.uuid(), otherAccount, character, "owner")); + update(connection(r), "PRAGMA foreign_keys = OFF"); + update(connection(r), "INSERT INTO owners VALUES ('broken-animal', 'broken-account', 'owner')"); + UUID missing = UUID.randomUUID(); legacy(r, missing, account, "owner"); + update(connection(r), "PRAGMA foreign_keys = ON"); + r.migrateOwners(() -> true, id -> Optional.of(heir())); + assertEquals(4, r.heldOwners()); assertTrue(r.hasAnyOwner(unknown.uuid())); + assertEquals("future-owner", rows(connection(r), "SELECT role FROM husbandry_owner_archive WHERE animal_uuid = ?", unknown.uuid().toString()).getFirst()[0]); + assertEquals(1, r.listOwners(conflicting.uuid()).size()); + assertThrows(IllegalArgumentException.class, () -> r.upsertOwner(new HusbandryOwner(unknown.uuid(), account, character, "strange"))); + assertThrows(IllegalArgumentException.class, () -> r.upsertOwner(new HusbandryOwner(conflicting.uuid(), account, character, "owner"))); + assertThrows(IllegalArgumentException.class, () -> r.saveAnimalAndOwner(conflicting, new HusbandryOwner(unknown.uuid(), account, character, "owner"))); + assertThrows(IllegalArgumentException.class, () -> r.saveAnimalAndOwner(conflicting, new HusbandryOwner(conflicting.uuid(), account, character, "owner"))); + update(connection(r), "INSERT INTO character_owners VALUES (?, 'bad', 'bad', 'owner')", unknown.uuid().toString()); + update(connection(r), "INSERT INTO character_owners VALUES (?, ?, ?, 'future')", unknown.uuid().toString(), account.toString(), character.toString()); + assertTrue(r.listOwners(unknown.uuid()).isEmpty()); + assertThrows(IllegalArgumentException.class, () -> r.upsertOwner(new HusbandryOwner(unknown.uuid(), account, character, "owner"))); + } + } + + @Test void recognizedCollisionKeepsStrongerRoleAndWrongAccountHeirRemainsHeld() throws Exception { + try (var repo = new Repository(directory.resolve("farm.db"))) { + var r = repo.value; var first = animal(r); legacy(r, first.uuid(), account, "coowner"); + r.upsertOwner(new HusbandryOwner(first.uuid(), account, character, "owner")); + var second = animal(r); legacy(r, second.uuid(), otherAccount, "owner"); + r.migrateOwners(() -> true, id -> Optional.of(heir())); + assertEquals("owner", r.listOwners(first.uuid()).getFirst().role()); + assertEquals(1, r.heldOwners()); assertTrue(r.hasHeldOwner(second.uuid())); + assertEquals("coowner", stronger("coowner", "coowner")); + assertTrue(r.accountHasAnimal(account, first.uuid())); assertFalse(r.accountHasAnimal(account, second.uuid())); + } + } + + @Test void corruptedSchemaFailsClosedAndBindingFailureClosesStatement() throws Exception { + try (var repo = new Repository(directory.resolve("farm.db"))) { + var r = repo.value; var animal = animal(r); + update(connection(r), "DROP TABLE character_owners"); + assertThrows(SqliteDatabaseException.class, () -> r.listOwners(animal.uuid())); + assertThrows(SqliteDatabaseException.class, () -> r.countForPlayer(account)); + } + var db = org.mockito.Mockito.mock(net.tfminecraft.tlibs.database.SqliteDatabase.class); + var connection = org.mockito.Mockito.mock(Connection.class); + var statement = org.mockito.Mockito.mock(java.sql.PreparedStatement.class); + org.mockito.Mockito.when(db.getConnection()).thenReturn(connection); + org.mockito.Mockito.when(connection.prepareStatement(org.mockito.ArgumentMatchers.anyString())).thenReturn(statement); + org.mockito.Mockito.doThrow(new java.sql.SQLException("Binding interrupted")).when(statement).setObject(1, account.toString()); + var store = new HusbandryOwnershipStore(db); + assertThrows(SqliteDatabaseException.class, () -> store.countAccount(account)); + org.mockito.Mockito.verify(statement).close(); + } + + @Test void archiveVerificationFailureRollsBackAndNeverPublishesRecipients() throws Exception { + try (var repo = new Repository(directory.resolve("farm.db"))) { + var real = repo.value.ownershipDatabase(); + var proxy = org.mockito.Mockito.spy(real.getConnection()); + var broken = org.mockito.Mockito.mock(java.sql.PreparedStatement.class); + var count = org.mockito.Mockito.mock(java.sql.ResultSet.class); + org.mockito.Mockito.when(count.getInt(1)).thenReturn(-1); + org.mockito.Mockito.when(broken.executeQuery()).thenReturn(count); + org.mockito.Mockito.doReturn(broken).when(proxy).prepareStatement("SELECT COUNT(*) FROM husbandry_owner_archive"); + var db = org.mockito.Mockito.spy(real); org.mockito.Mockito.doReturn(proxy).when(db).getConnection(); + var store = new HusbandryOwnershipStore(db); + assertThrows(SqliteDatabaseException.class, () -> store.migrate(() -> true, id -> Optional.empty())); + assertEquals(0, scalar(real.getConnection(), "SELECT COUNT(*) FROM sqlite_master WHERE name = 'husbandry_owner_archive'")); + } + } + + @Test void compatibilityAccountApisRemainPresentButCannotGrantOrDeleteCharacterRights() { + try (var fixture = new Repository(directory.resolve("farm.db"))) { + var repo = fixture.value; var animal = animal(repo); + var legacy = new HusbandryOwner(animal.uuid(), account, "owner"); + assertNull(legacy.characterUuid()); assertEquals(account, legacy.playerUuid()); + assertThrows(IllegalArgumentException.class, () -> repo.upsertOwner(legacy)); + repo.upsertOwner(new HusbandryOwner(animal.uuid(), account, character, "owner")); + assertFalse(HusbandryOwnershipService.isOwner(account, animal.uuid())); + assertThrows(IllegalArgumentException.class, () -> repo.deleteOwner(animal.uuid(), account)); + assertEquals(1, repo.listOwners(animal.uuid()).size()); + } + } + + @Test void finalResolvedSlotReleaseDoesNotLetRetainedLegacyRowsKeepAnimalsOwned() throws Exception { + try(var fixture=new Repository(directory.resolve("farm.db"))) { + var repo=fixture.value;var animal=animal(repo);legacy(repo,animal.uuid(),account,"owner"); + repo.migrateOwners(()->true,id->Optional.of(heir())); + assertEquals(1,repo.listOwnedAnimals().size()); + repo.deleteCharacterOwner(animal.uuid(),character); + assertFalse(repo.hasAnyOwner(animal.uuid()));assertTrue(repo.listOwnedAnimals().isEmpty()); + assertEquals(1,scalar(connection(repo),"SELECT COUNT(*) FROM owners")); + var held=animal(repo);legacy(repo,held.uuid(),otherAccount,"coowner"); + repo.upsertOwner(new HusbandryOwner(held.uuid(),account,character,"owner")); + repo.deleteCharacterOwner(held.uuid(),character); + assertTrue(repo.hasAnyOwner(held.uuid()));assertEquals(1,repo.listOwnedAnimals().size()); + } + } + + static class Repository implements AutoCloseable { + final HusbandryRepository value; + Repository(Path path) { value = HusbandryRepository.open(path.toFile()); } + @Override public void close() { value.close(); } + } +} diff --git a/src/test/java/net/tfminecraft/cooking/husbandry/HusbandryRemainingCoverageTest.java b/src/test/java/net/tfminecraft/cooking/husbandry/HusbandryRemainingCoverageTest.java index 57dcf72..44ab1a0 100644 --- a/src/test/java/net/tfminecraft/cooking/husbandry/HusbandryRemainingCoverageTest.java +++ b/src/test/java/net/tfminecraft/cooking/husbandry/HusbandryRemainingCoverageTest.java @@ -78,7 +78,7 @@ void products() throws Exception { hold(Material.LEATHER, 1); listener.onInteract(interaction(cow)); assertEquals(1, held()); animal.setDirtySince(1L); var clean = interaction(cow); listener.onInteract(clean); assertNull(animal.dirtySince()); assertEquals(1, held()); assertTrue(clean.isCancelled()); assertNull(f.repository.getAnimal(animal.uuid()).orElseThrow().dirtySince()); - animal.setHungrySince(1L); f.repository.deleteOwner(animal.uuid(), f.player.getUniqueId()); hold(Material.WHEAT, 2); listener.onInteract(interaction(cow)); assertEquals(2, held()); assertNotNull(animal.hungrySince()); + animal.setHungrySince(1L); f.repository.deleteCharacterOwner(animal.uuid(), f.player.getUniqueId()); hold(Material.WHEAT, 2); listener.onInteract(interaction(cow)); assertEquals(2, held()); assertNotNull(animal.hungrySince()); f.player.setOp(true); listener.onInteract(interaction(cow)); assertEquals(1, held()); assertNull(animal.hungrySince()); } @@ -141,7 +141,7 @@ boolean attack(HusbandryDamageListener listener, Entity victim, Entity attacker) command.onCommand(console, null, "animals", new String[0]); verify(console).sendMessage(any(net.kyori.adventure.text.Component.class)); f.player.setOp(true); command.onCommand(f.player, null, "animals", new String[]{"NeverJoined"}); assertTrue(f.player.nextMessage().contains("No player")); Cow cow = f.spawn(Cow.class); HusbandryAnimal animal = f.record(cow); f.own(animal, f.player); cow.teleport(new Location(f.world, 9, 66, 8)); animal.setDirtySince(1L); - command.onCommand(f.player, null, "animals", new String[0]); assertEquals(9, animal.x()); assertNotNull(f.player.nextMessage()); clearMessages(); + command.onCommand(f.player, null, "animals", new String[0]); assertEquals(9, animal.x()); assertEquals("Your character's animals (1)", ChatColor.stripColor(f.player.nextMessage())); assertEquals("Account animal places: 1/2", ChatColor.stripColor(f.player.nextMessage())); clearMessages(); HusbandryAnimal offline = f.ownedOffline("Offline"); command.onCommand(f.player, null, "animals", new String[]{f.player.getName()}); assertNotNull(f.player.nextMessage()); clearMessages(); assertEquals(List.of("Farmer"), command.onTabComplete(f.player, null, "animals", new String[]{"f"})); assertEquals(List.of("Farmer", "Neighbour"), command.onTabComplete(f.player, null, "animals", new String[]{""})); assertTrue(command.onTabComplete(f.neighbour, null, "animals", new String[]{""}).isEmpty()); assertTrue(command.onTabComplete(f.player, null, "animals", new String[0]).isEmpty()); @@ -300,7 +300,7 @@ boolean attack(HusbandryDamageListener listener, Entity victim, Entity attacker) HusbandryRepository repository = f.repository; HusbandryAnimal good = f.offline("COW", "???"), invalid = new HusbandryAnimal(null, "COW", "No ID"); repository.upsertAnimal(null); repository.upsertAnimal(invalid); repository.upsertAnimals(null); repository.upsertAnimals(List.of()); repository.upsertAnimals(Arrays.asList(null, invalid)); repository.upsertAnimals(Arrays.asList(null, good)); assertEquals("", repository.getAnimal(good.uuid()).orElseThrow().name()); assertTrue(repository.getAnimal(null).isEmpty()); assertFalse(repository.exists(null)); repository.deleteAnimal(null); - repository.upsertOwner(null); repository.upsertOwner(new HusbandryOwner(null, null, null)); repository.deleteOwner(null, null); assertEquals(0, repository.countForPlayer(null)); assertTrue(repository.listOwners(null).isEmpty()); assertTrue(repository.listForPlayer(null).isEmpty()); + repository.upsertOwner(null); repository.upsertOwner(new HusbandryOwner(null, null, null, null)); repository.deleteCharacterOwner(null, null); assertEquals(0, repository.countForPlayer(null)); assertTrue(repository.listOwners(null).isEmpty()); assertTrue(repository.listForPlayer(null).isEmpty()); repository.upsertSnapshots(null, 0); repository.upsertSnapshots(Map.of(), 0); Map snapshots = new LinkedHashMap<>(); snapshots.put(null, new byte[]{1}); snapshots.put(good.uuid(), null); repository.upsertSnapshots(snapshots, 1); assertTrue(repository.getSnapshot(good.uuid()).isEmpty()); assertTrue(repository.getSnapshot(null).isEmpty()); repository.deleteSnapshot(null); assertEquals(0, repository.resetStaleStats(" ", null)); } diff --git a/src/test/java/net/tfminecraft/cooking/husbandry/HusbandryRepositoryTest.java b/src/test/java/net/tfminecraft/cooking/husbandry/HusbandryRepositoryTest.java index 1b0b782..0f717f5 100644 --- a/src/test/java/net/tfminecraft/cooking/husbandry/HusbandryRepositoryTest.java +++ b/src/test/java/net/tfminecraft/cooking/husbandry/HusbandryRepositoryTest.java @@ -145,10 +145,10 @@ void locationRoundTripsAndListsOnlyThatPlayersAnimals(@TempDir Path tempDir) { HusbandryAnimal sheep = animal(UUID.randomUUID(), "SHEEP", "Woolly", 4); HusbandryAnimal pig = animal(UUID.randomUUID(), "PIG", "Ham", 1); repository.upsertAnimals(List.of(cow, sheep, pig)); - repository.upsertOwner(new HusbandryOwner(cow.uuid(), player, "owner")); - repository.upsertOwner(new HusbandryOwner(sheep.uuid(), player, "coowner")); - repository.upsertOwner(new HusbandryOwner(sheep.uuid(), other, "owner")); - repository.upsertOwner(new HusbandryOwner(pig.uuid(), other, "owner")); + repository.upsertOwner(new HusbandryOwner(cow.uuid(), player, player, "owner")); + repository.upsertOwner(new HusbandryOwner(sheep.uuid(), player, player, "coowner")); + repository.upsertOwner(new HusbandryOwner(sheep.uuid(), other, other, "owner")); + repository.upsertOwner(new HusbandryOwner(pig.uuid(), other, other, "owner")); HusbandryAnimal reloaded = repository.getAnimal(cow.uuid()).orElseThrow(); assertEquals("world", reloaded.world()); diff --git a/src/test/java/net/tfminecraft/cooking/husbandry/HusbandryRuntimeCoverageTest.java b/src/test/java/net/tfminecraft/cooking/husbandry/HusbandryRuntimeCoverageTest.java index 91a2b7a..3d13bea 100644 --- a/src/test/java/net/tfminecraft/cooking/husbandry/HusbandryRuntimeCoverageTest.java +++ b/src/test/java/net/tfminecraft/cooking/husbandry/HusbandryRuntimeCoverageTest.java @@ -297,10 +297,19 @@ static final class Farm implements AutoCloseable { final YamlConfiguration config = new YamlConfiguration(); final HusbandryLoaderCoverageTest.ConfigSnapshot snapshot; final MockedStatic tlibs; + final net.tfminecraft.cooking.testsupport.TestCharacters characters; Farm(Path directory) throws Exception { this.directory = directory; snapshot = new HusbandryLoaderCoverageTest.ConfigSnapshot(); previous = Cooking.plugin; server = MockBukkit.mock(); world = addWorld("farm"); player = server.addPlayer("Farmer"); neighbour = server.addPlayer("Neighbour"); player.teleport(new Location(world, 0, 65, 0)); neighbour.teleport(new Location(world, 1, 65, 0)); plugin = mock(Cooking.class); when(plugin.getName()).thenReturn("Cooking"); when(plugin.namespace()).thenReturn("cooking"); when(plugin.isEnabled()).thenReturn(true); Cooking.plugin = plugin; + characters = new net.tfminecraft.cooking.testsupport.TestCharacters(plugin); + characters.activate(player, player.getUniqueId()); characters.activate(neighbour, neighbour.getUniqueId()); + when(characters.provider.info(any())).thenAnswer(call -> { + UUID id = call.getArgument(0); Player online = server.getPlayer(id); + return online == null ? Optional.empty() : Optional.of(new net.tfminecraft.tlibs.character.CharacterInfo( + new net.tfminecraft.tlibs.character.CharacterRef(id, id), online.getName(), + net.tfminecraft.tlibs.character.CharacterStatus.ALIVE, null, Set.of(), 0, null)); + }); repository = spy(HusbandryRepository.open(directory.resolve("husbandry.db").toFile())); when(plugin.getHusbandryRepository()).thenReturn(repository); HusbandryEntities.clearLoaded(); config.loadFromString(""" max-animals: 2 @@ -341,10 +350,10 @@ T spawn(Class type) { HusbandryAnimal record(LivingEntity entity) { HusbandryAnimal animal = new HusbandryAnimal(entity.getUniqueId(), entity.getType().name(), HusbandryEntities.displayName(entity.getType())); animal.setLastProcessedAt(System.currentTimeMillis()); animal.setAfflictionAt(100); animal.setStatsRevision(HusbandryConfig.statsRevision()); repository.upsertAnimal(animal); HusbandryEntities.putLoaded(animal); return animal; } HusbandryAnimal offline(String type, String name) { HusbandryAnimal animal = new HusbandryAnimal(UUID.randomUUID(), type, name); animal.setLastProcessedAt(System.currentTimeMillis()); animal.setUnloadedAt(1L); animal.setLastLocation("farm", 0, 65, 0); repository.upsertAnimal(animal); return animal; } HusbandryAnimal ownedOffline(String name) { HusbandryAnimal animal = offline("COW", name); own(animal, player); return animal; } - void own(HusbandryAnimal animal, PlayerMock owner) { animal.setState(HusbandryAnimalState.OWNED); repository.upsertAnimal(animal); repository.upsertOwner(new HusbandryOwner(animal.uuid(), owner.getUniqueId(), "owner")); } + void own(HusbandryAnimal animal, PlayerMock owner) { animal.setState(HusbandryAnimalState.OWNED); repository.upsertAnimal(animal); repository.upsertOwner(new HusbandryOwner(animal.uuid(), owner.getUniqueId(), owner.getUniqueId(), "owner")); } @Override public void close() throws Exception { for (HusbandryAnimal animal : repository.listOwnedAnimals()) HusbandryLocator.markFound(animal.uuid()); - HusbandryTickTask.stop(); HusbandryEntities.clearLoaded(); repository.close(); MockBukkit.unmock(); tlibs.close(); Cooking.plugin = previous; snapshot.close(); + HusbandryTickTask.stop(); HusbandryEntities.clearLoaded(); repository.close(); characters.close(); MockBukkit.unmock(); tlibs.close(); Cooking.plugin = previous; snapshot.close(); } } diff --git a/src/test/java/net/tfminecraft/cooking/husbandry/HusbandrySnapshotsTest.java b/src/test/java/net/tfminecraft/cooking/husbandry/HusbandrySnapshotsTest.java index 9d194de..baacb4d 100644 --- a/src/test/java/net/tfminecraft/cooking/husbandry/HusbandrySnapshotsTest.java +++ b/src/test/java/net/tfminecraft/cooking/husbandry/HusbandrySnapshotsTest.java @@ -246,6 +246,6 @@ private static HusbandryAnimal lostHorse() { } private static List owners() { - return List.of(new HusbandryOwner(HORSE, OWNER, "owner")); + return List.of(new HusbandryOwner(HORSE, OWNER, OWNER, "owner")); } } diff --git a/tools/husbandry-ownership-rollback.py b/tools/husbandry-ownership-rollback.py new file mode 100644 index 0000000..2728cbe --- /dev/null +++ b/tools/husbandry-ownership-rollback.py @@ -0,0 +1,64 @@ +#!/usr/bin/env python3 +"""Preview an offline ownership rollback; --apply preserves a consistent current SQLite backup first.""" +import argparse +import json +from pathlib import Path +import sqlite3 +import time + + +def rollback(path, apply=False, backup=None): + path = Path(path).resolve() + uri = path.as_uri() + ('?mode=rw' if apply else '?mode=ro') + with sqlite3.connect(uri, uri=True) as database: + database.execute('PRAGMA foreign_keys=ON') + if database.execute('PRAGMA integrity_check').fetchone() != ('ok',): + raise ValueError('SQLite integrity check failed') + columns = [row[1] for row in database.execute('PRAGMA table_info(husbandry_owner_archive)')] + if not columns: + raise ValueError('No immutable husbandry owner archive exists') + current_columns = {row[1] for row in database.execute('PRAGMA table_info(owners)')} + if not set(columns) <= current_columns: + raise ValueError('Legacy owner schema conflicts with archive; review by hand') + result = { + 'archived_slots': database.execute('SELECT COUNT(*) FROM husbandry_owner_archive').fetchone()[0], + 'restorable_slots': database.execute('SELECT COUNT(*) FROM husbandry_owner_archive o JOIN animals a ON a.uuid=o.animal_uuid').fetchone()[0], + 'current_character_slots': database.execute('SELECT COUNT(*) FROM character_owners').fetchone()[0], + 'held_legacy_slots': database.execute("SELECT COUNT(*) FROM husbandry_owner_plan WHERE status='held'").fetchone()[0], + 'changed_resolved_slots': database.execute('''SELECT COUNT(*) FROM husbandry_owner_plan p + WHERE p.status='resolved' AND NOT EXISTS (SELECT 1 FROM character_owners c + WHERE c.animal_uuid=p.animal_uuid AND c.account_uuid=p.account_uuid + AND c.character_uuid=p.character_uuid AND c.role=p.role)''').fetchone()[0], + 'applied': False, + } + if not apply: + return result + target = Path(backup) if backup else path.with_name(path.name + '.before-owner-rollback-' + str(time.time_ns())) + # Never overwrite the only recovery image. SQLite backup includes committed WAL pages. + with target.open('xb'): + pass + with sqlite3.connect(target) as copy: + database.backup(copy) + if copy.execute('PRAGMA integrity_check').fetchone() != ('ok',): + raise ValueError('Current-state backup verification failed') + quoted = ', '.join('"' + column.replace('"', '""') + '"' for column in columns) + selected = ', '.join('o."' + column.replace('"', '""') + '"' for column in columns) + database.execute('BEGIN IMMEDIATE') + database.execute('DELETE FROM owners') + database.execute(f'INSERT INTO owners ({quoted}) SELECT {selected} FROM husbandry_owner_archive o JOIN animals a ON a.uuid=o.animal_uuid') + database.commit() + result.update(applied=True, backup=str(target)) + return result + + +def main(): + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument('database', type=Path) + parser.add_argument('--apply', action='store_true', help='Restore account owner slots for old Cooking while the server is stopped') + parser.add_argument('--backup', type=Path, help='New path for the current-state SQLite recovery image') + arguments = parser.parse_args() + print(json.dumps(rollback(arguments.database, arguments.apply, arguments.backup), indent=2)) + + +if __name__ == '__main__': + main() diff --git a/tools/test_husbandry_ownership_rollback.py b/tools/test_husbandry_ownership_rollback.py new file mode 100644 index 0000000..aad23db --- /dev/null +++ b/tools/test_husbandry_ownership_rollback.py @@ -0,0 +1,72 @@ +import importlib.util +from pathlib import Path +import sqlite3 +import tempfile +import unittest + +spec = importlib.util.spec_from_file_location('owner_rollback', Path(__file__).with_name('husbandry-ownership-rollback.py')) +rollback = importlib.util.module_from_spec(spec) +spec.loader.exec_module(rollback) + + +class OwnershipRollbackTest(unittest.TestCase): + def setUp(self): + self.temporary = tempfile.TemporaryDirectory() + self.path = Path(self.temporary.name) / 'husbandry.db' + self.database = sqlite3.connect(self.path) + self.database.executescript(''' + PRAGMA journal_mode=WAL; + CREATE TABLE animals (uuid TEXT PRIMARY KEY, care INTEGER, snapshot BLOB); + CREATE TABLE owners (animal_uuid TEXT, player_uuid TEXT, role TEXT, unknown TEXT, + FOREIGN KEY(animal_uuid) REFERENCES animals(uuid)); + INSERT INTO animals VALUES ('animal', 71, X'0001FF'); + INSERT INTO owners VALUES ('animal','account','owner',NULL); + CREATE TABLE husbandry_owner_archive AS SELECT * FROM owners; + INSERT INTO husbandry_owner_archive VALUES ('deleted-animal','account','coowner','original'); + CREATE TABLE character_owners (animal_uuid TEXT, account_uuid TEXT, character_uuid TEXT, role TEXT); + INSERT INTO character_owners VALUES ('animal','heir-account','heir','owner'); + CREATE TABLE husbandry_owner_plan (animal_uuid TEXT, account_uuid TEXT, character_uuid TEXT, role TEXT, status TEXT); + INSERT INTO husbandry_owner_plan VALUES ('animal','account','original-character','owner','resolved'); + INSERT INTO husbandry_owner_plan VALUES ('deleted-animal','account',NULL,'coowner','held'); + ''') + self.database.commit() + + def tearDown(self): + self.database.close() + self.temporary.cleanup() + + def test_preview_retains_every_current_row(self): + result = rollback.rollback(self.path) + self.assertEqual(2, result['archived_slots']) + self.assertEqual(1, result['restorable_slots']) + self.assertEqual(1, result['changed_resolved_slots']) + self.assertFalse(result['applied']) + self.assertEqual(('heir-account',), self.database.execute('SELECT account_uuid FROM character_owners').fetchone()) + self.assertEqual(71, self.database.execute('SELECT care FROM animals').fetchone()[0]) + + def test_apply_preserves_verified_wal_backup_progress_and_null_fields(self): + backup = self.path.with_name('recover.db') + result = rollback.rollback(self.path, True, backup) + self.assertTrue(result['applied']) + self.assertEqual([('animal','account','owner',None)], self.database.execute('SELECT * FROM owners').fetchall()) + self.assertEqual((71,b'\x00\x01\xff'), self.database.execute('SELECT care,snapshot FROM animals').fetchone()) + with sqlite3.connect(backup) as recovered: + self.assertEqual(('heir-account',), recovered.execute('SELECT account_uuid FROM character_owners').fetchone()) + self.assertEqual(('ok',), recovered.execute('PRAGMA integrity_check').fetchone()) + with self.assertRaises(FileExistsError): + rollback.rollback(self.path, True, backup) + self.assertEqual(1, self.database.execute('SELECT COUNT(*) FROM animals').fetchone()[0]) + + def test_missing_archive_and_changed_legacy_schema_are_refused(self): + self.database.execute('ALTER TABLE owners DROP COLUMN unknown') + self.database.commit() + with self.assertRaisesRegex(ValueError, 'schema conflicts'): + rollback.rollback(self.path) + self.database.execute('DROP TABLE husbandry_owner_archive') + self.database.commit() + with self.assertRaisesRegex(ValueError, 'No immutable'): + rollback.rollback(self.path) + + +if __name__ == '__main__': + unittest.main() From 7ac2287744209a7dfa4e0d483707b90bcd299ff5 Mon Sep 17 00:00:00 2001 From: Ryan Barlow <7389646+ryanbarlow97@users.noreply.github.com> Date: Tue, 13 Oct 2026 22:28:17 +0000 Subject: [PATCH 2/4] fix: preserve completed animal estate receipts --- .../cooking/husbandry/HusbandryEstate.java | 51 +++++++++--- .../husbandry/HusbandryEstateTest.java | 79 +++++++++++++++++++ 2 files changed, 120 insertions(+), 10 deletions(-) diff --git a/src/main/java/net/tfminecraft/cooking/husbandry/HusbandryEstate.java b/src/main/java/net/tfminecraft/cooking/husbandry/HusbandryEstate.java index 583009c..15f393d 100644 --- a/src/main/java/net/tfminecraft/cooking/husbandry/HusbandryEstate.java +++ b/src/main/java/net/tfminecraft/cooking/husbandry/HusbandryEstate.java @@ -53,19 +53,16 @@ static void start(Plugin plugin, HusbandryRepository repository) { try { if (decision.property().character().isEmpty()) return receipts(decision); CharacterRef to = decision.property().character().orElseThrow(); + if (to.equals(decision.testator())) return receipts(decision); db.runTransaction(connection -> { try { if (scalar(connection, "SELECT COUNT(*) FROM husbandry_estate_intents WHERE status != 'done' AND (death_id = ? OR from_character = ?)", decision.deathId().toString(), decision.testator().characterId().toString()) != 0) { throw new Held("Incomplete animal estate intent requires staff review"); } - List receipts = rows(connection, - "SELECT decision FROM husbandry_estate_intents WHERE death_id = ? AND revision = ?", - decision.deathId().toString(), decision.revision()); - if (!receipts.isEmpty()) { - if (!decision.toString().equals(receipts.get(0)[0])) throw new Held("Decision conflicts with stored animal receipt"); - return; - } + validateReceipts(connection, decision); + if (scalar(connection, "SELECT COUNT(*) FROM husbandry_estate_intents WHERE death_id = ? AND revision = ?", + decision.deathId().toString(), decision.revision()) != 0) return; List source = rows(connection, "SELECT animal_uuid, account_uuid, role FROM character_owners WHERE character_uuid = ?", decision.testator().characterId().toString()); @@ -120,9 +117,7 @@ private Outcome receipts(EstateDecision decision) throws SQLException { decision.deathId().toString(), decision.testator().characterId().toString()) != 0) { throw new Held("Incomplete animal estate intent requires staff review"); } - List saved = rows(connection, "SELECT decision FROM husbandry_estate_intents WHERE death_id = ? AND revision = ?", - decision.deathId().toString(), decision.revision()); - if (!saved.isEmpty() && !decision.toString().equals(saved.getFirst()[0])) throw new Held("Decision conflicts with stored animal receipt"); + validateReceipts(connection, decision); List moved = rows(connection, "SELECT DISTINCT animal_uuid FROM husbandry_estate_slots WHERE death_id = ? ORDER BY animal_uuid", decision.deathId().toString()).stream().map(row -> row[0]).toList(); @@ -130,6 +125,42 @@ private Outcome receipts(EstateDecision decision) throws SQLException { } } + /** A restored source cannot become an untouched holding merely by changing the revision. */ + private static void validateReceipts(Connection connection, EstateDecision decision) throws SQLException { + List saved = rows(connection, + "SELECT revision, decision, from_account, from_character, to_account, to_character FROM husbandry_estate_intents WHERE death_id = ?", + decision.deathId().toString()); + for (String[] row : saved) { + long revision; + try { revision = Long.parseLong(row[0]); } + catch (RuntimeException invalid) { throw new Held("Animal receipt revision is invalid"); } + if (revision < 0 || row[1].isBlank()) throw new Held("Animal receipt decision envelope is invalid"); + if (!decision.testator().ownerId().toString().equals(row[2]) + || !decision.testator().characterId().toString().equals(row[3])) + throw new Held("Animal receipt testator conflicts with this death"); + CharacterRef recipient; + try { recipient = new CharacterRef(UUID.fromString(row[4]), UUID.fromString(row[5])); } + catch (RuntimeException invalid) { throw new Held("Animal receipt recipient is invalid"); } + if (!row[1].startsWith("EstateDecision[deathId=" + decision.deathId() + ", testator=" + decision.testator() + ", diedAt=") + || !row[1].contains(", property=" + net.tfminecraft.tlibs.character.estate.Recipient.character(recipient) + ", guildLocks=") + || !row[1].endsWith(", revision=" + revision + "]")) + throw new Held("Historical animal receipt conflicts with its captured decision"); + if (revision == decision.revision()) { + if (!decision.toString().equals(row[1]) + || !decision.property().character().equals(java.util.Optional.of(recipient))) + throw new Held("Decision conflicts with stored animal receipt"); + if (scalar(connection, "SELECT COUNT(*) FROM character_owners WHERE character_uuid = ?", + decision.testator().characterId().toString()) != 0) + throw new Held("Completed animal receipt conflicts with remaining property; review a new revision"); + } + } + if (scalar(connection, """ + SELECT COUNT(*) FROM character_owners o JOIN husbandry_estate_slots s ON s.animal_uuid = o.animal_uuid + WHERE s.death_id = ? AND o.character_uuid = ? + """, decision.deathId().toString(), decision.testator().characterId().toString()) != 0) + throw new Held("Previously transferred animal reappeared at the testator; preserve receipts for review"); + } + /** Independent raw rows retain unknown nullable/BLOB metadata even after the source slot is deleted. */ private static void archiveSlot(Connection connection, EstateDecision decision, String animal, UUID character, String position) throws SQLException { diff --git a/src/test/java/net/tfminecraft/cooking/husbandry/HusbandryEstateTest.java b/src/test/java/net/tfminecraft/cooking/husbandry/HusbandryEstateTest.java index ed0c592..f71e117 100644 --- a/src/test/java/net/tfminecraft/cooking/husbandry/HusbandryEstateTest.java +++ b/src/test/java/net/tfminecraft/cooking/husbandry/HusbandryEstateTest.java @@ -78,6 +78,85 @@ HusbandryEstate estate(HusbandryRepository repo) { assertEquals(testator, repo.listOwners(animal.uuid()).getFirst().ref()); } } + @Test void restoredTransferredSlotBlocksEveryRevisionAndRestartWithoutChangingRowsOrReceipts() throws Exception { + UUID animalId; + try (var fixture = new HusbandryOwnershipMigrationTest.Repository(directory.resolve("farm.db"))) { + var repo = fixture.value; var animal = own(repo, testator, "owner"); animalId = animal.uuid(); + repo.upsertOwner(new HusbandryOwner(animalId, coowner.ownerId(), coowner.characterId(), "coowner")); + var estate = estate(repo); assertEquals(Outcome.Kind.DONE, estate.settle(decision(1, Recipient.character(heir))).kind()); + repo.upsertOwner(new HusbandryOwner(animalId, testator.ownerId(), testator.characterId(), "owner")); + var connection = repo.ownershipDatabase().getConnection(); + var owners = rows(connection, "SELECT * FROM character_owners ORDER BY animal_uuid, character_uuid").stream().map(Arrays::asList).toList(); + var intents = rows(connection, "SELECT * FROM husbandry_estate_intents").stream().map(Arrays::asList).toList(); + var slots = rows(connection, "SELECT * FROM husbandry_estate_slots").stream().map(Arrays::asList).toList(); + for (var request : List.of(decision(1, Recipient.character(heir)), decision(2, Recipient.character(coowner)), + decision(3, Recipient.WAITING), decision(4, Recipient.FROZEN))) { + assertEquals(Outcome.Kind.BLOCKED, estate.settle(request).kind()); + assertEquals(owners, rows(connection, "SELECT * FROM character_owners ORDER BY animal_uuid, character_uuid").stream().map(Arrays::asList).toList()); + assertEquals(intents, rows(connection, "SELECT * FROM husbandry_estate_intents").stream().map(Arrays::asList).toList()); + assertEquals(slots, rows(connection, "SELECT * FROM husbandry_estate_slots").stream().map(Arrays::asList).toList()); + assertEquals(61, repo.getAnimal(animalId).orElseThrow().care()); + } + } + try (var fixture = new HusbandryOwnershipMigrationTest.Repository(directory.resolve("farm.db"))) { + assertEquals(Outcome.Kind.BLOCKED, estate(fixture.value).settle(decision(5, Recipient.character(heir))).kind()); + assertEquals(3, fixture.value.listOwners(animalId).size()); + } + } + @Test void completedEmptyReceiptRequiresNewRevisionForLateUntouchedHoldings() { + try (var fixture = new HusbandryOwnershipMigrationTest.Repository(directory.resolve("farm.db"))) { + var estate = estate(fixture.value); var request = decision(1, Recipient.character(heir)); + assertEquals(Outcome.done(), estate.settle(request)); + var late = own(fixture.value, testator, "owner"); + assertEquals(Outcome.Kind.BLOCKED, estate.settle(request).kind()); + assertEquals(testator, fixture.value.listOwners(late.uuid()).getFirst().ref()); + assertEquals(List.of(late.uuid().toString()), ((Outcome.Done)estate.settle(decision(2, Recipient.character(heir)))).movedIds()); + } + } + @Test void completedReceiptsValidateFullRefsAndSupportedDecisionEnvelope() throws Exception { + try (var fixture = new HusbandryOwnershipMigrationTest.Repository(directory.resolve("farm.db"))) { + var repo = fixture.value; var animal = own(repo, testator, "owner"); var estate = estate(repo); + var request = decision(1, Recipient.character(heir)); assertEquals(Outcome.Kind.DONE, estate.settle(request).kind()); + var connection = repo.ownershipDatabase().getConnection(); + Map original = Map.of("from_account",testator.ownerId().toString(),"from_character",testator.characterId().toString(), + "to_account",heir.ownerId().toString(),"to_character",heir.characterId().toString(),"revision","1","decision",request.toString()); + for (String field : List.of("from_account","from_character","to_account","to_character","revision","decision")) { + List invalid = field.equals("revision") ? List.of("invalid", "-1") : field.equals("decision") ? List.of("", "wrong") : List.of(UUID.randomUUID().toString()); + for (String value : invalid) { + update(connection,"UPDATE husbandry_estate_intents SET " + field + " = ?",value); + assertEquals(Outcome.Kind.BLOCKED,estate.settle(request).kind(),field+"="+value); + assertEquals(heir,repo.listOwners(animal.uuid()).getFirst().ref()); + update(connection,"UPDATE husbandry_estate_intents SET " + field + " = ?",original.get(field)); + } + } + update(connection,"UPDATE husbandry_estate_intents SET to_account = 'invalid'"); + assertEquals(Outcome.Kind.BLOCKED,estate.settle(decision(2,Recipient.FROZEN)).kind()); + } + } + @Test void historicalEnvelopeCorruptionBlocksNewWaitingAndCharacterRevisionsWithoutChangingState() throws Exception { + try (var fixture = new HusbandryOwnershipMigrationTest.Repository(directory.resolve("farm.db"))) { + var repo = fixture.value; var animal = own(repo,testator,"owner");var estate=estate(repo); + var request=decision(1,Recipient.character(heir));assertEquals(Outcome.Kind.DONE,estate.settle(request).kind()); + var connection=repo.ownershipDatabase().getConnection(); + Map original=Map.of("to_account",heir.ownerId().toString(),"to_character",heir.characterId().toString(), + "decision",request.toString(),"revision","1"); + for(String field:List.of("to_account","to_character","decision","revision")) { + String value=field.equals("decision")?"wrong":field.equals("revision")?"2":UUID.randomUUID().toString(); + update(connection,"UPDATE husbandry_estate_intents SET "+field+" = ?",value); + var intents=rows(connection,"SELECT * FROM husbandry_estate_intents").stream().map(Arrays::asList).toList(); + var slots=rows(connection,"SELECT * FROM husbandry_estate_slots").stream().map(Arrays::asList).toList(); + var archive=rows(connection,"SELECT * FROM husbandry_estate_owner_archive").stream().map(Arrays::asList).toList(); + for(var next:List.of(decision(3,Recipient.WAITING),decision(4,Recipient.character(coowner)))) { + assertEquals(Outcome.Kind.BLOCKED,estate.settle(next).kind(),field); + assertEquals(heir,repo.listOwners(animal.uuid()).getFirst().ref()); + assertEquals(intents,rows(connection,"SELECT * FROM husbandry_estate_intents").stream().map(Arrays::asList).toList()); + assertEquals(slots,rows(connection,"SELECT * FROM husbandry_estate_slots").stream().map(Arrays::asList).toList()); + assertEquals(archive,rows(connection,"SELECT * FROM husbandry_estate_owner_archive").stream().map(Arrays::asList).toList()); + } + update(connection,"UPDATE husbandry_estate_intents SET "+field+" = ?",original.get(field)); + } + } + } @Test void faultAfterFirstSlotRollsBackWholeEstateThenRetryMovesExactlyOnce() throws Exception { try (var fixture = new HusbandryOwnershipMigrationTest.Repository(directory.resolve("farm.db"))) { var repo = fixture.value; var first = own(repo, testator, "owner"); var second = own(repo, testator, "owner"); From 41736ea832bb36ebff77845ab120ff444bb63493 Mon Sep 17 00:00:00 2001 From: Ryan Barlow <7389646+ryanbarlow97@users.noreply.github.com> Date: Tue, 13 Oct 2026 22:43:47 +0000 Subject: [PATCH 3/4] fix: bind animal receipts to preserved source IDs --- .../cooking/husbandry/HusbandryEstate.java | 17 ++++++++++ .../husbandry/HusbandryEstateTest.java | 32 +++++++++++++++++++ 2 files changed, 49 insertions(+) diff --git a/src/main/java/net/tfminecraft/cooking/husbandry/HusbandryEstate.java b/src/main/java/net/tfminecraft/cooking/husbandry/HusbandryEstate.java index 15f393d..8ace671 100644 --- a/src/main/java/net/tfminecraft/cooking/husbandry/HusbandryEstate.java +++ b/src/main/java/net/tfminecraft/cooking/husbandry/HusbandryEstate.java @@ -145,6 +145,23 @@ private static void validateReceipts(Connection connection, EstateDecision decis || !row[1].contains(", property=" + net.tfminecraft.tlibs.character.estate.Recipient.character(recipient) + ", guildLocks=") || !row[1].endsWith(", revision=" + revision + "]")) throw new Held("Historical animal receipt conflicts with its captured decision"); + List original = scalar(connection, + "SELECT COUNT(*) FROM sqlite_master WHERE type = 'table' AND name = 'husbandry_estate_owner_archive'") == 0 + ? List.of() : rows(connection, """ + SELECT animal_uuid, account_uuid, character_uuid FROM husbandry_estate_owner_archive + WHERE estate_death_id = ? AND estate_revision = ? AND estate_position = 'source' + """, decision.deathId().toString(), revision); + for (String[] slot : original) { + if (!row[2].equals(slot[1]) || !row[3].equals(slot[2])) + throw new Held("Animal receipt source archive conflicts with its full reference"); + } + List plannedIds = original.stream().map(slot -> slot[0]).toList(); + List movedIds = rows(connection, + "SELECT animal_uuid FROM husbandry_estate_slots WHERE death_id = ? AND revision = ?", + decision.deathId().toString(), revision).stream().map(slot -> slot[0]).toList(); + if (plannedIds.size() != new java.util.HashSet<>(plannedIds).size() + || !new java.util.HashSet<>(plannedIds).equals(new java.util.HashSet<>(movedIds))) + throw new Held("Animal receipt IDs conflict with the immutable source archive"); if (revision == decision.revision()) { if (!decision.toString().equals(row[1]) || !decision.property().character().equals(java.util.Optional.of(recipient))) diff --git a/src/test/java/net/tfminecraft/cooking/husbandry/HusbandryEstateTest.java b/src/test/java/net/tfminecraft/cooking/husbandry/HusbandryEstateTest.java index f71e117..1d1756c 100644 --- a/src/test/java/net/tfminecraft/cooking/husbandry/HusbandryEstateTest.java +++ b/src/test/java/net/tfminecraft/cooking/husbandry/HusbandryEstateTest.java @@ -157,6 +157,38 @@ HusbandryEstate estate(HusbandryRepository repo) { } } } + @Test void missingCompletedIdsCannotHideRestoredSourcesFromNewRevisions() throws Exception { + try(var fixture=new HusbandryOwnershipMigrationTest.Repository(directory.resolve("farm.db"))) { + var repo=fixture.value;var animal=own(repo,testator,"owner");var estate=estate(repo); + assertEquals(Outcome.Kind.DONE,estate.settle(decision(1,Recipient.character(heir))).kind()); + var connection=repo.ownershipDatabase().getConnection();update(connection,"DELETE FROM husbandry_estate_slots"); + repo.upsertOwner(new HusbandryOwner(animal.uuid(),testator.ownerId(),testator.characterId(),"owner")); + var before=rows(connection,"SELECT * FROM character_owners ORDER BY character_uuid").stream().map(Arrays::asList).toList(); + assertEquals(Outcome.Kind.BLOCKED,estate.settle(decision(2,Recipient.character(coowner))).kind()); + assertEquals(before,rows(connection,"SELECT * FROM character_owners ORDER BY character_uuid").stream().map(Arrays::asList).toList()); + assertEquals(1,scalar(connection,"SELECT COUNT(*) FROM husbandry_estate_intents")); + assertEquals(0,scalar(connection,"SELECT COUNT(*) FROM husbandry_estate_slots")); + assertEquals(1,scalar(connection,"SELECT COUNT(*) FROM husbandry_estate_owner_archive")); + } + } + @Test void completedReceiptIdsAndArchivedRefsStayCoherentAcrossEveryRevision() throws Exception { + try(var fixture=new HusbandryOwnershipMigrationTest.Repository(directory.resolve("farm.db"))) { + var repo=fixture.value;var animal=own(repo,testator,"owner");var estate=estate(repo); + assertEquals(Outcome.Kind.DONE,estate.settle(decision(1,Recipient.character(heir))).kind()); + var connection=repo.ownershipDatabase().getConnection(); + for(String field:List.of("account_uuid","character_uuid")) { + update(connection,"UPDATE husbandry_estate_owner_archive SET "+field+" = ?",UUID.randomUUID().toString()); + assertEquals(Outcome.Kind.BLOCKED,estate.settle(decision(2,Recipient.FROZEN)).kind()); + update(connection,"UPDATE husbandry_estate_owner_archive SET "+field+" = ?",field.equals("account_uuid")?testator.ownerId().toString():testator.characterId().toString()); + } + update(connection,"INSERT INTO husbandry_estate_owner_archive SELECT * FROM husbandry_estate_owner_archive"); + assertEquals(Outcome.Kind.BLOCKED,estate.settle(decision(2,Recipient.FROZEN)).kind()); + update(connection,"DELETE FROM husbandry_estate_owner_archive WHERE rowid=(SELECT MAX(rowid) FROM husbandry_estate_owner_archive)"); + update(connection,"UPDATE husbandry_estate_slots SET animal_uuid = ?",UUID.randomUUID().toString()); + assertEquals(Outcome.Kind.BLOCKED,estate.settle(decision(2,Recipient.FROZEN)).kind()); + assertEquals(heir,repo.listOwners(animal.uuid()).getFirst().ref()); + } + } @Test void faultAfterFirstSlotRollsBackWholeEstateThenRetryMovesExactlyOnce() throws Exception { try (var fixture = new HusbandryOwnershipMigrationTest.Repository(directory.resolve("farm.db"))) { var repo = fixture.value; var first = own(repo, testator, "owner"); var second = own(repo, testator, "owner"); From 87187ba1af2b404b7273e56276752461c2dd0969 Mon Sep 17 00:00:00 2001 From: Ryan Barlow <7389646+ryanbarlow97@users.noreply.github.com> Date: Wed, 14 Oct 2026 01:34:58 +0000 Subject: [PATCH 4/4] fix(husbandry): preserve held records and receipt contracts --- .../cooking/husbandry/HusbandryEstate.java | 43 ++++- .../cooking/husbandry/HusbandryLocator.java | 18 ++- .../cooking/husbandry/HusbandryMigration.java | 32 +++- .../HusbandryDecisionEncodingTest.java | 146 +++++++++++++++++ .../HusbandryMaintenanceRegressionTest.java | 149 ++++++++++++++++++ tools/husbandry-ownership-rollback.py | 5 +- tools/test_husbandry_ownership_rollback.py | 26 +++ 7 files changed, 403 insertions(+), 16 deletions(-) create mode 100644 src/test/java/net/tfminecraft/cooking/husbandry/HusbandryDecisionEncodingTest.java create mode 100644 src/test/java/net/tfminecraft/cooking/husbandry/HusbandryMaintenanceRegressionTest.java diff --git a/src/main/java/net/tfminecraft/cooking/husbandry/HusbandryEstate.java b/src/main/java/net/tfminecraft/cooking/husbandry/HusbandryEstate.java index 8ace671..dd0b897 100644 --- a/src/main/java/net/tfminecraft/cooking/husbandry/HusbandryEstate.java +++ b/src/main/java/net/tfminecraft/cooking/husbandry/HusbandryEstate.java @@ -2,9 +2,11 @@ import java.sql.Connection; import java.sql.SQLException; +import java.time.Instant; import java.util.HashMap; import java.util.List; import java.util.Map; +import java.util.Optional; import java.util.UUID; import java.util.function.Predicate; import java.util.function.Supplier; @@ -18,6 +20,7 @@ import net.tfminecraft.tlibs.character.estate.Estates; import net.tfminecraft.tlibs.character.estate.Holdings; import net.tfminecraft.tlibs.character.estate.Outcome; +import net.tfminecraft.tlibs.character.estate.Recipient; import net.tfminecraft.tlibs.database.SqliteDatabaseException; import static net.tfminecraft.cooking.husbandry.HusbandryOwnershipStore.*; @@ -51,6 +54,7 @@ static void start(Plugin plugin, HusbandryRepository repository) { if (!guard.test(decision)) return Outcome.retry("Animal estate guard does not hold"); var db = repository.ownershipDatabase(); try { + String capturedDecision = captured(decision); if (decision.property().character().isEmpty()) return receipts(decision); CharacterRef to = decision.property().character().orElseThrow(); if (to.equals(decision.testator())) return receipts(decision); @@ -67,7 +71,7 @@ static void start(Plugin plugin, HusbandryRepository repository) { "SELECT animal_uuid, account_uuid, role FROM character_owners WHERE character_uuid = ?", decision.testator().characterId().toString()); update(connection, "INSERT INTO husbandry_estate_intents VALUES (?, ?, ?, ?, ?, ?, ?, 'prepared')", - decision.deathId().toString(), decision.revision(), decision.toString(), + decision.deathId().toString(), decision.revision(), capturedDecision, decision.testator().ownerId().toString(), decision.testator().characterId().toString(), to.ownerId().toString(), to.characterId().toString()); for (String[] row : source) { @@ -141,8 +145,8 @@ private static void validateReceipts(Connection connection, EstateDecision decis CharacterRef recipient; try { recipient = new CharacterRef(UUID.fromString(row[4]), UUID.fromString(row[5])); } catch (RuntimeException invalid) { throw new Held("Animal receipt recipient is invalid"); } - if (!row[1].startsWith("EstateDecision[deathId=" + decision.deathId() + ", testator=" + decision.testator() + ", diedAt=") - || !row[1].contains(", property=" + net.tfminecraft.tlibs.character.estate.Recipient.character(recipient) + ", guildLocks=") + if (!row[1].startsWith("EstateDecision[deathId=" + decision.deathId() + ", testator=" + refText(decision.testator()) + ", diedAt=") + || !row[1].contains(", property=Character[ref=" + refText(recipient) + "], guildLocks=") || !row[1].endsWith(", revision=" + revision + "]")) throw new Held("Historical animal receipt conflicts with its captured decision"); List original = scalar(connection, @@ -163,7 +167,7 @@ private static void validateReceipts(Connection connection, EstateDecision decis || !new java.util.HashSet<>(plannedIds).equals(new java.util.HashSet<>(movedIds))) throw new Held("Animal receipt IDs conflict with the immutable source archive"); if (revision == decision.revision()) { - if (!decision.toString().equals(row[1]) + if (!captured(decision).equals(row[1]) || !decision.property().character().equals(java.util.Optional.of(recipient))) throw new Held("Decision conflicts with stored animal receipt"); if (scalar(connection, "SELECT COUNT(*) FROM character_owners WHERE character_uuid = ?", @@ -178,6 +182,37 @@ SELECT COUNT(*) FROM character_owners o JOIN husbandry_estate_slots s ON s.anima throw new Held("Previously transferred animal reappeared at the testator; preserve receipts for review"); } + /** Fixed text of all twelve TLibs 2.6.0 fields; byte-identical to its Java 21 record text. */ + private static String captured(EstateDecision decision) { + return "EstateDecision[deathId=" + decision.deathId() + ", testator=" + refText(decision.testator()) + + ", diedAt=" + optionalText(decision.diedAt().map(Instant::toString)) + + ", decidedAt=" + decision.decidedAt() + + ", heir=" + optionalText(decision.heir().map(HusbandryEstate::refText)) + + ", deathGuild=" + optionalText(decision.deathGuild().map(UUID::toString)) + + ", deathRealm=" + optionalText(decision.deathRealm().map(UUID::toString)) + + ", money=" + partText(decision.money()) + ", property=" + partText(decision.property()) + + ", guildLocks=" + partText(decision.guildLocks()) + ", letters=" + partText(decision.letters()) + + ", revision=" + decision.revision() + "]"; + } + + private static String refText(CharacterRef ref) { + return "CharacterRef[ownerId=" + ref.ownerId() + ", characterId=" + ref.characterId() + "]"; + } + + private static String optionalText(Optional value) { + return value.map(text -> "Optional[" + text + "]").orElse("Optional.empty"); + } + + private static String partText(Recipient recipient) { + Recipient.Kind kind = recipient.kind(); + if (kind == Recipient.Kind.ACCOUNT) return "Account[accountId=" + recipient.account().orElseThrow() + "]"; + if (kind == Recipient.Kind.CHARACTER) return "Character[ref=" + refText(recipient.character().orElseThrow()) + "]"; + if (kind == Recipient.Kind.WAITING) return "Waiting[]"; + if (kind == Recipient.Kind.FROZEN) return "Frozen[]"; + if (kind == Recipient.Kind.SENDER) return "Sender[]"; + throw new Held("Unsupported estate decision part " + kind + "; Cooking must be updated"); + } + /** Independent raw rows retain unknown nullable/BLOB metadata even after the source slot is deleted. */ private static void archiveSlot(Connection connection, EstateDecision decision, String animal, UUID character, String position) throws SQLException { diff --git a/src/main/java/net/tfminecraft/cooking/husbandry/HusbandryLocator.java b/src/main/java/net/tfminecraft/cooking/husbandry/HusbandryLocator.java index 7c7d6f9..9cb7af2 100644 --- a/src/main/java/net/tfminecraft/cooking/husbandry/HusbandryLocator.java +++ b/src/main/java/net/tfminecraft/cooking/husbandry/HusbandryLocator.java @@ -96,6 +96,7 @@ private static void apply( int restoring = 0; int dropped = 0; int missing = 0; + int held = 0; List scannedWorlds = worlds.stream().map(HusbandryEntityScan.WorldDir::world).toList(); for (Map.Entry target : targets.entrySet()) { UUID uuid = target.getKey(); @@ -134,6 +135,10 @@ private static void apply( } try { String line = deleteGhost(repository, animal); + if (line == null) { + held++; + continue; + } Bukkit.getLogger().warning(line); dropped++; } catch (SqliteDatabaseException ex) { @@ -157,12 +162,13 @@ private static void apply( Bukkit.getLogger().severe("[Cooking] Failed to save husbandry animal location: " + ex.getMessage()); } } - if (located > 0 || restoring > 0 || dropped > 0 || missing > 0 || !result.complete()) { + if (located > 0 || restoring > 0 || dropped > 0 || missing > 0 || held > 0 || !result.complete()) { String failed = missing > 0 ? ", " + missing + " still marked missing after a failed drop" : ""; Bukkit.getLogger().info("[Cooking] Animal scan: updated " + located + " locations, restoring " + restoring + " lost animals from snapshots, dropped " + dropped + " ghost animals" + failed + + (held > 0 ? ", held " + held + " missing animal records for staff review" : "") + (result.complete() ? "." : " (some chunks could not be read, none dropped).")); } } @@ -181,13 +187,15 @@ static boolean isConfirmedGhost(boolean scanComplete, String storedWorld, Collec return scannedWorlds != null && scannedWorlds.contains(storedWorld); } - /** Deletes the animal and its owners. Returns the log line for the dropped record. */ + /** Deletes the animal and its owners, or returns null without mutation for an estate hold. */ static String deleteGhost(HusbandryRepository repository, HusbandryAnimal animal) { + if (repository.isEstateHeld(animal.uuid())) { + MISSING.add(animal.uuid()); + return null; + } List owners = repository.listOwners(animal.uuid()); + // Foreign-key cascades remove account owners, character owners and snapshots together. repository.deleteAnimal(animal.uuid()); - for (HusbandryOwner owner : owners) { - repository.deleteCharacterOwner(animal.uuid(), owner.characterUuid()); - } HusbandryEntities.evict(animal.uuid()); MISSING.remove(animal.uuid()); return ghostLog(animal, owners); diff --git a/src/main/java/net/tfminecraft/cooking/husbandry/HusbandryMigration.java b/src/main/java/net/tfminecraft/cooking/husbandry/HusbandryMigration.java index 4cbab4b..5370d55 100644 --- a/src/main/java/net/tfminecraft/cooking/husbandry/HusbandryMigration.java +++ b/src/main/java/net/tfminecraft/cooking/husbandry/HusbandryMigration.java @@ -5,20 +5,42 @@ /** Kept out of the old-API classloading path. Every pass uses the same archived plan. */ final class HusbandryMigration { + private int quietMinutes; + private String lastProblem; private HusbandryMigration() {} static void start(Plugin plugin, HusbandryRepository repository) { - plugin.getServer().getScheduler().runTaskTimer(plugin, () -> reconcile(plugin, repository), 1, 20L * 60 * 60); + HusbandryMigration migration = new HusbandryMigration(); + plugin.getServer().getScheduler().runTaskTimer(plugin, () -> { + if (migration.quietMinutes > 0) { + migration.quietMinutes--; + return; + } + if (migration.attempt(plugin, repository)) migration.quietMinutes = 59; + }, 1, 20L * 60); } static void reconcile(Plugin plugin, HusbandryRepository repository) { + new HusbandryMigration().attempt(plugin, repository); + } + private boolean attempt(Plugin plugin, HusbandryRepository repository) { try { if (!repository.migrateOwners(Characters::legacyHeirsReady, Characters::legacyHeir)) { - plugin.getLogger().warning("Animal ownership waits for reviewed heirs; legacy slots remain held."); - } else if (repository.heldOwners() != 0) { - plugin.getLogger().warning("Animal ownership holds " + repository.heldOwners() + warnChanged(plugin, "Animal ownership waits for reviewed heirs; legacy slots remain held."); + return false; + } + int held = repository.heldOwners(); + if (held != 0) { + plugin.getLogger().warning("Animal ownership holds " + held + " legacy slots for staff; see husbandry_owner_plan in Data/husbandry.db."); } + lastProblem = null; + return true; } catch (RuntimeException ex) { - plugin.getLogger().warning("Animal ownership migration rolled back; retrying: " + ex.getMessage()); + warnChanged(plugin, "Animal ownership migration rolled back; retrying: " + ex.getMessage()); + return false; } } + private void warnChanged(Plugin plugin, String message) { + if (!message.equals(lastProblem)) plugin.getLogger().warning(message); + lastProblem = message; + } } diff --git a/src/test/java/net/tfminecraft/cooking/husbandry/HusbandryDecisionEncodingTest.java b/src/test/java/net/tfminecraft/cooking/husbandry/HusbandryDecisionEncodingTest.java new file mode 100644 index 0000000..1ceb69b --- /dev/null +++ b/src/test/java/net/tfminecraft/cooking/husbandry/HusbandryDecisionEncodingTest.java @@ -0,0 +1,146 @@ +package net.tfminecraft.cooking.husbandry; + +import static org.junit.jupiter.api.Assertions.*; +import static org.mockito.Mockito.*; +import static net.tfminecraft.cooking.husbandry.HusbandryOwnershipStore.*; + +import java.nio.file.Path; +import java.sql.Connection; +import java.time.Instant; +import java.util.*; +import net.tfminecraft.tlibs.character.CharacterRef; +import net.tfminecraft.tlibs.character.estate.EstateDecision; +import net.tfminecraft.tlibs.character.estate.Outcome; +import net.tfminecraft.tlibs.character.estate.Recipient; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.io.TempDir; + +class HusbandryDecisionEncodingTest { + @TempDir Path directory; + final HusbandryEstateTest fixture = new HusbandryEstateTest(); + + @Test void fixedTwelveFieldRenderingMatchesAll15552PinnedFormatCombinations() throws Exception { + var captured = HusbandryEstate.class.getDeclaredMethod("captured", EstateDecision.class); + captured.setAccessible(true); + var times = List.of(Optional.empty(), Optional.of(Instant.EPOCH), + Optional.of(Instant.ofEpochMilli(1700000000123L)), Optional.of(Instant.ofEpochSecond(123, 456789123))); + var instants = List.of(Instant.EPOCH, Instant.ofEpochMilli(1700000000123L), Instant.ofEpochSecond(123, 456789123)); + List> heirs = List.of(Optional.empty(), Optional.of(fixture.heir)); + List> guilds = List.of(Optional.empty(), Optional.of(UUID.randomUUID())); + List> realms = List.of(Optional.empty(), Optional.of(UUID.randomUUID())); + var money = List.of(Recipient.account("char:" + fixture.heir.characterId() + ":bank"), Recipient.account("guild:" + UUID.randomUUID()), Recipient.FROZEN); + var holdings = List.of(Recipient.character(fixture.heir), Recipient.WAITING, Recipient.FROZEN); + int cases = 0; + for (var diedAt : times) for (var decidedAt : instants) for (var heir : heirs) + for (var guild : guilds) for (var realm : realms) for (var funds : money) + for (var property : holdings) for (var locks : holdings) + for (var letters : List.of(Recipient.character(fixture.heir), Recipient.SENDER)) + for (long revision : new long[]{0, 1, Long.MAX_VALUE}) { + var decision = new EstateDecision(fixture.death, fixture.testator, diedAt, decidedAt, + heir, guild, realm, + funds, property, locks, letters, revision); + assertEquals(decision.toString(), captured.invoke(null, decision)); + cases++; + } + assertEquals(15552, cases); + } + + @Test void runtimeRecordTextDriftAfterRestartKeepsFullReceiptAndCumulativeIdsUnchanged() throws Exception { + var original = fixture.decision(1, Recipient.character(fixture.heir)); + Path path = directory.resolve("drift.db"); + Map state; + Outcome expected; + try (var repository = new HusbandryOwnershipMigrationTest.Repository(path)) { + var repo = repository.value; var animal = fixture.own(repo, fixture.testator, "owner"); + var connection = repo.ownershipDatabase().getConnection(); + update(connection, "ALTER TABLE character_owners ADD COLUMN future BLOB"); + update(connection, "ALTER TABLE character_owners ADD COLUMN unknown TEXT"); + update(connection, "UPDATE character_owners SET future=?", new byte[]{0, 3, -1}); + expected = fixture.estate(repo).settle(original); + assertEquals(Outcome.Kind.DONE, expected.kind()); + assertEquals(List.of(animal.uuid().toString()), ((Outcome.Done)expected).movedIds()); + assertEquals(original.toString(), rows(connection, "SELECT decision FROM husbandry_estate_intents").getFirst()[0]); + state = state(connection); + } + try (var repository = new HusbandryOwnershipMigrationTest.Repository(path)) { + var repo = repository.value; var estate = fixture.estate(repo); + assertEquals(expected, estate.settle(drift(original, "FutureEstateDecision{same accessors}"))); + assertEquals(state, state(repo.ownershipDatabase().getConnection())); + assertEquals(expected, estate.settle(drift(fixture.decision(2, Recipient.WAITING), "Future waiting text"))); + assertEquals(state, state(repo.ownershipDatabase().getConnection())); + } + } + + @Test void everyNonPropertyFieldChangeAtSameRevisionBlocksEvenWhenRuntimeTextLies() throws Exception { + var original = fixture.decision(1, Recipient.character(fixture.heir)); + List changes = List.of( + decision(Optional.empty(), original.decidedAt(), original.heir(), original.deathGuild(), original.deathRealm(), original.money(), original.guildLocks(), original.letters()), + decision(original.diedAt(), Instant.ofEpochMilli(1), original.heir(), original.deathGuild(), original.deathRealm(), original.money(), original.guildLocks(), original.letters()), + decision(original.diedAt(), original.decidedAt(), Optional.empty(), original.deathGuild(), original.deathRealm(), original.money(), original.guildLocks(), original.letters()), + decision(original.diedAt(), original.decidedAt(), original.heir(), Optional.of(UUID.randomUUID()), original.deathRealm(), original.money(), original.guildLocks(), original.letters()), + decision(original.diedAt(), original.decidedAt(), original.heir(), original.deathGuild(), Optional.of(UUID.randomUUID()), original.money(), original.guildLocks(), original.letters()), + decision(original.diedAt(), original.decidedAt(), original.heir(), original.deathGuild(), original.deathRealm(), Recipient.account("char:" + fixture.heir.characterId() + ":bank"), original.guildLocks(), original.letters()), + decision(original.diedAt(), original.decidedAt(), original.heir(), original.deathGuild(), original.deathRealm(), original.money(), Recipient.WAITING, original.letters()), + decision(original.diedAt(), original.decidedAt(), original.heir(), original.deathGuild(), original.deathRealm(), original.money(), original.guildLocks(), Recipient.character(fixture.heir))); + try (var repository = new HusbandryOwnershipMigrationTest.Repository(directory.resolve("authority.db"))) { + var repo = repository.value; fixture.own(repo, fixture.testator, "owner"); var estate = fixture.estate(repo); + assertEquals(Outcome.Kind.DONE, estate.settle(original).kind()); + var before = state(repo.ownershipDatabase().getConnection()); + for (var changed : changes) { + assertEquals(Outcome.Kind.BLOCKED, estate.settle(changed).kind()); + assertEquals(before, state(repo.ownershipDatabase().getConnection())); + assertEquals(Outcome.Kind.BLOCKED, estate.settle(drift(changed, original.toString())).kind()); + assertEquals(before, state(repo.ownershipDatabase().getConnection())); + } + } + } + + @Test void unknownKindInAnyPartBlocksBeforeWritesIncludingWaitingAndSelfRecipients() throws Exception { + try (var repository = new HusbandryOwnershipMigrationTest.Repository(directory.resolve("unknown.db"))) { + var repo = repository.value; fixture.own(repo, fixture.testator, "owner"); var estate = fixture.estate(repo); + var before = state(repo.ownershipDatabase().getConnection()); + Recipient.Character unknown = mock(Recipient.Character.class); + when(unknown.kind()).thenReturn(null); + when(unknown.character()).thenReturn(Optional.of(fixture.heir)); + for (String field : List.of("money", "property", "guildLocks", "letters", "waiting-money", "self-letters")) { + var original = fixture.decision(1, field.equals("waiting-money") ? Recipient.WAITING + : Recipient.character(field.equals("self-letters") ? fixture.testator : fixture.heir)); + var invalid = drift(original, original.toString()); + switch (field) { + case "money", "waiting-money" -> doReturn(unknown).when(invalid).money(); + case "property" -> doReturn(unknown).when(invalid).property(); + case "guildLocks" -> doReturn(unknown).when(invalid).guildLocks(); + default -> doReturn(unknown).when(invalid).letters(); + } + assertEquals(Outcome.Kind.BLOCKED, estate.settle(invalid).kind(), field); + assertEquals(before, state(repo.ownershipDatabase().getConnection()), field); + } + } + } + + private EstateDecision decision(Optional diedAt, Instant decidedAt, + Optional heir, Optional guild, + Optional realm, Recipient money, Recipient locks, Recipient letters) { + return new EstateDecision(fixture.death, fixture.testator, diedAt, decidedAt, heir, guild, realm, + money, Recipient.character(fixture.heir), locks, letters, 1); + } + + private EstateDecision drift(EstateDecision original, String text) { + EstateDecision result = mock(EstateDecision.class, + withSettings().defaultAnswer(call -> call.getMethod().invoke(original, call.getArguments()))); + doReturn(text).when(result).toString(); + return result; + } + + private Map state(Connection connection) throws Exception { + Map result = new TreeMap<>(); + for (String table : List.of("character_owners", "husbandry_estate_intents", "husbandry_estate_slots", "husbandry_estate_owner_archive")) { + if (scalar(connection, "SELECT COUNT(*) FROM sqlite_master WHERE name=? AND type='table'", table) == 0) { + result.put(table, "absent"); continue; + } + var columns = rows(connection, "PRAGMA table_info(" + table + ")").stream().map(row -> "quote(\"" + row[1] + "\")").toList(); + result.put(table, Arrays.deepToString(rows(connection, "SELECT " + String.join(",", columns) + " FROM " + table + " ORDER BY rowid").toArray())); + } + return result; + } +} diff --git a/src/test/java/net/tfminecraft/cooking/husbandry/HusbandryMaintenanceRegressionTest.java b/src/test/java/net/tfminecraft/cooking/husbandry/HusbandryMaintenanceRegressionTest.java new file mode 100644 index 0000000..0eb29de --- /dev/null +++ b/src/test/java/net/tfminecraft/cooking/husbandry/HusbandryMaintenanceRegressionTest.java @@ -0,0 +1,149 @@ +package net.tfminecraft.cooking.husbandry; + +import static org.junit.jupiter.api.Assertions.*; +import static org.mockito.ArgumentMatchers.*; +import static org.mockito.Mockito.*; +import static net.tfminecraft.cooking.husbandry.HusbandryOwnershipStore.*; + +import java.nio.file.Path; +import java.sql.Connection; +import java.util.*; +import java.util.concurrent.atomic.AtomicBoolean; +import java.util.logging.Handler; +import java.util.logging.LogRecord; +import java.util.logging.Logger; +import net.tfminecraft.tlibs.character.CharacterRef; +import net.tfminecraft.tlibs.character.Characters; +import org.bukkit.Bukkit; +import org.bukkit.Server; +import org.bukkit.plugin.Plugin; +import org.bukkit.scheduler.BukkitScheduler; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.io.TempDir; +import org.mockito.MockedStatic; + +class HusbandryMaintenanceRegressionTest { + @TempDir Path directory; + + @Test void heldGhostKeepsEverySqliteRowAndScanContinuesWithAccurateCounters() throws Exception { + try (var farm = new HusbandryRuntimeCoverageTest.Farm(directory)) { + var repo = farm.repository; + HusbandryAnimal held = farm.ownedOffline("Held"), normal = farm.offline("COW", "Normal"); + farm.own(normal, farm.neighbour); + var connection = repo.ownershipDatabase().getConnection(); + UUID death = UUID.randomUUID(), recipient = UUID.randomUUID(); + update(connection, "INSERT INTO owners VALUES (?, ?, 'owner')", held.uuid().toString(), farm.player.getUniqueId().toString()); + update(connection, "ALTER TABLE character_owners ADD COLUMN future BLOB"); + update(connection, "UPDATE character_owners SET future=? WHERE animal_uuid=?", new byte[]{0, 7, -1}, held.uuid().toString()); + repo.upsertSnapshots(Map.of(held.uuid(), new byte[]{0, 1, -1}, normal.uuid(), new byte[]{8, 9}), 73); + update(connection, "INSERT INTO husbandry_estate_intents VALUES (?, 1, 'captured opaque decision', ?, ?, ?, ?, 'prepared')", + death.toString(), farm.player.getUniqueId().toString(), farm.player.getUniqueId().toString(), recipient.toString(), recipient.toString()); + update(connection, "INSERT INTO husbandry_estate_slots VALUES (?, 1, ?, 'owner', NULL, 'owner')", death.toString(), held.uuid().toString()); + var before = heldState(connection, held.uuid(), death); + assertTrue(repo.isEstateHeld(held.uuid())); + assertNull(HusbandryLocator.deleteGhost(repo, held)); + assertEquals(before, heldState(connection, held.uuid(), death)); + assertTrue(HusbandryLocator.isMissing(held.uuid())); + assertEquals(1, repo.countForPlayer(farm.player.getUniqueId())); + assertEquals(1, repo.countForPlayer(farm.neighbour.getUniqueId())); + + List logs = new ArrayList<>(); + Handler handler = new Handler() { + @Override public void publish(LogRecord record) { logs.add(record.getMessage()); } + @Override public void flush() {} + @Override public void close() {} + }; + Logger logger = Bukkit.getLogger(); logger.addHandler(handler); + try (MockedStatic snapshots = mockStatic(HusbandrySnapshots.class); + var scan = new HusbandryRuntimeCoverageTest.Scan(new HusbandryEntityScan.Result(Map.of(), Set.of(), true))) { + snapshots.when(() -> HusbandrySnapshots.restore(any(), any(), anyList())).thenReturn(false); + HusbandryLocator.scanUnloaded(); scan.background(); assertDoesNotThrow(scan::apply); + } finally { logger.removeHandler(handler); } + assertEquals(before, heldState(connection, held.uuid(), death)); + assertArrayEquals(new byte[]{0, 1, -1}, repo.getSnapshot(held.uuid()).orElseThrow().data()); + assertTrue(repo.exists(held.uuid())); + assertEquals(1, repo.countForPlayer(farm.player.getUniqueId())); + assertEquals(0, repo.countForPlayer(farm.neighbour.getUniqueId())); + assertFalse(repo.exists(normal.uuid())); + assertTrue(repo.listOwners(normal.uuid()).isEmpty()); + assertTrue(repo.getSnapshot(normal.uuid()).isEmpty()); + assertTrue(logs.stream().anyMatch(line -> line.contains("dropped 1 ghost animals") && line.contains("held 1 missing animal records"))); + assertFalse(logs.stream().anyMatch(line -> line.startsWith("[Cooking] Dropped ghost animal") && line.contains(held.uuid().toString()))); + } + } + + @Test void pendingReadinessPublishesOnNextMinuteThenReportsHeldPlansOnlyHourlyAndSurvivesRestart() throws Exception { + Plugin plugin = mock(Plugin.class); + Server server = mock(Server.class); BukkitScheduler scheduler = mock(BukkitScheduler.class); + Logger logger = mock(Logger.class); when(plugin.getServer()).thenReturn(server); + when(server.getScheduler()).thenReturn(scheduler); when(plugin.getLogger()).thenReturn(logger); + List tasks = new ArrayList<>(); + when(scheduler.runTaskTimer(eq(plugin), any(Runnable.class), eq(1L), eq(20L * 60))).thenAnswer(call -> { tasks.add(call.getArgument(1)); return null; }); + UUID account = UUID.randomUUID(), unknown = UUID.randomUUID(), character = UUID.randomUUID(); + var heir = new CharacterRef(account, character); + AtomicBoolean ready = new AtomicBoolean(false); + Path database = directory.resolve("retry.db"); + try (MockedStatic characters = mockStatic(Characters.class)) { + characters.when(Characters::legacyHeirsReady).thenAnswer(call -> ready.get()); + characters.when(() -> Characters.legacyHeir(any())).thenAnswer(call -> account.equals(call.getArgument(0)) ? Optional.of(heir) : Optional.empty()); + try (var fixture = new HusbandryOwnershipMigrationTest.Repository(database)) { + var repo = spy(fixture.value); var connection = repo.ownershipDatabase().getConnection(); + var animal = new HusbandryAnimal(UUID.randomUUID(), "COW", "Late heir"); repo.upsertAnimal(animal); + update(connection, "INSERT INTO owners VALUES (?, ?, 'owner')", animal.uuid().toString(), account.toString()); + update(connection, "INSERT INTO owners VALUES (?, ?, 'coowner')", animal.uuid().toString(), unknown.toString()); + HusbandryMigration.start(plugin, repo); Runnable task = tasks.getFirst(); + task.run(); task.run(); task.run(); + assertTrue(repo.listOwners(animal.uuid()).isEmpty()); assertEquals(1, repo.countForPlayer(account)); + characters.verify(() -> Characters.legacyHeir(any()), never()); + verify(logger, times(1)).warning(contains("waits for reviewed heirs")); + ready.set(true); task.run(); + assertEquals(heir, repo.listOwners(animal.uuid()).getFirst().ref()); assertEquals(1, repo.heldOwners()); + assertEquals(1, repo.countForPlayer(unknown)); + var original = Arrays.deepToString(rows(connection, "SELECT * FROM husbandry_owner_archive ORDER BY player_uuid").toArray()); + clearInvocations(repo, logger); characters.clearInvocations(); + for (int minute = 0; minute < 59; minute++) task.run(); + verify(repo, never()).migrateOwners(any(), any()); verify(repo, never()).heldOwners(); + characters.verifyNoInteractions(); verifyNoInteractions(logger); + task.run(); + verify(repo, times(1)).migrateOwners(any(), any()); verify(repo, times(1)).heldOwners(); + verify(logger, times(1)).warning(contains("holds 1 legacy slots")); + assertEquals(original, Arrays.deepToString(rows(connection, "SELECT * FROM husbandry_owner_archive ORDER BY player_uuid").toArray())); + } + ready.set(false); characters.clearInvocations(); clearInvocations(logger); + try (var fixture = new HusbandryOwnershipMigrationTest.Repository(database)) { + var repo = spy(fixture.value); HusbandryMigration.start(plugin, repo); tasks.getLast().run(); + assertEquals(1, repo.heldOwners()); + characters.verifyNoInteractions(); + verify(logger, times(1)).warning(contains("holds 1 legacy slots")); + assertEquals(2, scalar(repo.ownershipDatabase().getConnection(), "SELECT COUNT(*) FROM husbandry_owner_archive")); + assertEquals(1, scalar(repo.ownershipDatabase().getConnection(), "SELECT COUNT(*) FROM character_owners")); + } + } + } + + @Test void pendingFailuresRetryWithoutRepeatingIdenticalWarnings() { + Plugin plugin = mock(Plugin.class); Server server = mock(Server.class); + BukkitScheduler scheduler = mock(BukkitScheduler.class); Logger logger = mock(Logger.class); + when(plugin.getServer()).thenReturn(server); when(server.getScheduler()).thenReturn(scheduler); when(plugin.getLogger()).thenReturn(logger); + List tasks = new ArrayList<>(); + when(scheduler.runTaskTimer(eq(plugin), any(Runnable.class), eq(1L), eq(20L * 60))).thenAnswer(call -> { tasks.add(call.getArgument(1)); return null; }); + HusbandryRepository repo = mock(HusbandryRepository.class); + when(repo.migrateOwners(any(), any())).thenThrow(new IllegalStateException("disk unavailable"), new IllegalStateException("disk unavailable")).thenReturn(true); + HusbandryMigration.start(plugin, repo); Runnable task = tasks.getFirst(); + task.run(); task.run(); task.run(); + verify(repo, times(3)).migrateOwners(any(), any()); + verify(logger, times(1)).warning(contains("disk unavailable")); + verify(repo).heldOwners(); + } + + private Map heldState(Connection connection, UUID animal, UUID death) throws Exception { + Map state = new TreeMap<>(); + for (String table : List.of("animals", "owners", "character_owners", "snapshots", "husbandry_estate_intents", "husbandry_estate_slots")) { + String key = table.equals("animals") ? "uuid" : table.equals("husbandry_estate_intents") ? "death_id" : "animal_uuid"; + var columns = rows(connection, "PRAGMA table_info(" + table + ")").stream().map(row -> "quote(\"" + row[1] + "\")").toList(); + state.put(table, Arrays.deepToString(rows(connection, "SELECT " + String.join(",", columns) + " FROM " + table + " WHERE " + key + "=? ORDER BY rowid", + (table.equals("husbandry_estate_intents") ? death : animal).toString()).toArray())); + } + return state; + } +} diff --git a/tools/husbandry-ownership-rollback.py b/tools/husbandry-ownership-rollback.py index 2728cbe..b22e66c 100644 --- a/tools/husbandry-ownership-rollback.py +++ b/tools/husbandry-ownership-rollback.py @@ -2,6 +2,7 @@ """Preview an offline ownership rollback; --apply preserves a consistent current SQLite backup first.""" import argparse import json +import os from pathlib import Path import sqlite3 import time @@ -35,8 +36,8 @@ def rollback(path, apply=False, backup=None): return result target = Path(backup) if backup else path.with_name(path.name + '.before-owner-rollback-' + str(time.time_ns())) # Never overwrite the only recovery image. SQLite backup includes committed WAL pages. - with target.open('xb'): - pass + descriptor = os.open(target, os.O_CREAT | os.O_EXCL | os.O_WRONLY, 0o600) + os.close(descriptor) with sqlite3.connect(target) as copy: database.backup(copy) if copy.execute('PRAGMA integrity_check').fetchone() != ('ok',): diff --git a/tools/test_husbandry_ownership_rollback.py b/tools/test_husbandry_ownership_rollback.py index aad23db..4f76686 100644 --- a/tools/test_husbandry_ownership_rollback.py +++ b/tools/test_husbandry_ownership_rollback.py @@ -1,4 +1,5 @@ import importlib.util +import os from pathlib import Path import sqlite3 import tempfile @@ -67,6 +68,31 @@ def test_missing_archive_and_changed_legacy_schema_are_refused(self): with self.assertRaisesRegex(ValueError, 'No immutable'): rollback.rollback(self.path) + def test_backup_stays_private_with_permissive_umask_and_never_changes_existing_modes(self): + self.path.chmod(0o600) + self.database.execute('UPDATE animals SET care=93') + self.database.commit() # Committed WAL content must reach the restricted recovery image. + backup = self.path.with_name('private-recovery.db') + previous = os.umask(0) + try: + result = rollback.rollback(self.path, True, backup) + finally: + os.umask(previous) + self.assertTrue(result['applied']) + self.assertEqual(0o600, backup.stat().st_mode & 0o777) + self.assertEqual(0o600, self.path.stat().st_mode & 0o777) + with sqlite3.connect(backup) as recovered: + self.assertEqual((93, b'\x00\x01\xff'), recovered.execute('SELECT care,snapshot FROM animals').fetchone()) + self.assertEqual(('ok',), recovered.execute('PRAGMA integrity_check').fetchone()) + existing = self.path.with_name('existing.db') + existing.write_bytes(b'original recovery image') + existing.chmod(0o640) + with self.assertRaises(FileExistsError): + rollback.rollback(self.path, True, existing) + self.assertEqual(b'original recovery image', existing.read_bytes()) + self.assertEqual(0o640, existing.stat().st_mode & 0o777) + self.assertEqual(0o600, self.path.stat().st_mode & 0o777) + if __name__ == '__main__': unittest.main()