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..dd0b897
--- /dev/null
+++ b/src/main/java/net/tfminecraft/cooking/husbandry/HusbandryEstate.java
@@ -0,0 +1,268 @@
+package net.tfminecraft.cooking.husbandry;
+
+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;
+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.character.estate.Recipient;
+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 {
+ 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);
+ 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");
+ }
+ 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());
+ update(connection, "INSERT INTO husbandry_estate_intents VALUES (?, ?, ?, ?, ?, ?, ?, 'prepared')",
+ decision.deathId().toString(), decision.revision(), capturedDecision,
+ 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");
+ }
+ 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();
+ return moved.isEmpty() ? Outcome.done() : Outcome.done(Map.of("animals", moved.size()), moved);
+ }
+ }
+
+ /** 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=" + 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,
+ "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 (!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 = ?",
+ 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");
+ }
+
+ /** 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 {
+ 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..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.deleteOwner(animal.uuid(), owner.playerUuid());
- }
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
new file mode 100644
index 0000000..5370d55
--- /dev/null
+++ b/src/main/java/net/tfminecraft/cooking/husbandry/HusbandryMigration.java
@@ -0,0 +1,46 @@
+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 int quietMinutes;
+ private String lastProblem;
+ private HusbandryMigration() {}
+ static void start(Plugin plugin, HusbandryRepository repository) {
+ 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)) {
+ 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) {
+ 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/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/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/HusbandryEstateTest.java b/src/test/java/net/tfminecraft/cooking/husbandry/HusbandryEstateTest.java
new file mode 100644
index 0000000..1d1756c
--- /dev/null
+++ b/src/test/java/net/tfminecraft/cooking/husbandry/HusbandryEstateTest.java
@@ -0,0 +1,267 @@
+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 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 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");
+ 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/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/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..b22e66c
--- /dev/null
+++ b/tools/husbandry-ownership-rollback.py
@@ -0,0 +1,65 @@
+#!/usr/bin/env python3
+"""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
+
+
+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.
+ 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',):
+ 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..4f76686
--- /dev/null
+++ b/tools/test_husbandry_ownership_rollback.py
@@ -0,0 +1,98 @@
+import importlib.util
+import os
+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)
+
+ 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()