diff --git a/src/main/java/net/tfminecraft/cooking/Cooking.java b/src/main/java/net/tfminecraft/cooking/Cooking.java index d82aef4..e8ab6cc 100644 --- a/src/main/java/net/tfminecraft/cooking/Cooking.java +++ b/src/main/java/net/tfminecraft/cooking/Cooking.java @@ -18,6 +18,7 @@ import net.tfminecraft.cooking.farming.FarmHarvestListener; import net.tfminecraft.cooking.farming.FarmTrampleListener; import net.tfminecraft.cooking.farming.FarmingLoader; +import net.tfminecraft.cooking.husbandry.HusbandryAnimalsCommand; import net.tfminecraft.cooking.husbandry.HusbandryBreedListener; import net.tfminecraft.cooking.husbandry.HusbandryCareListener; import net.tfminecraft.cooking.husbandry.HusbandryDamageListener; @@ -154,6 +155,9 @@ public void onEnable() { getCommand("cooking").setExecutor(commands); getCommand("cooking").setTabCompleter(commands); + HusbandryAnimalsCommand animals = new HusbandryAnimalsCommand(); + getCommand("animals").setExecutor(animals); + getCommand("animals").setTabCompleter(animals); if (ItemScanService.get() != null) { ItemScanService.get().subscribe(tagManager); ItemScanService.get().subscribe(legacyFishScan); diff --git a/src/main/java/net/tfminecraft/cooking/husbandry/HusbandryAnimal.java b/src/main/java/net/tfminecraft/cooking/husbandry/HusbandryAnimal.java index 1b9d55f..bbcf9e3 100644 --- a/src/main/java/net/tfminecraft/cooking/husbandry/HusbandryAnimal.java +++ b/src/main/java/net/tfminecraft/cooking/husbandry/HusbandryAnimal.java @@ -26,6 +26,10 @@ public final class HusbandryAnimal { private long careUpRemainderSeconds; private long careDownRemainderSeconds; private String statsRevision; + private String world; + private Integer x; + private Integer y; + private Integer z; public HusbandryAnimal(UUID uuid, String type, String name) { this.uuid = uuid; @@ -215,6 +219,52 @@ public void setStatsRevision(String statsRevision) { this.statsRevision = statsRevision.trim(); } + public String world() { + return world; + } + + public Integer x() { + return x; + } + + public Integer y() { + return y; + } + + public Integer z() { + return z; + } + + public boolean hasLocation() { + return world != null && !world.isBlank() && x != null && y != null && z != null; + } + + public void setLastLocation(String world, int x, int y, int z) { + if (world == null || world.isBlank()) { + clearLocation(); + return; + } + this.world = world.trim(); + this.x = x; + this.y = y; + this.z = z; + } + + public void setStoredLocation(String world, Integer x, Integer y, Integer z) { + if (world == null || world.isBlank() || x == null || y == null || z == null) { + clearLocation(); + return; + } + setLastLocation(world, x, y, z); + } + + public void clearLocation() { + this.world = null; + this.x = null; + this.y = null; + this.z = null; + } + private static String sanitizeName(String name) { if (name == null || name.isBlank() || "???".equals(name.trim())) { return ""; diff --git a/src/main/java/net/tfminecraft/cooking/husbandry/HusbandryAnimalsCommand.java b/src/main/java/net/tfminecraft/cooking/husbandry/HusbandryAnimalsCommand.java new file mode 100644 index 0000000..1a663d3 --- /dev/null +++ b/src/main/java/net/tfminecraft/cooking/husbandry/HusbandryAnimalsCommand.java @@ -0,0 +1,88 @@ +package net.tfminecraft.cooking.husbandry; + +import java.util.ArrayList; +import java.util.Collections; +import java.util.List; +import java.util.Locale; +import java.util.UUID; + +import org.bukkit.Bukkit; +import org.bukkit.OfflinePlayer; +import org.bukkit.command.Command; +import org.bukkit.command.CommandExecutor; +import org.bukkit.command.CommandSender; +import org.bukkit.command.TabCompleter; +import org.bukkit.entity.Entity; +import org.bukkit.entity.Player; + +import net.kyori.adventure.text.Component; +import net.kyori.adventure.text.format.NamedTextColor; +import net.tfminecraft.cooking.Cooking; + +public final class HusbandryAnimalsCommand implements CommandExecutor, TabCompleter { + + @Override + public boolean onCommand(CommandSender sender, Command command, String label, String[] args) { + HusbandryRepository repository = Cooking.plugin == null ? null : Cooking.plugin.getHusbandryRepository(); + if (repository == null) { + sender.sendMessage(Component.text("Animal records are not available right now.", NamedTextColor.RED)); + return true; + } + if (args.length > 0 && !sender.hasPermission("cooking.admin")) { + sender.sendMessage(Component.text("You can only list your own animals.", NamedTextColor.RED)); + return true; + } + boolean self = args.length == 0; + UUID targetId; + String ownerName; + if (self) { + if (!(sender instanceof Player player)) { + sender.sendMessage(Component.text("Usage: /" + label + " ", NamedTextColor.RED)); + return true; + } + targetId = player.getUniqueId(); + ownerName = player.getName(); + } else { + OfflinePlayer offline = Bukkit.getOfflinePlayerIfCached(args[0]); + if (offline == null) { + sender.sendMessage(Component.text("No player by that name has joined.", NamedTextColor.RED)); + return true; + } + targetId = offline.getUniqueId(); + ownerName = offline.getName() == null ? args[0] : offline.getName(); + } + List rows = new ArrayList<>(); + for (HusbandryOwned owned : repository.listForPlayer(targetId)) { + if (owned.animal() == null) { + continue; + } + HusbandryAnimal animal = HusbandryEntities.getLoaded(owned.animal().uuid()).orElse(owned.animal()); + Entity live = Bukkit.getEntity(animal.uuid()); + if (live != null) { + HusbandryLocation.remember(animal, live); + } + rows.add(new HusbandryOwned(animal, owned.role())); + } + for (Component line : HusbandryRoster.render( + ownerName, self, rows, HusbandryConfig.maxAnimals(), System.currentTimeMillis())) { + sender.sendMessage(line); + } + return true; + } + + @Override + public List onTabComplete(CommandSender sender, Command command, String alias, String[] args) { + if (!sender.hasPermission("cooking.admin") || args.length != 1) { + return Collections.emptyList(); + } + String prefix = args[0].toLowerCase(Locale.ROOT); + List names = new ArrayList<>(); + for (Player player : Bukkit.getOnlinePlayers()) { + if (player.getName().toLowerCase(Locale.ROOT).startsWith(prefix)) { + names.add(player.getName()); + } + } + names.sort(String.CASE_INSENSITIVE_ORDER); + return names; + } +} diff --git a/src/main/java/net/tfminecraft/cooking/husbandry/HusbandryLifecycleListener.java b/src/main/java/net/tfminecraft/cooking/husbandry/HusbandryLifecycleListener.java index c44dbc7..254e755 100644 --- a/src/main/java/net/tfminecraft/cooking/husbandry/HusbandryLifecycleListener.java +++ b/src/main/java/net/tfminecraft/cooking/husbandry/HusbandryLifecycleListener.java @@ -101,6 +101,10 @@ public static void flushLoadedForDisable() { long now = System.currentTimeMillis(); List toSave = new ArrayList<>(snapshot.size()); for (HusbandryAnimal animal : snapshot) { + Entity entity = Bukkit.getEntity(animal.uuid()); + if (entity != null) { + HusbandryLocation.remember(animal, entity); + } animal.setUnloadedAt(now); toSave.add(animal); } @@ -147,6 +151,7 @@ static void handleLoad(Entity entity) { } HusbandryAnimal animal = stored.get(); long now = System.currentTimeMillis(); + HusbandryLocation.remember(animal, living); HusbandrySimulator.catchUp(animal, now, java.util.concurrent.ThreadLocalRandom.current()); HusbandryGrowth.applyMaturity(living, animal, now); HusbandryMounts.applyStats(living, animal); @@ -172,6 +177,7 @@ static void handleUnload(Entity entity) { return; } HusbandryAnimal animal = stored.get(); + HusbandryLocation.remember(animal, entity); animal.setUnloadedAt(System.currentTimeMillis()); repository.upsertAnimal(animal); } diff --git a/src/main/java/net/tfminecraft/cooking/husbandry/HusbandryLocation.java b/src/main/java/net/tfminecraft/cooking/husbandry/HusbandryLocation.java new file mode 100644 index 0000000..495ba1e --- /dev/null +++ b/src/main/java/net/tfminecraft/cooking/husbandry/HusbandryLocation.java @@ -0,0 +1,22 @@ +package net.tfminecraft.cooking.husbandry; + +import org.bukkit.Location; +import org.bukkit.World; +import org.bukkit.entity.Entity; + +public final class HusbandryLocation { + + private HusbandryLocation() {} + + public static void remember(HusbandryAnimal animal, Entity entity) { + if (animal == null || entity == null) { + return; + } + World world = entity.getWorld(); + if (world == null) { + return; + } + Location location = entity.getLocation(); + animal.setLastLocation(world.getName(), location.getBlockX(), location.getBlockY(), location.getBlockZ()); + } +} diff --git a/src/main/java/net/tfminecraft/cooking/husbandry/HusbandryOwned.java b/src/main/java/net/tfminecraft/cooking/husbandry/HusbandryOwned.java new file mode 100644 index 0000000..b824edf --- /dev/null +++ b/src/main/java/net/tfminecraft/cooking/husbandry/HusbandryOwned.java @@ -0,0 +1,14 @@ +package net.tfminecraft.cooking.husbandry; + +import java.util.Locale; + +public record HusbandryOwned(HusbandryAnimal animal, String role) { + + public HusbandryOwned { + if (role == null || role.isBlank()) { + role = "owner"; + } else { + role = role.toLowerCase(Locale.ROOT); + } + } +} diff --git a/src/main/java/net/tfminecraft/cooking/husbandry/HusbandryRepository.java b/src/main/java/net/tfminecraft/cooking/husbandry/HusbandryRepository.java index 151ecf6..ad26f97 100644 --- a/src/main/java/net/tfminecraft/cooking/husbandry/HusbandryRepository.java +++ b/src/main/java/net/tfminecraft/cooking/husbandry/HusbandryRepository.java @@ -67,8 +67,9 @@ INSERT INTO animals ( hungry_since, dirty_since, last_processed_at, unloaded_at, affliction_elapsed, affliction_at, last_milk_at, wool_ready_at, neutered, loaded_visit_start, mature_at, shed_ready_at, egg_ready_at, - care_up_remainder, care_down_remainder, stats_revision - ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) + care_up_remainder, care_down_remainder, stats_revision, + world, x, y, z + ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) ON CONFLICT(uuid) DO UPDATE SET type = excluded.type, name = excluded.name, @@ -90,7 +91,11 @@ ON CONFLICT(uuid) DO UPDATE SET egg_ready_at = excluded.egg_ready_at, care_up_remainder = excluded.care_up_remainder, care_down_remainder = excluded.care_down_remainder, - stats_revision = excluded.stats_revision + stats_revision = excluded.stats_revision, + world = excluded.world, + x = excluded.x, + y = excluded.y, + z = excluded.z """; private static final String SELECT_ANIMAL = "SELECT * FROM animals WHERE uuid = ?"; @@ -180,6 +185,21 @@ private void migrateSchema() { } database.execute("PRAGMA user_version = 7"); } + if (version < 8) { + if (!hasColumn("animals", "world")) { + database.execute("ALTER TABLE animals ADD COLUMN world TEXT"); + } + if (!hasColumn("animals", "x")) { + database.execute("ALTER TABLE animals ADD COLUMN x INTEGER"); + } + if (!hasColumn("animals", "y")) { + database.execute("ALTER TABLE animals ADD COLUMN y INTEGER"); + } + if (!hasColumn("animals", "z")) { + database.execute("ALTER TABLE animals ADD COLUMN z INTEGER"); + } + database.execute("PRAGMA user_version = 8"); + } } private boolean hasColumn(String table, String column) { @@ -310,6 +330,21 @@ public List listOwners(UUID animalUuid) { animalUuid.toString()); } + public List listForPlayer(UUID playerUuid) { + if (playerUuid == null) { + return List.of(); + } + return queryList( + """ + SELECT a.*, o.role AS owner_role + FROM animals a + INNER JOIN owners o ON o.animal_uuid = a.uuid + WHERE o.player_uuid = ? + """, + result -> new HusbandryOwned(mapAnimal(result), result.getString("owner_role")), + playerUuid.toString()); + } + public void checkpointWal(boolean truncate) { String mode = truncate ? "TRUNCATE" : "PASSIVE"; database.execute("PRAGMA wal_checkpoint(" + mode + ")"); @@ -355,7 +390,11 @@ private static void bindAnimal(PreparedStatement statement, HusbandryAnimal anim animal.eggReadyAt(), animal.careUpRemainderSeconds(), animal.careDownRemainderSeconds(), - blankToNull(animal.statsRevision())); + blankToNull(animal.statsRevision()), + animal.hasLocation() ? animal.world() : null, + animal.hasLocation() ? animal.x() : null, + animal.hasLocation() ? animal.y() : null, + animal.hasLocation() ? animal.z() : null); } private static HusbandryAnimal mapAnimal(ResultSet result) throws SQLException { @@ -382,6 +421,11 @@ private static HusbandryAnimal mapAnimal(ResultSet result) throws SQLException { animal.setCareUpRemainderSeconds(intOrZero(result, "care_up_remainder")); animal.setCareDownRemainderSeconds(intOrZero(result, "care_down_remainder")); animal.setStatsRevision(nullableString(result, "stats_revision")); + animal.setStoredLocation( + nullableString(result, "world"), + nullableInt(result, "x"), + nullableInt(result, "y"), + nullableInt(result, "z")); return animal; } @@ -409,6 +453,15 @@ private static String nullableString(ResultSet result, String column) throws SQL } } + private static Integer nullableInt(ResultSet result, String column) throws SQLException { + try { + int value = result.getInt(column); + return result.wasNull() ? null : value; + } catch (SQLException ex) { + return null; + } + } + private static int intOrZero(ResultSet result, String column) throws SQLException { try { int value = result.getInt(column); diff --git a/src/main/java/net/tfminecraft/cooking/husbandry/HusbandryRoster.java b/src/main/java/net/tfminecraft/cooking/husbandry/HusbandryRoster.java new file mode 100644 index 0000000..38afe29 --- /dev/null +++ b/src/main/java/net/tfminecraft/cooking/husbandry/HusbandryRoster.java @@ -0,0 +1,140 @@ +package net.tfminecraft.cooking.husbandry; + +import java.util.ArrayList; +import java.util.Comparator; +import java.util.List; +import java.util.Locale; + +import net.kyori.adventure.text.Component; +import net.kyori.adventure.text.event.ClickEvent; +import net.kyori.adventure.text.event.HoverEvent; +import net.kyori.adventure.text.format.NamedTextColor; +import net.kyori.adventure.text.format.TextDecoration; + +public final class HusbandryRoster { + + private HusbandryRoster() {} + + public static List render( + String ownerName, + boolean self, + List owned, + int maxAnimals, + long nowMillis) { + List rows = owned == null ? List.of() : owned; + int shown = rows.size(); + int cap = Math.max(1, maxAnimals); + String shownName = plain(ownerName); + String title = self || shownName.isEmpty() ? "Your animals" : shownName + "'s animals"; + List lines = new ArrayList<>(); + lines.add(Component.text(title + " ", NamedTextColor.GOLD) + .append(Component.text("(" + shown + "/" + cap + ")", NamedTextColor.GRAY))); + if (rows.isEmpty()) { + if (self) { + lines.add(Component.text("You do not own any animals yet.", NamedTextColor.GRAY)); + lines.add(Component.text("Claim one with a named Ownership Token.", NamedTextColor.GRAY)); + } else { + String who = shownName.isEmpty() ? "That player" : shownName; + lines.add(Component.text(who + " does not own any animals.", NamedTextColor.GRAY)); + } + return lines; + } + List sorted = new ArrayList<>(rows); + sorted.sort(Comparator + .comparing((HusbandryOwned row) -> sortLabel(row).toLowerCase(Locale.ROOT)) + .thenComparing(row -> speciesLabel(row.animal().type()).toLowerCase(Locale.ROOT)) + .thenComparing(row -> row.animal().uuid() == null ? "" : row.animal().uuid().toString())); + for (HusbandryOwned row : sorted) { + lines.add(line(row, nowMillis)); + } + return lines; + } + + static String speciesLabel(String type) { + if (type == null || type.isBlank()) { + return "Animal"; + } + String raw = type.toLowerCase(Locale.ROOT).replace('_', ' '); + StringBuilder out = new StringBuilder(); + for (String word : raw.split(" ")) { + if (word.isEmpty()) { + continue; + } + if (!out.isEmpty()) { + out.append(' '); + } + out.append(Character.toUpperCase(word.charAt(0))); + if (word.length() > 1) { + out.append(word.substring(1)); + } + } + return out.isEmpty() ? "Animal" : out.toString(); + } + + private static String sortLabel(HusbandryOwned owned) { + String name = plain(owned.animal().name()); + if (!name.isEmpty()) { + return name; + } + return speciesLabel(owned.animal().type()); + } + + private static Component line(HusbandryOwned owned, long nowMillis) { + HusbandryAnimal animal = owned.animal(); + String species = speciesLabel(animal.type()); + String name = plain(animal.name()); + boolean named = !name.isEmpty() && !name.equalsIgnoreCase(species); + Component row = Component.text("• ", NamedTextColor.GOLD) + .append(Component.text(named ? name : species, NamedTextColor.WHITE)); + if (named) { + row = row.append(sep()).append(Component.text(species, NamedTextColor.GRAY)); + } + if ("coowner".equals(owned.role())) { + row = row.append(sep()).append(Component.text("Co-owner", NamedTextColor.GRAY)); + } + if (!HusbandryGrowth.isMature(animal, nowMillis)) { + row = row.append(sep()).append(Component.text("Growing", NamedTextColor.GRAY)); + } + return row.append(sep()).append(status(animal)).append(sep()).append(place(animal)); + } + + private static Component status(HusbandryAnimal animal) { + boolean hungry = animal.hungrySince() != null; + boolean dirty = animal.dirtySince() != null; + if (hungry && dirty) { + return Component.text("Hungry, Dirty", NamedTextColor.RED); + } + if (hungry) { + return Component.text("Hungry", NamedTextColor.GOLD); + } + if (dirty) { + return Component.text("Dirty", NamedTextColor.YELLOW); + } + return Component.text("Happy", NamedTextColor.GREEN); + } + + private static Component place(HusbandryAnimal animal) { + if (!animal.hasLocation()) { + return Component.text("location not recorded yet", NamedTextColor.GRAY) + .decorate(TextDecoration.ITALIC); + } + String coords = animal.x() + ", " + animal.y() + ", " + animal.z(); + String copy = animal.world() + " " + coords; + return Component.text(animal.world(), NamedTextColor.GRAY) + .append(Component.space()) + .append(Component.text(coords, NamedTextColor.WHITE) + .clickEvent(ClickEvent.copyToClipboard(copy)) + .hoverEvent(HoverEvent.showText(Component.text("Click to copy coordinates")))); + } + + private static Component sep() { + return Component.text(" · ", NamedTextColor.DARK_GRAY); + } + + static String plain(String value) { + if (value == null || value.isBlank()) { + return ""; + } + return value.replaceAll("(?i)§[0-9a-fk-or]", "").replace('\n', ' ').replace('\r', ' ').trim(); + } +} diff --git a/src/main/java/net/tfminecraft/cooking/husbandry/HusbandryTickTask.java b/src/main/java/net/tfminecraft/cooking/husbandry/HusbandryTickTask.java index 488dfc0..a73bfcf 100644 --- a/src/main/java/net/tfminecraft/cooking/husbandry/HusbandryTickTask.java +++ b/src/main/java/net/tfminecraft/cooking/husbandry/HusbandryTickTask.java @@ -51,6 +51,7 @@ private static void tick() { if (HusbandrySimulator.visitLongEnough(animal, now)) { HusbandrySimulator.tickLoaded(animal, now); } + HusbandryLocation.remember(animal, living); HusbandryGrowth.applyMaturity(living, animal, now); HusbandryMounts.applyStats(living, animal); HusbandryShed.tryShed(living, animal, now); diff --git a/src/main/resources/plugin.yml b/src/main/resources/plugin.yml index f6fac1f..a05eedb 100644 --- a/src/main/resources/plugin.yml +++ b/src/main/resources/plugin.yml @@ -12,6 +12,10 @@ commands: description: Cooking admin/debug commands usage: /cooking reload | /cooking builditem | /cooking preview ... permission: cooking.admin + animals: + description: List the animals you own and where they were last seen + usage: /animals + aliases: [livestock] permissions: cooking.admin: diff --git a/src/test/java/net/tfminecraft/cooking/husbandry/HusbandryRepositoryTest.java b/src/test/java/net/tfminecraft/cooking/husbandry/HusbandryRepositoryTest.java index b47f6af..d05aedb 100644 --- a/src/test/java/net/tfminecraft/cooking/husbandry/HusbandryRepositoryTest.java +++ b/src/test/java/net/tfminecraft/cooking/husbandry/HusbandryRepositoryTest.java @@ -1,10 +1,15 @@ package net.tfminecraft.cooking.husbandry; import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertNull; import static org.junit.jupiter.api.Assertions.assertTrue; import java.io.File; import java.nio.file.Path; +import java.sql.Connection; +import java.sql.DriverManager; +import java.sql.PreparedStatement; import java.util.Arrays; import java.util.List; import java.util.Random; @@ -128,6 +133,127 @@ void bumpedRevisionResetsMatchingRows(@TempDir Path tempDir) { } } + @Test + void locationRoundTripsAndListsOnlyThatPlayersAnimals(@TempDir Path tempDir) { + File dbFile = tempDir.resolve("husbandry.db").toFile(); + HusbandryRepository repository = HusbandryRepository.open(dbFile); + try { + UUID player = UUID.randomUUID(); + UUID other = UUID.randomUUID(); + HusbandryAnimal cow = animal(UUID.randomUUID(), "COW", "Bess", 10); + cow.setLastLocation("world", 120, 64, -340); + 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")); + + HusbandryAnimal reloaded = repository.getAnimal(cow.uuid()).orElseThrow(); + assertEquals("world", reloaded.world()); + assertEquals(120, reloaded.x()); + assertEquals(64, reloaded.y()); + assertEquals(-340, reloaded.z()); + assertFalse(repository.getAnimal(sheep.uuid()).orElseThrow().hasLocation()); + + List mine = repository.listForPlayer(player); + assertEquals(2, mine.size()); + assertEquals("owner", roleOf(mine, cow.uuid())); + assertEquals("coowner", roleOf(mine, sheep.uuid())); + assertEquals(1, repository.listForPlayer(other).stream() + .filter(row -> row.animal().uuid().equals(pig.uuid())) + .count()); + assertTrue(repository.listForPlayer(UUID.randomUUID()).isEmpty()); + } finally { + repository.close(); + } + } + + @Test + void versionSevenGainsLocationColumnsWithoutLosingRows(@TempDir Path tempDir) throws Exception { + File dbFile = tempDir.resolve("legacy.db").toFile(); + UUID uuid = UUID.randomUUID(); + try (Connection connection = DriverManager.getConnection("jdbc:sqlite:" + dbFile.getAbsolutePath())) { + connection.createStatement().execute(""" + CREATE TABLE animals ( + uuid TEXT PRIMARY KEY, + type TEXT NOT NULL, + name TEXT NOT NULL DEFAULT '', + state TEXT NOT NULL DEFAULT 'untamed', + genetics INTEGER NOT NULL DEFAULT 0, + care INTEGER NOT NULL DEFAULT 0, + hungry_since INTEGER, + dirty_since INTEGER, + last_processed_at INTEGER NOT NULL, + unloaded_at INTEGER, + affliction_elapsed REAL NOT NULL DEFAULT 0, + affliction_at REAL NOT NULL DEFAULT 0, + last_milk_at INTEGER, + wool_ready_at INTEGER, + neutered INTEGER NOT NULL DEFAULT 0, + loaded_visit_start INTEGER, + mature_at INTEGER, + shed_ready_at INTEGER, + egg_ready_at INTEGER, + care_up_remainder INTEGER NOT NULL DEFAULT 0, + care_down_remainder INTEGER NOT NULL DEFAULT 0, + stats_revision TEXT + ) + """); + connection.createStatement().execute("PRAGMA user_version = 7"); + try (PreparedStatement insert = connection.prepareStatement( + "INSERT INTO animals (uuid, type, name, state, genetics, care, last_processed_at, stats_revision) VALUES (?, ?, ?, ?, ?, ?, ?, ?)")) { + insert.setString(1, uuid.toString()); + insert.setString(2, "GOAT"); + insert.setString(3, "Nanny"); + insert.setString(4, "owned"); + insert.setInt(5, 440); + insert.setInt(6, 80); + insert.setLong(7, 50L); + insert.setString(8, "1"); + insert.executeUpdate(); + } + } + + HusbandryRepository repository = HusbandryRepository.open(dbFile); + try { + HusbandryAnimal loaded = repository.getAnimal(uuid).orElseThrow(); + assertEquals("Nanny", loaded.name()); + assertEquals(440, loaded.genetics()); + assertEquals(80, loaded.care()); + assertFalse(loaded.hasLocation()); + assertNull(loaded.world()); + loaded.setLastLocation("tfmc_world", 3, 70, 9); + repository.upsertAnimal(loaded); + } finally { + repository.close(); + } + + try (Connection connection = DriverManager.getConnection("jdbc:sqlite:" + dbFile.getAbsolutePath())) { + assertEquals(8, connection.createStatement().executeQuery("PRAGMA user_version").getInt(1)); + } + HusbandryRepository again = HusbandryRepository.open(dbFile); + try { + HusbandryAnimal loaded = again.getAnimal(uuid).orElseThrow(); + assertEquals("tfmc_world", loaded.world()); + assertEquals(3, loaded.x()); + assertEquals(70, loaded.y()); + assertEquals(9, loaded.z()); + assertEquals(440, loaded.genetics()); + } finally { + again.close(); + } + } + + private static String roleOf(List rows, UUID animalUuid) { + return rows.stream() + .filter(row -> animalUuid.equals(row.animal().uuid())) + .findFirst() + .orElseThrow() + .role(); + } + private static HusbandryAnimal animal(UUID uuid, String type, String name, int care) { HusbandryAnimal animal = new HusbandryAnimal(uuid, type, name); animal.setCare(care); diff --git a/src/test/java/net/tfminecraft/cooking/husbandry/HusbandryRosterTest.java b/src/test/java/net/tfminecraft/cooking/husbandry/HusbandryRosterTest.java new file mode 100644 index 0000000..70e01e6 --- /dev/null +++ b/src/test/java/net/tfminecraft/cooking/husbandry/HusbandryRosterTest.java @@ -0,0 +1,95 @@ +package net.tfminecraft.cooking.husbandry; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertNull; +import static org.junit.jupiter.api.Assertions.assertTrue; + +import java.util.List; +import java.util.UUID; + +import org.junit.jupiter.api.Test; + +import net.kyori.adventure.text.Component; +import net.kyori.adventure.text.event.ClickEvent; +import net.kyori.adventure.text.serializer.plain.PlainTextComponentSerializer; + +class HusbandryRosterTest { + + private static final PlainTextComponentSerializer PLAIN = PlainTextComponentSerializer.plainText(); + + @Test + void emptyRosterTellsTheOwnerHowToClaim() { + List lines = text(HusbandryRoster.render("Ada", true, List.of(), 15, 1_000L)); + assertEquals(List.of( + "Your animals (0/15)", + "You do not own any animals yet.", + "Claim one with a named Ownership Token."), lines); + } + + @Test + void listsNameSpeciesStatusAndCopyableCoordinates() { + HusbandryAnimal bess = animal("COW", "Bess"); + bess.setLastLocation("world", 120, 64, -340); + HusbandryAnimal woolly = animal("SHEEP", "§cWoolly"); + woolly.setHungrySince(1L); + woolly.setDirtySince(2L); + woolly.setLastLocation("world", 118, 65, -338); + HusbandryAnimal calf = animal("COW", ""); + calf.setMatureAt(5_000L); + calf.setDirtySince(1L); + + List lines = HusbandryRoster.render("Ada", true, List.of( + new HusbandryOwned(woolly, "owner"), + new HusbandryOwned(calf, "coowner"), + new HusbandryOwned(bess, "owner")), 15, 1_000L); + + assertEquals("Your animals (3/15)", PLAIN.serialize(lines.get(0))); + assertEquals("• Bess · Cow · Happy · world 120, 64, -340", PLAIN.serialize(lines.get(1))); + assertEquals("• Cow · Co-owner · Growing · Dirty · location not recorded yet", PLAIN.serialize(lines.get(2))); + assertEquals("• Woolly · Sheep · Hungry, Dirty · world 118, 65, -338", PLAIN.serialize(lines.get(3))); + + ClickEvent copy = findClick(lines.get(1)); + assertEquals(ClickEvent.Action.COPY_TO_CLIPBOARD, copy.action()); + assertEquals("world 120, 64, -340", copy.value()); + assertNull(findClick(lines.get(2))); + } + + @Test + void otherPlayersEmptyListUsesTheirName() { + List lines = text(HusbandryRoster.render("Ada", false, List.of(), 15, 1_000L)); + assertEquals(List.of( + "Ada's animals (0/15)", + "Ada does not own any animals."), lines); + } + + private static List text(List lines) { + return lines.stream().map(PLAIN::serialize).toList(); + } + + private static ClickEvent findClick(Component component) { + if (component.clickEvent() != null) { + return component.clickEvent(); + } + for (Component child : component.children()) { + ClickEvent found = findClick(child); + if (found != null) { + return found; + } + } + return null; + } + + private static HusbandryAnimal animal(String type, String name) { + HusbandryAnimal animal = new HusbandryAnimal(UUID.randomUUID(), type, name); + animal.setLastProcessedAt(1L); + return animal; + } + + @Test + void speciesLabelsAreReadable() { + assertEquals("Cow", HusbandryRoster.speciesLabel("COW")); + assertEquals("Mushroom Cow", HusbandryRoster.speciesLabel("MUSHROOM_COW")); + assertEquals("Animal", HusbandryRoster.speciesLabel(" ")); + assertTrue(HusbandryRoster.plain("§aBess\n").equals("Bess")); + } +}