From 0ec22026cbe090ce50ca05406cadd12d2fc8791c Mon Sep 17 00:00:00 2001 From: Nowko Date: Thu, 24 Sep 2026 11:42:05 +0200 Subject: [PATCH 1/2] feat: show training progress and explain pet actions in English Players were losing the session, the word they just said, and what the pet needed. Holograms and short cues keep training understandable, and right-click reports a problem or pets the animal instead of sitting it. Co-authored-by: Cursor --- README.md | 6 +- .../tfminecraft/companionpets/PetsPlugin.java | 1 + .../companionpets/chat/SpokenOrder.java | 9 +- .../tfminecraft/companionpets/fx/PetFx.java | 57 +- .../companionpets/fx/PetHolograms.java | 98 +++ .../companionpets/gui/PetMenus.java | 256 +++++--- .../companionpets/gui/StatLook.java | 127 ++++ .../tfminecraft/companionpets/pet/Pet.java | 15 +- .../companionpets/runtime/PetActions.java | 559 ++++++++++-------- .../companionpets/runtime/PetTicker.java | 51 +- .../companionpets/session/HatchPrompt.java | 20 +- .../companionpets/session/PlayerHints.java | 27 + .../companionpets/session/Sessions.java | 4 + .../session/TrainingSession.java | 9 + .../companionpets/store/PetStore.java | 2 + .../tfminecraft/companionpets/text/Names.java | 27 +- .../companionpets/text/PetTexts.java | 202 +++++-- .../companionpets/training/TrainingMath.java | 7 + .../behavior/LocomotionAndOrdersTest.java | 5 +- .../companionpets/text/NamesTest.java | 29 + .../companionpets/text/PetTextsTest.java | 28 + .../training/TrainingMathTest.java | 8 + 22 files changed, 1141 insertions(+), 406 deletions(-) create mode 100644 src/main/java/net/tfminecraft/companionpets/fx/PetHolograms.java create mode 100644 src/main/java/net/tfminecraft/companionpets/gui/StatLook.java create mode 100644 src/main/java/net/tfminecraft/companionpets/session/PlayerHints.java create mode 100644 src/test/java/net/tfminecraft/companionpets/text/NamesTest.java create mode 100644 src/test/java/net/tfminecraft/companionpets/text/PetTextsTest.java diff --git a/README.md b/README.md index 8e25fa7..1197eb1 100644 --- a/README.md +++ b/README.md @@ -12,8 +12,10 @@ plugins are installed. - Use a configured egg, name the pet in chat, and confirm. The egg is spent only after the name is confirmed and there is room in the outside quota. -- Right-click the pet with an empty hand for the care screen. Food, a brush, - and medicine act immediately. Anyone nearby can feed, clean, and heal. Play, +- Sneak and right-click the pet with an empty hand for its info screen. A + plain right-click checks on the pet: it tells you what is wrong, or you pet + it when it is fine. Pets only sit through the trained Sit trick. Food, a + brush, and medicine act immediately. Anyone nearby can feed, clean, and heal. Play, sleep, tricks, and the whistle belong to the owner. - Sneak and right-click with the kennel item to place a kennel. The kennel and the whistle open the same list: take a pet out, call one, store one, or diff --git a/src/main/java/net/tfminecraft/companionpets/PetsPlugin.java b/src/main/java/net/tfminecraft/companionpets/PetsPlugin.java index 48d56da..ca40ed3 100644 --- a/src/main/java/net/tfminecraft/companionpets/PetsPlugin.java +++ b/src/main/java/net/tfminecraft/companionpets/PetsPlugin.java @@ -58,6 +58,7 @@ public void onDisable() { autosave.cancel(); } if (actions != null) { + actions.holograms().clear(); actions.stashLooseToys(); } if (store != null) { diff --git a/src/main/java/net/tfminecraft/companionpets/chat/SpokenOrder.java b/src/main/java/net/tfminecraft/companionpets/chat/SpokenOrder.java index 70554bb..5a7a11d 100644 --- a/src/main/java/net/tfminecraft/companionpets/chat/SpokenOrder.java +++ b/src/main/java/net/tfminecraft/companionpets/chat/SpokenOrder.java @@ -13,10 +13,17 @@ public static String line(String message) { return message.trim(); } + public static String key(String message) { + return line(message) + .toLowerCase(Locale.ROOT) + .replaceAll("^[\\p{Punct}\\s]+|[\\p{Punct}\\s]+$", "") + .replaceAll("\\s+", " "); + } + public static boolean matches(String message, String word) { if (word == null || word.isBlank()) { return false; } - return line(message).toLowerCase(Locale.ROOT).equals(word.trim().toLowerCase(Locale.ROOT)); + return key(message).equals(key(word)); } } diff --git a/src/main/java/net/tfminecraft/companionpets/fx/PetFx.java b/src/main/java/net/tfminecraft/companionpets/fx/PetFx.java index 20976ad..884a2e3 100644 --- a/src/main/java/net/tfminecraft/companionpets/fx/PetFx.java +++ b/src/main/java/net/tfminecraft/companionpets/fx/PetFx.java @@ -19,6 +19,8 @@ import org.bukkit.util.Vector; import net.kyori.adventure.text.Component; +import net.kyori.adventure.text.format.NamedTextColor; +import net.kyori.adventure.text.serializer.plain.PlainTextComponentSerializer; public final class PetFx { private static final long REPEAT_AFTER_MILLIS = 1_600L; @@ -30,11 +32,31 @@ private PetFx() { } public static void bar(Player player, String text) { + bar(player, Component.text(text)); + } + + public static void bar(Player player, Component text) { show(player, text); QUIET.put(player.getUniqueId(), System.currentTimeMillis() + HOLD_MILLIS); } + public static void tell(Player player, String text) { + player.sendMessage(Component.text("✦ ", NamedTextColor.GOLD).append(Component.text(text, NamedTextColor.YELLOW))); + } + + public static void tip(Player player, String text) { + player.sendMessage(Component.text(" Tip: ", NamedTextColor.AQUA).append(Component.text(text, NamedTextColor.GRAY))); + } + + public static void cue(Player player, Sound sound, float pitch) { + player.playSound(player.getLocation(), sound, 0.7f, pitch); + } + public static void status(Player player, String text) { + status(player, Component.text(text)); + } + + public static void status(Player player, Component text) { Long until = QUIET.get(player.getUniqueId()); if (until != null && System.currentTimeMillis() < until) { return; @@ -42,14 +64,15 @@ public static void status(Player player, String text) { show(player, text); } - private static void show(Player player, String text) { + private static void show(Player player, Component text) { long now = System.currentTimeMillis(); + String key = PlainTextComponentSerializer.plainText().serialize(text); Hold hold = HOLDS.get(player.getUniqueId()); - if (hold != null && text.equals(hold.text) && now - hold.at < REPEAT_AFTER_MILLIS) { + if (hold != null && key.equals(hold.text) && now - hold.at < REPEAT_AFTER_MILLIS) { return; } - HOLDS.put(player.getUniqueId(), new Hold(text, now)); - player.sendActionBar(Component.text(text)); + HOLDS.put(player.getUniqueId(), new Hold(key, now)); + player.sendActionBar(text); } public static void ambient(Entity entity) { @@ -96,6 +119,12 @@ public static void beg(Entity entity) { } } + public static void stopBeg(Entity entity) { + if (entity instanceof Wolf wolf && wolf.isValid()) { + wolf.setInterested(false); + } + } + public static void jump(Entity entity, boolean partial) { Vector velocity = entity.getVelocity(); velocity.setY(partial ? 0.28 : 0.48); @@ -111,6 +140,26 @@ public static void look(Entity entity, Location target) { private record Hold(String text, long at) { } + public static void happy(Entity entity, boolean loud) { + Sound sound = switch (entity.getType()) { + case WOLF -> loud ? Sound.ENTITY_WOLF_AMBIENT : Sound.ENTITY_WOLF_PANT; + case CAT -> loud ? Sound.ENTITY_CAT_PURREOW : Sound.ENTITY_CAT_PURR; + case FOX -> loud ? Sound.ENTITY_FOX_AMBIENT : Sound.ENTITY_FOX_SNIFF; + default -> ambientSound(entity.getType()); + }; + entity.getWorld().playSound(entity.getLocation(), sound, 0.9f, 1.1f); + } + + public static void sad(Entity entity) { + Sound sound = switch (entity.getType()) { + case WOLF -> Sound.ENTITY_WOLF_WHINE; + case CAT -> Sound.ENTITY_CAT_BEG_FOR_FOOD; + case FOX -> Sound.ENTITY_FOX_SNIFF; + default -> ambientSound(entity.getType()); + }; + entity.getWorld().playSound(entity.getLocation(), sound, 0.8f, 0.9f); + } + public static Sound ambientSound(EntityType type) { return switch (type) { case WOLF -> Sound.ENTITY_WOLF_AMBIENT; diff --git a/src/main/java/net/tfminecraft/companionpets/fx/PetHolograms.java b/src/main/java/net/tfminecraft/companionpets/fx/PetHolograms.java new file mode 100644 index 0000000..9736e91 --- /dev/null +++ b/src/main/java/net/tfminecraft/companionpets/fx/PetHolograms.java @@ -0,0 +1,98 @@ +package net.tfminecraft.companionpets.fx; + +import java.util.HashMap; +import java.util.List; +import java.util.Map; +import java.util.UUID; + +import org.bukkit.Bukkit; +import org.bukkit.Location; +import org.bukkit.entity.ArmorStand; +import org.bukkit.entity.Entity; +import org.bukkit.plugin.Plugin; +import org.bukkit.scheduler.BukkitTask; + +import net.kyori.adventure.text.Component; + +public final class PetHolograms { + private static final long FOLLOW_TICKS = 2L; + private static final double ABOVE_NAME = 0.25; + + private final Plugin plugin; + private final Map shown = new HashMap<>(); + + public PetHolograms(Plugin plugin) { + this.plugin = plugin; + } + + public void show(Entity pet, Component text, long ticks) { + if (pet == null || !pet.isValid()) { + return; + } + UUID id = pet.getUniqueId(); + remove(id); + ArmorStand stand = pet.getWorld().spawn(above(pet), ArmorStand.class, as -> { + as.customName(text); + as.setCustomNameVisible(true); + as.setVisible(false); + as.setMarker(true); + as.setGravity(false); + as.setSmall(true); + as.setInvulnerable(true); + as.setSilent(true); + as.setPersistent(false); + }); + Hologram hologram = new Hologram(stand); + shown.put(id, hologram); + hologram.follow = Bukkit.getScheduler().runTaskTimer(plugin, () -> { + if (!pet.isValid() || !stand.isValid()) { + remove(id, hologram); + return; + } + stand.teleport(above(pet)); + }, FOLLOW_TICKS, FOLLOW_TICKS); + hologram.expire = Bukkit.getScheduler().runTaskLater(plugin, () -> remove(id, hologram), ticks); + } + + public void clear() { + for (UUID id : List.copyOf(shown.keySet())) { + remove(id); + } + } + + private void remove(UUID id) { + Hologram hologram = shown.get(id); + if (hologram != null) { + remove(id, hologram); + } + } + + private void remove(UUID id, Hologram hologram) { + if (shown.get(id) == hologram) { + shown.remove(id); + } + if (hologram.follow != null) { + hologram.follow.cancel(); + } + if (hologram.expire != null) { + hologram.expire.cancel(); + } + if (hologram.stand.isValid()) { + hologram.stand.remove(); + } + } + + private static Location above(Entity pet) { + return pet.getLocation().add(0, pet.getHeight() + ABOVE_NAME, 0); + } + + private static final class Hologram { + private final ArmorStand stand; + private BukkitTask follow; + private BukkitTask expire; + + private Hologram(ArmorStand stand) { + this.stand = stand; + } + } +} diff --git a/src/main/java/net/tfminecraft/companionpets/gui/PetMenus.java b/src/main/java/net/tfminecraft/companionpets/gui/PetMenus.java index 6bf42fa..7ceb54e 100644 --- a/src/main/java/net/tfminecraft/companionpets/gui/PetMenus.java +++ b/src/main/java/net/tfminecraft/companionpets/gui/PetMenus.java @@ -13,22 +13,25 @@ import net.kyori.adventure.text.Component; import net.kyori.adventure.text.format.NamedTextColor; +import net.kyori.adventure.text.format.TextColor; import net.kyori.adventure.text.format.TextDecoration; import net.tfminecraft.companionpets.config.PetTypeDef; -import net.tfminecraft.companionpets.pet.Activity; +import net.tfminecraft.companionpets.pet.Illness; import net.tfminecraft.companionpets.pet.Need; import net.tfminecraft.companionpets.pet.NeedBand; import net.tfminecraft.companionpets.pet.Pet; -import net.tfminecraft.companionpets.pet.PetOrder; import net.tfminecraft.companionpets.pet.PetSex; import net.tfminecraft.companionpets.pet.Trick; import net.tfminecraft.companionpets.runtime.PetRuntime; import net.tfminecraft.companionpets.text.PetTexts; +import net.tfminecraft.companionpets.training.TrainingMath; +import net.tfminecraft.companionpets.training.TrainingSettings; public final class PetMenus { private static final int TRICK_ROW = 10; - + private static final int WORD_SLOT = 4; + public static final int SKIP_SLOT = 22; private final PetRuntime runtime; public PetMenus(PetRuntime runtime) { @@ -45,7 +48,7 @@ public static int trickIndex(int slot) { public void openCare(org.bukkit.entity.Player player, Pet pet) { MenuHolder holder = new MenuHolder(MenuHolder.Kind.CARE, pet.id(), null); - Inventory inventory = Bukkit.createInventory(holder, 27, title(pet.name())); + Inventory inventory = Bukkit.createInventory(holder, 45, title(pet.name())); holder.inventory(inventory); fillCare(inventory, pet); player.openInventory(inventory); @@ -60,37 +63,102 @@ public void refreshCare(org.bukkit.entity.Player player, Pet pet) { fillCare(holder.getInventory(), pet); } - private static void fillCare(Inventory inventory, Pet pet) { + private void fillCare(Inventory inventory, Pet pet) { + if (inventory.getSize() < 45) { + return; + } frame(inventory); - inventory.setItem(4, named(Material.NAME_TAG, pet.name(), NamedTextColor.GOLD, - line("Vínculo " + Math.round(pet.bond()), NamedTextColor.LIGHT_PURPLE), - meter(pet.bond()))); - inventory.setItem(10, needIcon(Material.COOKED_BEEF, "Hambre", pet.need(Need.HUNGER))); - inventory.setItem(11, needIcon(Material.SUNFLOWER, "Ánimo", pet.need(Need.MOOD))); - inventory.setItem(12, needIcon(Material.FEATHER, "Energía", pet.need(Need.ENERGY))); - inventory.setItem(13, needIcon(Material.BRUSH, "Limpieza", pet.need(Need.CLEANLINESS))); - inventory.setItem(14, needIcon(Material.HONEY_BOTTLE, "Salud", pet.need(Need.HEALTH))); - inventory.setItem(19, named(Material.COOKED_BEEF, "Alimentar", NamedTextColor.GOLD, - line("Gasta comida del inventario", NamedTextColor.GRAY))); - inventory.setItem(20, named(Material.SLIME_BALL, "Jugar", NamedTextColor.GREEN, - line("Un juego corto, sin objeto", NamedTextColor.GRAY))); - inventory.setItem(21, named(Material.WHITE_BED, pet.activity() == Activity.SLEEPING ? "Despertar" : "Acostar", NamedTextColor.AQUA, - line(pet.activity() == Activity.SLEEPING ? "La despierta" : "Solo si tiene sueño", NamedTextColor.GRAY))); - boolean sitting = pet.order() == PetOrder.SIT; - inventory.setItem(22, named(Material.LEAD, sitting ? "Seguir" : "Sentar", NamedTextColor.WHITE, - line(sitting ? "Vuelve a caminar contigo" : "Se queda donde está", NamedTextColor.GRAY))); - inventory.setItem(23, named(Material.BRUSH, "Limpiar", NamedTextColor.YELLOW, - line("Hace falta un cepillo", NamedTextColor.GRAY))); - inventory.setItem(24, named(Material.GLISTERING_MELON_SLICE, "Curar", NamedTextColor.RED, - line("Solo si está enferma", NamedTextColor.GRAY))); - String favorite = pet.favoriteToy() == null ? "ninguno" : pet.favoriteToy().toLowerCase(); - inventory.setItem(26, named(Material.STICK, "Juguete favorito", NamedTextColor.GOLD, - line(favorite, NamedTextColor.GRAY))); + PetTypeDef type = runtime.config().type(pet.typeId()); + Material egg = type == null || type.egg() == null ? Material.BONE : type.egg(); + boolean female = pet.sex() == PetSex.FEMALE; + Material food = type == null || type.favoriteFood() == null ? Material.COOKED_BEEF : type.favoriteFood(); + boolean sick = pet.illness() != Illness.NONE; + + inventory.setItem(4, named(egg, PetTexts.speciesName(pet.typeId()), NamedTextColor.GOLD, + line("Species", NamedTextColor.GRAY))); + inventory.setItem(11, named( + female ? Material.PINK_DYE : Material.LIGHT_BLUE_DYE, + PetTexts.sexName(pet.sex()), + female ? TextColor.color(0xFF8AD4) : TextColor.color(0x55BFFF))); + inventory.setItem(13, named(Material.NAME_TAG, pet.name(), NamedTextColor.GOLD, + condition(pet), + Component.empty(), + line("Rename " + PetTexts.him(pet.sex()) + " from your kennel", NamedTextColor.DARK_GRAY))); + inventory.setItem(15, named(Material.CLOCK, "Age", NamedTextColor.WHITE, + line(PetTexts.age(pet.bornAt(), System.currentTimeMillis()), NamedTextColor.GRAY))); + + Component treat = type == null || type.favoriteFood() == null ? line("treats", NamedTextColor.GRAY) : item(type.favoriteFood()); + inventory.setItem(20, needIcon(food, pet, Need.HUNGER, line("Feed ", NamedTextColor.DARK_GRAY).append(foods(type)))); + inventory.setItem(21, needIcon(Material.SUNFLOWER, pet, Need.MOOD, + line("Play fetch or give " + PetTexts.him(pet.sex()) + " ", NamedTextColor.DARK_GRAY).append(treat))); + inventory.setItem(22, needIcon(Material.BLAZE_POWDER, pet, Need.ENERGY, + line("Recovers while " + PetTexts.he(pet.sex()) + " sleeps", NamedTextColor.DARK_GRAY))); + inventory.setItem(23, needIcon(Material.BRUSH, pet, Need.CLEANLINESS, + line("Use a brush on " + PetTexts.him(pet.sex()), NamedTextColor.DARK_GRAY))); + Material medicine = type == null || type.medicine() == null ? Material.HONEY_BOTTLE : type.medicine(); + inventory.setItem(24, needIcon(sick ? medicine : Material.GOLDEN_APPLE, pet, Need.HEALTH, + sick + ? line("Cure with ", NamedTextColor.DARK_GRAY).append(item(medicine)) + : line("Keep " + PetTexts.his(pet.sex()) + " needs up to stay healthy", NamedTextColor.DARK_GRAY), + sick ? line(PetTexts.illness(pet.name(), pet.sex(), pet.illness()), NamedTextColor.RED) : null)); + + inventory.setItem(30, named(Material.LEAD, "Bond", StatLook.BOND, + StatLook.bar(pet.bond(), StatLook.BOND), + line(StatLook.bondState(pet.bond()), StatLook.BOND), + Component.empty(), + line("Grows while you spend time together", NamedTextColor.DARK_GRAY))); + inventory.setItem(32, toyIcon(pet)); + } + + private static Component condition(Pet pet) { + if (pet.illness() != Illness.NONE) { + return line("● " + PetTexts.illness(pet.name(), pet.sex(), pet.illness()), NamedTextColor.RED); + } + Need worst = null; + for (Need need : Need.values()) { + if (worst == null || pet.need(need) < pet.need(worst)) { + worst = need; + } + } + double value = pet.need(worst); + if (NeedBand.of(value) == NeedBand.STABLE) { + return line("● Happy and healthy", StatLook.band(value)); + } + return line("● Needs care: " + StatLook.state(worst, value).toLowerCase(java.util.Locale.ROOT), StatLook.band(value)); + } + + private static Component item(Material material) { + return Component.translatable(material.translationKey(), NamedTextColor.GRAY).decoration(TextDecoration.ITALIC, false); + } + + private static Component foods(PetTypeDef type) { + if (type == null || type.foods().isEmpty()) { + return line("food", NamedTextColor.GRAY); + } + Component joined = Component.empty(); + int index = 0; + for (Material food : type.foods().keySet()) { + if (index > 0) { + joined = joined.append(line(index == type.foods().size() - 1 ? " or " : ", ", NamedTextColor.DARK_GRAY)); + } + joined = joined.append(item(food)); + index++; + } + return joined; + } + + private static ItemStack toyIcon(Pet pet) { + Material toy = pet.favoriteToy() == null ? null : Material.matchMaterial(pet.favoriteToy()); + if (toy == null) { + return named(Material.BARRIER, "No favorite toy yet", NamedTextColor.DARK_GRAY); + } + return named(toy, "Favorite Toy", NamedTextColor.GOLD, + Component.translatable(toy.translationKey()).color(NamedTextColor.GRAY).decoration(TextDecoration.ITALIC, false)); } public void openKennel(org.bukkit.entity.Player player) { MenuHolder holder = new MenuHolder(MenuHolder.Kind.KENNEL, null, null); - Inventory inventory = Bukkit.createInventory(holder, 27, title("Caseta")); + Inventory inventory = Bukkit.createInventory(holder, 27, title("Kennel")); holder.inventory(inventory); frame(inventory); int slot = 0; @@ -100,15 +168,17 @@ public void openKennel(org.bukkit.entity.Player player) { } PetTypeDef type = runtime.config().type(pet.typeId()); Material icon = type == null || type.egg() == null ? Material.BONE : type.egg(); - String sex = pet.sex() == PetSex.FEMALE ? "Hembra" : "Macho"; boolean stored = pet.stored(); ItemStack item = named(icon, pet.name(), NamedTextColor.GOLD, - line(sex + " · " + pet.typeId(), NamedTextColor.GRAY), - line(stored ? "En la caseta" : "Fuera", stored ? NamedTextColor.DARK_GRAY : NamedTextColor.GREEN), - meter(pet.need(Need.ENERGY)), - line("Izquierda: sacar o llamar", NamedTextColor.GRAY), - line("Derecha: guardar", NamedTextColor.GRAY), - line("Mayús: cambiar nombre", NamedTextColor.GRAY)); + line(PetTexts.sexName(pet.sex()) + " " + PetTexts.speciesName(pet.typeId()).toLowerCase(java.util.Locale.ROOT), + NamedTextColor.GRAY), + line(stored ? "Resting in the kennel" : "Out and about", stored ? NamedTextColor.DARK_GRAY : NamedTextColor.GREEN), + line("Energy ", StatLook.theme(Need.ENERGY)).append(StatLook.bar(pet.need(Need.ENERGY))), + Component.empty(), + line(stored ? "Left-click to bring " + PetTexts.him(pet.sex()) + " out" : "Left-click to call " + PetTexts.him(pet.sex()), + NamedTextColor.YELLOW), + stored ? null : line("Right-click to send " + PetTexts.him(pet.sex()) + " to the kennel", NamedTextColor.YELLOW), + line("Shift-click to rename", NamedTextColor.YELLOW)); ItemMeta meta = item.getItemMeta(); meta.getPersistentDataContainer().set(runtime.petKey(), PersistentDataType.STRING, pet.id().toString()); item.setItemMeta(meta); @@ -119,29 +189,69 @@ public void openKennel(org.bukkit.entity.Player player) { public void openTricks(org.bukkit.entity.Player player, Pet pet, String word) { MenuHolder holder = new MenuHolder(MenuHolder.Kind.TRICK, pet.id(), word); - Inventory inventory = Bukkit.createInventory(holder, 27, title("Enseñar: " + word)); + Inventory inventory = Bukkit.createInventory(holder, 27, title("What does “" + word + "” mean?")); holder.inventory(inventory); frame(inventory); - inventory.setItem(4, named(Material.BOOK, word, NamedTextColor.GOLD, - line("Elige qué significa", NamedTextColor.GRAY))); + inventory.setItem(WORD_SLOT, named(Material.WRITABLE_BOOK, "New word: “" + word + "”", NamedTextColor.GOLD, + line(pet.name() + " doesn't know this word yet.", NamedTextColor.GRAY), + line("Pick the trick " + PetTexts.he(pet.sex()) + " should do", NamedTextColor.GRAY), + line("whenever you say “" + word + "”.", NamedTextColor.GRAY))); + TrainingSettings training = runtime.config().training(); Trick[] tricks = Trick.values(); - Material[] icons = { - Material.SADDLE, - Material.LEAD, - Material.BARRIER, - Material.GOAT_HORN, - Material.RABBIT_FOOT, - Material.COMPASS, - Material.BONE, - Material.PLAYER_HEAD - }; for (int index = 0; index < tricks.length; index++) { - inventory.setItem(TRICK_ROW + index, named(icons[index], PetTexts.trickName(tricks[index]), NamedTextColor.WHITE, - line("Palabra: " + word, NamedTextColor.GRAY))); + inventory.setItem(TRICK_ROW + index, trickIcon(pet, tricks[index], word, training)); } + inventory.setItem(SKIP_SLOT, named(Material.BARRIER, "Not now", NamedTextColor.RED, + line("Leave “" + word + "” unlinked", NamedTextColor.GRAY))); player.openInventory(inventory); } + private static ItemStack trickIcon(Pet pet, Trick trick, String word, TrainingSettings training) { + boolean learned = pet.progress(trick) >= training.learnedAt(); + ItemStack item = named(trickMaterial(trick), PetTexts.trickName(trick), learned ? NamedTextColor.GREEN : NamedTextColor.WHITE, + line(PetTexts.trickDescription(trick), NamedTextColor.GRAY), + Component.empty(), + knownAs(pet, trick, training), + Component.empty(), + line("▶ Click to link “" + word + "”", NamedTextColor.YELLOW)); + ItemMeta meta = item.getItemMeta(); + meta.setEnchantmentGlintOverride(learned); + meta.addItemFlags(ItemFlag.HIDE_DYE); + item.setItemMeta(meta); + return item; + } + + private static Material trickMaterial(Trick trick) { + return switch (trick) { + case SIT -> Material.OAK_STAIRS; + case COME -> Material.COMPASS; + case STAY -> Material.ARMOR_STAND; + case SPEAK -> Material.OAK_SIGN; + case JUMP -> Material.LEATHER_BOOTS; + case SPIN -> Material.WIND_CHARGE; + case BEG -> Material.COOKIE; + case PAW -> Material.RABBIT_FOOT; + }; + } + + private static Component knownAs(Pet pet, Trick trick, TrainingSettings training) { + List words = new ArrayList<>(); + for (java.util.Map.Entry entry : pet.words().entrySet()) { + if (entry.getValue() == trick) { + words.add("“" + entry.getKey() + "”"); + } + } + if (words.isEmpty()) { + return line("Not taught yet", NamedTextColor.DARK_GRAY); + } + double progress = pet.progress(trick); + Component taught = line("Taught as " + String.join(", ", words) + " ", NamedTextColor.AQUA); + if (progress >= training.learnedAt()) { + return taught.append(line("✔ Learned", NamedTextColor.GREEN)); + } + return taught.append(StatLook.bar(TrainingMath.percentLearned(progress, training), NamedTextColor.AQUA)); + } + private static void frame(Inventory inventory) { ItemStack edge = pane(Material.BROWN_STAINED_GLASS_PANE); ItemStack fill = pane(Material.LIGHT_GRAY_STAINED_GLASS_PANE); @@ -161,43 +271,41 @@ private static ItemStack pane(Material material) { return item; } - private static ItemStack needIcon(Material material, String label, double value) { - NamedTextColor color = bandColor(value); - return named(material, label + " " + Math.round(value), color, meter(value)); - } - - private static Component meter(double value) { - int filled = (int) Math.round(Math.max(0.0, Math.min(100.0, value)) / 10.0); - return Component.text("●".repeat(filled), bandColor(value)).decoration(TextDecoration.ITALIC, false) - .append(Component.text("●".repeat(10 - filled), NamedTextColor.DARK_GRAY).decoration(TextDecoration.ITALIC, false)); - } - - private static NamedTextColor bandColor(double value) { - return switch (NeedBand.of(value)) { - case STABLE -> NamedTextColor.GREEN; - case LOW -> NamedTextColor.YELLOW; - case CRITICAL -> NamedTextColor.RED; - }; + private static ItemStack needIcon(Material material, Pet pet, Need need, Component hint, Component... extra) { + double value = pet.need(need); + List lore = new ArrayList<>(); + lore.add(StatLook.bar(value)); + lore.add(line(StatLook.state(need, value), StatLook.band(value))); + for (Component component : extra) { + if (component != null) { + lore.add(component); + } + } + lore.add(Component.empty()); + lore.add(hint); + return named(material, PetTexts.needName(need), StatLook.theme(need), lore.toArray(new Component[0])); } private static Component title(String text) { return Component.text(text, NamedTextColor.GOLD); } - private static Component line(String text, NamedTextColor color) { + private static Component line(String text, TextColor color) { return Component.text(text, color).decoration(TextDecoration.ITALIC, false); } - private static ItemStack named(Material material, String name, NamedTextColor color, Component... lore) { + private static ItemStack named(Material material, String name, TextColor color, Component... lore) { ItemStack item = new ItemStack(material); ItemMeta meta = item.getItemMeta(); meta.displayName(Component.text(name, color).decoration(TextDecoration.ITALIC, false)); meta.addItemFlags(ItemFlag.HIDE_ATTRIBUTES, ItemFlag.HIDE_ADDITIONAL_TOOLTIP); - if (lore.length > 0) { - List lines = new ArrayList<>(); - for (Component component : lore) { + List lines = new ArrayList<>(); + for (Component component : lore) { + if (component != null) { lines.add(component.decoration(TextDecoration.ITALIC, false)); } + } + if (!lines.isEmpty()) { meta.lore(lines); } item.setItemMeta(meta); diff --git a/src/main/java/net/tfminecraft/companionpets/gui/StatLook.java b/src/main/java/net/tfminecraft/companionpets/gui/StatLook.java new file mode 100644 index 0000000..6a71e56 --- /dev/null +++ b/src/main/java/net/tfminecraft/companionpets/gui/StatLook.java @@ -0,0 +1,127 @@ +package net.tfminecraft.companionpets.gui; + +import net.kyori.adventure.text.Component; +import net.kyori.adventure.text.TextComponent; +import net.kyori.adventure.text.format.NamedTextColor; +import net.kyori.adventure.text.format.TextColor; +import net.kyori.adventure.text.format.TextDecoration; + +import net.tfminecraft.companionpets.pet.Need; +import net.tfminecraft.companionpets.pet.NeedBand; +import net.tfminecraft.companionpets.pet.Pet; + +public final class StatLook { + private static final int SEGMENTS = 10; + private static final int BAR_SEGMENTS_SHORT = 5; + private static final String SEGMENT = "■"; + private static final TextColor EMPTY = TextColor.color(0x3A3A3A); + private static final TextColor GOOD = TextColor.color(0x55FF55); + private static final TextColor LOW = TextColor.color(0xFFD23F); + private static final TextColor CRITICAL = TextColor.color(0xFF4F4F); + public static final TextColor BOND = TextColor.color(0xF28DD7); + + private StatLook() { + } + + public static TextColor theme(Need need) { + return switch (need) { + case HUNGER -> TextColor.color(0xF0A04B); + case MOOD -> TextColor.color(0xFFE066); + case ENERGY -> TextColor.color(0x6FE3FF); + case CLEANLINESS -> TextColor.color(0xA7D8FF); + case HEALTH -> TextColor.color(0xFF6B6B); + }; + } + + public static TextColor band(double value) { + return switch (NeedBand.of(value)) { + case STABLE -> GOOD; + case LOW -> LOW; + case CRITICAL -> CRITICAL; + }; + } + + public static Component bar(double value) { + return bar(value, band(value), SEGMENTS); + } + + public static Component bar(double value, TextColor fill) { + return bar(value, fill, SEGMENTS); + } + + public static Component bar(double value, TextColor fill, int segments) { + double clamped = Math.max(0.0, Math.min(100.0, value)); + int filled = (int) Math.round(clamped / 100.0 * segments); + if (filled == 0 && clamped > 0.0) { + filled = 1; + } + TextComponent.Builder bar = Component.text().decoration(TextDecoration.ITALIC, false); + bar.append(Component.text(SEGMENT.repeat(filled), fill)); + bar.append(Component.text(SEGMENT.repeat(segments - filled), EMPTY)); + return bar.build(); + } + + public static String state(Need need, double value) { + NeedBand band = NeedBand.of(value); + return switch (need) { + case HUNGER -> pick(band, "Well fed", "Peckish", "Starving"); + case MOOD -> pick(band, "Cheerful", "Restless", "Miserable"); + case ENERGY -> pick(band, "Full of energy", "Tired", "Exhausted"); + case CLEANLINESS -> pick(band, "Spotless", "Scruffy", "Filthy"); + case HEALTH -> pick(band, "Healthy", "Weak", "In danger"); + }; + } + + public static String bondState(double bond) { + if (bond >= 85.0) { + return "Devoted"; + } + if (bond >= 60.0) { + return "Loyal"; + } + if (bond >= 30.0) { + return "Friendly"; + } + return "Wary"; + } + + public static Component summary(Pet pet, Component tag) { + TextComponent.Builder line = Component.text(); + if (tag != null) { + line.append(tag).append(Component.text(" │ ", NamedTextColor.DARK_GRAY)); + } + boolean first = true; + for (Need need : Need.values()) { + double value = pet.need(need); + if (!first) { + line.append(Component.text(" ")); + } + first = false; + line.append(Component.text(shortName(need) + " ", NamedTextColor.GRAY)); + line.append(bar(value, band(value), BAR_SEGMENTS_SHORT)); + } + return line.build(); + } + + public static Component tag(String text, TextColor color) { + return Component.text("● " + text, color); + } + + private static String shortName(Need need) { + return switch (need) { + case HUNGER -> "Food"; + case MOOD -> "Mood"; + case ENERGY -> "Energy"; + case CLEANLINESS -> "Clean"; + case HEALTH -> "Health"; + }; + } + + private static String pick(NeedBand band, String stable, String low, String critical) { + return switch (band) { + case STABLE -> stable; + case LOW -> low; + case CRITICAL -> critical; + }; + } +} diff --git a/src/main/java/net/tfminecraft/companionpets/pet/Pet.java b/src/main/java/net/tfminecraft/companionpets/pet/Pet.java index 7739851..99b8b63 100644 --- a/src/main/java/net/tfminecraft/companionpets/pet/Pet.java +++ b/src/main/java/net/tfminecraft/companionpets/pet/Pet.java @@ -4,10 +4,10 @@ import java.util.EnumMap; import java.util.EnumSet; import java.util.LinkedHashMap; -import java.util.Locale; import java.util.Map; import java.util.UUID; +import net.tfminecraft.companionpets.chat.SpokenOrder; import net.tfminecraft.companionpets.play.FetchJob; public final class Pet { @@ -28,6 +28,7 @@ public final class Pet { private UUID entityId; private final EnumMap needs = new EnumMap<>(Need.class); private double bond; + private long bornAt; private long criticalMillis; private long dirtyMillis; private final EnumSet announcedLow = EnumSet.noneOf(Need.class); @@ -86,6 +87,14 @@ public void sex(PetSex sex) { this.sex = sex; } + public long bornAt() { + return bornAt; + } + + public void bornAt(long bornAt) { + this.bornAt = bornAt; + } + public PetOrder order() { return order; } @@ -262,14 +271,14 @@ public Map words() { } public void bindWord(String word, Trick trick) { - words.put(word.toLowerCase(Locale.ROOT), trick); + words.put(SpokenOrder.key(word), trick); } public Trick trickFor(String word) { if (word == null) { return null; } - return words.get(word.toLowerCase(Locale.ROOT)); + return words.get(SpokenOrder.key(word)); } public boolean knowsWord(String word) { diff --git a/src/main/java/net/tfminecraft/companionpets/runtime/PetActions.java b/src/main/java/net/tfminecraft/companionpets/runtime/PetActions.java index 311d87a..fcc0aed 100644 --- a/src/main/java/net/tfminecraft/companionpets/runtime/PetActions.java +++ b/src/main/java/net/tfminecraft/companionpets/runtime/PetActions.java @@ -10,6 +10,7 @@ import org.bukkit.Location; import org.bukkit.Material; import org.bukkit.Particle; +import org.bukkit.Sound; import org.bukkit.World; import org.bukkit.block.Block; import org.bukkit.block.BlockFace; @@ -24,14 +25,22 @@ import org.bukkit.util.RayTraceResult; import org.bukkit.util.Vector; +import net.kyori.adventure.text.Component; +import net.kyori.adventure.text.format.NamedTextColor; + +import net.tfminecraft.companionpets.care.DominantNeed; +import net.tfminecraft.companionpets.chat.SpokenOrder; import net.tfminecraft.companionpets.config.PetTypeDef; import net.tfminecraft.companionpets.fx.PetFx; +import net.tfminecraft.companionpets.fx.PetHolograms; import net.tfminecraft.companionpets.gui.MenuHolder; import net.tfminecraft.companionpets.gui.PetMenus; +import net.tfminecraft.companionpets.gui.StatLook; import net.tfminecraft.companionpets.management.Quota; import net.tfminecraft.companionpets.pet.Activity; import net.tfminecraft.companionpets.pet.Illness; import net.tfminecraft.companionpets.pet.Need; +import net.tfminecraft.companionpets.pet.NeedBand; import net.tfminecraft.companionpets.pet.Pet; import net.tfminecraft.companionpets.pet.PetOrder; import net.tfminecraft.companionpets.pet.PetSex; @@ -41,6 +50,7 @@ import net.tfminecraft.companionpets.play.FetchJob; import net.tfminecraft.companionpets.play.ThrowSpeed; import net.tfminecraft.companionpets.session.HatchPrompt; +import net.tfminecraft.companionpets.session.PlayerHints; import net.tfminecraft.companionpets.session.RenamePrompt; import net.tfminecraft.companionpets.session.TrainingSession; import net.tfminecraft.companionpets.store.PetStore; @@ -52,18 +62,33 @@ public final class PetActions { private static final long PROMPT_MILLIS = 60_000L; + private static final long ATTEMPT_HOLOGRAM_TICKS = 60L; + private static final long REWARD_HOLOGRAM_TICKS = 60L; + private static final long LEARNED_HOLOGRAM_TICKS = 100L; + private static final long PET_COOLDOWN_MILLIS = 30_000L; + private static final double PET_MOOD_GAIN = 4.0; + private final PetRuntime runtime; private final PetMenus menus; + private final PetHolograms holograms; + private final PlayerHints hints; + private final java.util.Map pettedAt = new java.util.HashMap<>(); public PetActions(PetRuntime runtime) { this.runtime = runtime; this.menus = new PetMenus(runtime); + this.holograms = new PetHolograms(runtime.plugin()); + this.hints = new PlayerHints(runtime.plugin()); } public PetMenus menus() { return menus; } + public PetHolograms holograms() { + return holograms; + } + public boolean useOnPet(Player player, Entity entity, ItemStack hand) { Pet pet = runtime.byEntity(entity); if (pet == null || pet.stored()) { @@ -84,14 +109,14 @@ public boolean useOnPet(Player player, Entity entity, ItemStack hand) { pet.need(Need.HEALTH, pet.need(Need.HEALTH) + runtime.config().care().medicineHealthBump()); comfort(pet, now); PetFx.hearts(entity, 3); - PetFx.bar(player, pet.name() + " empieza a recuperarse"); + PetFx.bar(player, pet.name() + " is already starting to feel better"); return true; } if (held == Material.BRUSH) { pet.need(Need.CLEANLINESS, 100); comfort(pet, now); PetFx.hearts(entity, 2); - PetFx.bar(player, pet.name() + (pet.sex() == PetSex.FEMALE ? " queda limpia" : " queda limpio")); + PetFx.bar(player, pet.name() + "'s coat is clean and shiny again"); return true; } if (owner && held == type.favoriteFood()) { @@ -102,7 +127,7 @@ public boolean useOnPet(Player player, Entity entity, ItemStack hand) { } if (pet.need(Need.HUNGER) >= 60.0) { if (TrainingMath.attentionBlocked(pet.need(Need.HUNGER), pet.need(Need.ENERGY), sick(pet))) { - PetFx.bar(player, PetTexts.refusal(pet.name(), pet.sex(), "attention")); + PetFx.bar(player, pet.name() + " can't train right now: " + focusReason(pet)); return true; } beginTraining(player, pet, entity); @@ -112,26 +137,71 @@ public boolean useOnPet(Player player, Entity entity, ItemStack hand) { Double gain = type.foodGain(held); if (gain != null) { feed(player, pet, entity, hand, gain, held == type.favoriteFood()); + if (owner && held == type.favoriteFood()) { + PetFx.bar(player, pet.name() + " was too hungry to train, so " + PetTexts.he(pet.sex()) + + " ate the treat. Keep feeding " + PetTexts.him(pet.sex()) + " before training"); + } else { + PetFx.bar(player, pet.name() + " eats happily"); + } return true; } if (held == Material.AIR) { - menus.openCare(player, pet); + if (player.isSneaking()) { + menus.openCare(player, pet); + return true; + } + checkIn(player, pet, entity, type, now); return true; } if (type.acceptsToy(held)) { - PetFx.bar(player, "Lanza el juguete al aire"); + PetFx.bar(player, "Throw it into the air and " + pet.name() + " will fetch it"); return true; } return false; } + private void checkIn(Player player, Pet pet, Entity entity, PetTypeDef type, long now) { + PetFx.look(entity, player.getEyeLocation()); + if (pet.illness() != Illness.NONE) { + String medicine = type.medicine() == null ? "medicine" : "a " + PetTexts.itemName(type.medicine().name()); + PetFx.bar(player, PetTexts.illnessCheck(pet.name(), pet.sex(), pet.illness(), medicine)); + PetFx.sad(entity); + return; + } + Need need = DominantNeed.select(pet); + if (need != null) { + PetFx.bar(player, PetTexts.needCheck(pet.name(), pet.sex(), need, NeedBand.of(pet.need(need)) == NeedBand.CRITICAL)); + PetFx.sad(entity); + return; + } + comfort(pet, now); + Long last = pettedAt.get(pet.id()); + if (last == null || now - last >= PET_COOLDOWN_MILLIS) { + pettedAt.put(pet.id(), now); + pet.need(Need.MOOD, pet.need(Need.MOOD) + PET_MOOD_GAIN); + } + if (runtime.sessions().resting(pet.id(), now)) { + PetFx.bar(player, PetTexts.restingCheck(pet.name(), pet.sex())); + PetFx.happy(entity, false); + PetFx.hearts(entity, 1); + return; + } + boolean devoted = pet.bond() >= 85.0; + PetFx.bar(player, PetTexts.petted(pet.name(), pet.sex(), pet.typeId(), devoted)); + PetFx.happy(entity, runtime.random().nextInt(3) == 0); + PetFx.hearts(entity, devoted ? 4 : 2); + if (devoted && pet.order() != PetOrder.SIT && !pet.staying()) { + PetFx.jump(entity, true); + } + } + public void useWorld(Player player, ItemStack hand, Block clicked, BlockFace face, boolean sneaking, boolean air) { Material held = hand == null ? Material.AIR : hand.getType(); if (clicked != null && isKennel(clicked) && !sneaking) { UUID owner = runtime.store().kennelOwner(PetStore.kennelKey( clicked.getWorld().getName(), clicked.getX(), clicked.getY(), clicked.getZ())); if (owner != null && !owner.equals(player.getUniqueId())) { - PetFx.bar(player, "Esta caseta no es tuya"); + PetFx.bar(player, "This kennel belongs to someone else"); return; } menus.openKennel(player); @@ -174,7 +244,6 @@ public boolean handledWorld(Player player, ItemStack hand, Block clicked, BlockF public void clickMenu(Player player, MenuHolder holder, int slot, ItemStack current, boolean rightClick, boolean shift) { if (holder.kind() == MenuHolder.Kind.CARE) { - clickCare(player, holder.petId(), slot); return; } if (holder.kind() == MenuHolder.Kind.TRICK) { @@ -234,7 +303,22 @@ public void bindTrick(Player player, Pet pet, String word, Trick trick) { session.pendingWord(null); } player.closeInventory(); - PetFx.bar(player, word + " significa " + PetTexts.trickName(trick).toLowerCase(Locale.ROOT)); + String name = PetTexts.trickName(trick); + hologram(pet, "“" + word + "” → " + name, NamedTextColor.WHITE, ATTEMPT_HOLOGRAM_TICKS); + PetFx.bar(player, "Say “" + word + "” again to practice " + name); + PetFx.cue(player, Sound.ENTITY_EXPERIENCE_ORB_PICKUP, 1.0f); + if (pet.progress(trick) >= runtime.config().training().learnedAt()) { + PetFx.tell(player, "“" + word + "” is now another way to ask " + pet.name() + " to " + name.toLowerCase(Locale.ROOT) + + ". " + PetTexts.He(pet.sex()) + " already knows this trick."); + return; + } + PetFx.tell(player, "“" + word + "” now means " + name + " for " + pet.name() + ". " + + PetTexts.He(pet.sex()) + " doesn't know it yet, so keep practicing."); + if (hints.firstTime(player, "practice")) { + PetFx.tip(player, "Say “" + word + "” while looking at " + pet.name() + " and reward each try with " + + treatName(pet) + " right away. Early tries are clumsy, but once " + PetTexts.he(pet.sex()) + + " catches on, rewarded successes teach much faster."); + } } public void reattach(Entity entity) { @@ -274,60 +358,11 @@ public void stashLooseToys() { } } - private void clickCare(Player player, UUID petId, int slot) { - Pet pet = runtime.store().get(petId); - if (pet == null) { + private void clickTrick(Player player, MenuHolder holder, int slot) { + if (slot == PetMenus.SKIP_SLOT) { + player.closeInventory(); return; } - boolean owner = pet.ownerId().equals(player.getUniqueId()); - Entity entity = runtime.entity(pet); - switch (slot) { - case 19 -> feedFromInventory(player, pet, entity); - case 20 -> { - if (!owner) { - PetFx.bar(player, PetTexts.refusal(pet.name(), pet.sex(), "owner")); - return; - } - startPlay(player, pet); - } - case 21 -> { - if (!owner) { - PetFx.bar(player, PetTexts.refusal(pet.name(), pet.sex(), "owner")); - return; - } - toggleSleep(player, pet); - } - case 22 -> { - if (!owner) { - PetFx.bar(player, PetTexts.refusal(pet.name(), pet.sex(), "owner")); - return; - } - toggleSit(player, pet); - } - case 23 -> { - if (!player.getInventory().contains(Material.BRUSH)) { - PetFx.bar(player, "Hace falta un cepillo"); - return; - } - pet.need(Need.CLEANLINESS, 100); - comfort(pet, System.currentTimeMillis()); - if (entity != null) { - PetFx.hearts(entity, 2); - } - } - case 24 -> healFromInventory(player, pet, entity); - default -> { - return; - } - } - Bukkit.getScheduler().runTask(runtime.plugin(), () -> { - if (player.isOnline() && runtime.store().get(pet.id()) != null) { - menus.openCare(player, pet); - } - }); - } - - private void clickTrick(Player player, MenuHolder holder, int slot) { int index = PetMenus.trickIndex(slot); Trick[] tricks = Trick.values(); if (index < 0 || index >= tricks.length || holder.word() == null || holder.petId() == null) { @@ -347,7 +382,14 @@ private void beginHatch(Player player, PetTypeDef type) { } HatchPrompt prompt = new HatchPrompt(type.id(), System.currentTimeMillis() + PROMPT_MILLIS); runtime.sessions().hatch(player.getUniqueId(), prompt); - player.sendMessage("Escribe el nombre de la mascota. Escribe no para cancelar."); + String species = PetTexts.speciesName(type.id()).toLowerCase(Locale.ROOT); + if (type.sexMode() == SexMode.CHOOSE) { + PetFx.tell(player, "Will your new " + species + " be a boy or a girl? Type \"male\" or \"female\", or \"cancel\" to keep the egg."); + return; + } + prompt.sex(runtime.random().nextBoolean() ? PetSex.MALE : PetSex.FEMALE); + PetFx.tell(player, "Your new " + species + " is a " + (prompt.sex() == PetSex.FEMALE ? "girl" : "boy") + "! What will you call " + + PetTexts.him(prompt.sex()) + "? Type a name in chat, or \"cancel\" to keep the egg."); } private boolean handleHatchChat(Player player, String text, long now) { @@ -357,12 +399,12 @@ private boolean handleHatchChat(Player player, String text, long now) { } if (now > prompt.expiresAt()) { runtime.sessions().clearHatch(player.getUniqueId()); - player.sendMessage("Se ha cancelado el nombre. El huevo no se ha gastado."); + PetFx.tell(player, "You took too long to answer. Your egg is safe."); return true; } if (Names.cancels(text)) { runtime.sessions().clearHatch(player.getUniqueId()); - player.sendMessage("El huevo no se ha gastado."); + PetFx.tell(player, "Hatching cancelled. Your egg is safe."); return true; } PetTypeDef type = runtime.config().type(prompt.typeId()); @@ -370,43 +412,34 @@ private boolean handleHatchChat(Player player, String text, long now) { runtime.sessions().clearHatch(player.getUniqueId()); return true; } + if (prompt.sex() == null) { + PetSex sex = Names.sex(text); + if (sex == null) { + PetFx.tell(player, "Type \"male\" or \"female\", or \"cancel\" to keep the egg."); + return true; + } + prompt.sex(sex); + PetFx.tell(player, "A " + (sex == PetSex.FEMALE ? "girl" : "boy") + "! What will you call " + + PetTexts.him(sex) + "? Type a name in chat."); + return true; + } if (prompt.name() == null) { String name = Names.sanitize(text); if (name.isBlank()) { - player.sendMessage("El nombre está vacío."); + PetFx.tell(player, "That name is empty. Try another one."); return true; } prompt.name(name); prompt.confirming(true); - player.sendMessage("Confirma el nombre " + name + " escribiendo sí."); + PetFx.tell(player, "Name " + PetTexts.him(prompt.sex()) + " " + name + "? Type \"yes\" to confirm or \"no\" to cancel."); return true; } - if (prompt.confirming() && !prompt.choosingSex()) { + if (prompt.confirming()) { if (!Names.confirms(text)) { - player.sendMessage("Escribe sí para confirmar o no para cancelar."); + PetFx.tell(player, "Type \"yes\" to confirm or \"no\" to cancel."); return true; } - prompt.confirming(false); - if (type.sexMode() == SexMode.CHOOSE) { - prompt.choosingSex(true); - player.sendMessage("Escribe macho o hembra."); - return true; - } - finishHatch(player, type, prompt.name(), runtime.random().nextBoolean() ? PetSex.MALE : PetSex.FEMALE); - return true; - } - if (prompt.choosingSex()) { - String line = text.trim().toLowerCase(Locale.ROOT); - PetSex sex; - if (line.equals("macho") || line.equals("male")) { - sex = PetSex.MALE; - } else if (line.equals("hembra") || line.equals("female")) { - sex = PetSex.FEMALE; - } else { - player.sendMessage("Escribe macho o hembra."); - return true; - } - finishHatch(player, type, prompt.name(), sex); + finishHatch(player, type, prompt.name(), prompt.sex()); } return true; } @@ -415,25 +448,26 @@ private void finishHatch(Player player, PetTypeDef type, String name, PetSex sex runtime.sessions().clearHatch(player.getUniqueId()); ItemStack hand = player.getInventory().getItemInMainHand(); if (hand.getType() != type.egg()) { - player.sendMessage("El huevo ya no está en tu mano."); + PetFx.tell(player, "The egg left your hand, so nothing hatched. Use it again to start over."); return; } if (!Quota.canBringOut(runtime.store().countOut(player.getUniqueId()), runtime.config().limits().maxOut())) { - player.sendMessage(PetTexts.refusal(name, sex, "full-out")); + PetFx.tell(player, PetTexts.refusal(name, sex, "full-out")); return; } if (!consumeHand(player, hand)) { return; } Pet pet = new Pet(UUID.randomUUID(), player.getUniqueId(), type.id(), name, sex); + pet.bornAt(System.currentTimeMillis()); FavoriteToy.Result favorite = FavoriteToy.reconcile(null, toyNames(type), runtime.random()); pet.favoriteToy(favorite.toy()); runtime.store().add(pet); - player.sendMessage("Ha nacido " + name + ". Es " + (sex == PetSex.FEMALE ? "hembra." : "macho.")); + PetFx.tell(player, name + " has hatched! Welcome to the family."); Entity entity = runtime.bodies().spawn(pet, type, PetRuntime.beside(player), player); if (entity == null) { pet.stored(true); - player.sendMessage(name + " no ha podido salir. Está en la caseta."); + PetFx.tell(player, "There was no room out here, so " + name + " is waiting for you in the kennel."); } else { pet.stored(false); runtime.remember(pet, entity); @@ -443,7 +477,7 @@ private void finishHatch(Player player, PetTypeDef type, String name, PetSex sex private void beginRename(Player player, Pet pet) { runtime.sessions().rename(player.getUniqueId(), new RenamePrompt(pet.id(), System.currentTimeMillis() + PROMPT_MILLIS)); - player.sendMessage("Escribe el nuevo nombre de " + pet.name() + "."); + PetFx.tell(player, "Type a new name for " + pet.name() + " in chat, or \"cancel\" to keep it."); } private boolean handleRenameChat(Player player, String text, long now) { @@ -456,27 +490,27 @@ private boolean handleRenameChat(Player player, String text, long now) { runtime.sessions().clearRename(player.getUniqueId()); return true; } + if (Names.cancels(text)) { + runtime.sessions().clearRename(player.getUniqueId()); + PetFx.tell(player, pet.name() + " keeps " + PetTexts.his(pet.sex()) + " name."); + return true; + } if (prompt.name() == null) { String name = Names.sanitize(text); if (name.isBlank()) { - player.sendMessage("El nombre está vacío."); + PetFx.tell(player, "That name is empty. Try another one."); return true; } prompt.name(name); prompt.confirming(true); - player.sendMessage("Confirma el nombre " + name + " escribiendo sí."); + PetFx.tell(player, "Rename " + pet.name() + " to " + name + "? Type \"yes\" to confirm or \"no\" to cancel."); return true; } if (!prompt.confirming()) { return true; } - if (Names.cancels(text)) { - runtime.sessions().clearRename(player.getUniqueId()); - player.sendMessage("Se mantiene el nombre " + pet.name() + "."); - return true; - } if (!Names.confirms(text)) { - player.sendMessage("Escribe sí para confirmar o no para cancelar."); + PetFx.tell(player, "Type \"yes\" to confirm or \"no\" to cancel."); return true; } pet.name(prompt.name()); @@ -485,7 +519,7 @@ private boolean handleRenameChat(Player player, String text, long now) { runtime.bodies().name(entity, pet.name()); } runtime.sessions().clearRename(player.getUniqueId()); - player.sendMessage("Ahora se llama " + pet.name() + "."); + PetFx.tell(player, "From now on, your pet answers to " + pet.name() + "."); return true; } @@ -495,13 +529,18 @@ private void handleTrainingChat(Player player, String text, long now) { if (pet == null || !pet.ownerId().equals(player.getUniqueId()) || pet.stored()) { return; } - String line = text.trim(); + String line = SpokenOrder.key(text); if (line.isEmpty()) { return; } TrainingSession session = runtime.sessions().training(player.getUniqueId()); + TrainingSettings training = runtime.config().training(); if (session != null && session.petId().equals(pet.id())) { - if (session.pendingWord() != null || session.bored()) { + if (session.pendingWord() != null) { + return; + } + if (session.bored()) { + PetFx.bar(player, pet.name() + " has stopped listening. Give " + PetTexts.him(pet.sex()) + " a last treat"); return; } Trick known = pet.trickFor(line); @@ -511,66 +550,149 @@ private void handleTrainingChat(Player player, String text, long now) { if (looked != null) { PetFx.particle(looked, Particle.END_ROD, 4); } - PetFx.bar(player, pet.name() + " inclina la cabeza"); + PetFx.bar(player, pet.name() + " tilts " + PetTexts.his(pet.sex()) + " head at “" + line + "”. Pick what it means"); return; } - if (pet.progress(known) >= runtime.config().training().learnedAt()) { + if (pet.progress(known) >= training.learnedAt()) { perform(player, pet, looked, known, false); + PetFx.bar(player, pet.name() + " already knows " + PetTexts.trickName(known) + ". No practice needed"); return; } if (!spendTrainingEffort(player, pet)) { return; } int attempts = session.addAttempt(); - TrainingMath.Attempt result = TrainingMath.attempt(pet.progress(known), runtime.random(), runtime.config().training()); + session.lastWord(line); + TrainingMath.Attempt result = TrainingMath.attempt(pet.progress(known), runtime.random(), training); perform(player, pet, looked, known, result != TrainingMath.Attempt.SUCCESS); session.reward( - now + Math.round(runtime.config().training().rewardWindowSeconds() * 1000.0), + now + Math.round(training.rewardWindowSeconds() * 1000.0), result == TrainingMath.Attempt.SUCCESS, known); - if (result == TrainingMath.Attempt.SUCCESS) { - player.sendMessage("Premia a " + pet.name() + "."); - } else if (result == TrainingMath.Attempt.PARTIAL) { - player.sendMessage(pet.name() + " lo intenta."); - } else { - player.sendMessage(pet.name() + " no entiende."); - } - if (attempts >= runtime.config().training().attemptsBeforeBored()) { + boolean last = attempts >= training.attemptsBeforeBored(); + if (last) { session.bored(true); - runtime.sessions().rest(pet.id(), now + Math.round(runtime.config().training().restSeconds() * 1000.0)); - PetFx.bar(player, pet.name() + " se aburre. Energía " + Math.round(pet.need(Need.ENERGY))); + runtime.sessions().rest(pet.id(), now + Math.round(training.restSeconds() * 1000.0)); + } + String encourage = last + ? "Last try before a break. A treat now still helps a little" + : "A treat now encourages " + PetTexts.him(pet.sex()) + " a little, or say “" + line + "” again"; + switch (result) { + case SUCCESS -> { + hologram(pet, "Nailed it! Quick, give a treat", NamedTextColor.GREEN, ATTEMPT_HOLOGRAM_TICKS); + PetFx.bar(player, "Reward " + PetTexts.him(pet.sex()) + " now! Right-click " + pet.name() + + " with " + treatName(pet) + (last ? " · last try before a break" : "")); + PetFx.cue(player, Sound.ENTITY_EXPERIENCE_ORB_PICKUP, 1.4f); + } + case PARTIAL -> { + hologram(pet, "Almost got it…", NamedTextColor.YELLOW, ATTEMPT_HOLOGRAM_TICKS); + PetFx.bar(player, encourage); + } + case FAIL -> { + hologram(pet, "Doesn't get it yet", NamedTextColor.GRAY, ATTEMPT_HOLOGRAM_TICKS); + PetFx.bar(player, encourage); + } } return; } Trick known = pet.trickFor(line); - if (known != null && pet.progress(known) >= runtime.config().training().learnedAt()) { + if (known == null) { + return; + } + if (pet.progress(known) >= training.learnedAt()) { perform(player, pet, looked, known, false); + return; } + if (runtime.sessions().resting(pet.id(), now)) { + PetFx.bar(player, pet.name() + " " + restPhrase(pet, now)); + return; + } + PetFx.bar(player, pet.name() + " isn't training right now. Hold " + treatName(pet) + + " and right-click " + PetTexts.him(pet.sex()) + " to start"); + } + + public void endTraining(Player player, Pet pet, String reason) { + runtime.sessions().clearTraining(player.getUniqueId()); + if (!player.isOnline()) { + return; + } + String name = pet == null ? "your pet" : pet.name(); + PetFx.tell(player, "Training with " + name + " is over: " + reason + "."); + PetFx.cue(player, Sound.BLOCK_NOTE_BLOCK_BASS, 0.7f); + } + + public void missedReward(Player player, Pet pet, TrainingSession session) { + boolean success = Boolean.TRUE.equals(session.rewardSuccess()); + session.clearReward(); + if (success && pet != null && player.isOnline()) { + PetFx.bar(player, "You missed the moment to reward " + pet.name() + ". Say “" + session.lastWord() + "” again"); + } + } + + private String restPhrase(Pet pet, long now) { + double total = runtime.config().training().restSeconds() * 1000.0; + double left = runtime.sessions().restUntil(pet.id()) - now; + if (total <= 0.0 || left <= total * 0.15) { + return "is almost ready to train again"; + } + if (left <= total * 0.5) { + return "is still resting after training"; + } + return "needs a good rest before training again"; + } + + public String treatName(Pet pet) { + PetTypeDef type = runtime.config().type(pet.typeId()); + return type == null || type.favoriteFood() == null ? "a treat" : PetTexts.itemName(type.favoriteFood().name()); } private void beginTraining(Player player, Pet pet, Entity entity) { TrainingSession existing = runtime.sessions().training(player.getUniqueId()); if (existing != null && existing.petId().equals(pet.id())) { + PetFx.bar(player, PetTexts.trainingPrompt(pet)); return; } - if (runtime.sessions().resting(pet.id(), System.currentTimeMillis())) { - PetFx.bar(player, PetTexts.refusal(pet.name(), pet.sex(), "bored")); + long now = System.currentTimeMillis(); + if (runtime.sessions().resting(pet.id(), now)) { + hologram(pet, "Resting…", NamedTextColor.GRAY, ATTEMPT_HOLOGRAM_TICKS); + PetFx.bar(player, pet.name() + " " + restPhrase(pet, now)); return; } + if (existing != null) { + endTraining(player, runtime.store().get(existing.petId()), "you started training another pet"); + } runtime.sessions().training(player.getUniqueId(), new TrainingSession(pet.id())); PetFx.look(entity, player.getEyeLocation()); if (entity instanceof Mob mob) { mob.getPathfinder().stopPathfinding(); } - PetFx.bar(player, "Di la orden mirando a " + pet.name() - + ". Energía " + Math.round(pet.need(Need.ENERGY))); + PetFx.bar(player, PetTexts.trainingPrompt(pet)); + PetFx.tell(player, "Training " + pet.name() + ". Look at " + PetTexts.him(pet.sex()) + " and say a command in chat." + + practiceList(pet)); + PetFx.cue(player, Sound.BLOCK_NOTE_BLOCK_CHIME, 1.2f); + if (hints.firstTime(player, "training")) { + PetFx.tip(player, "Any word works. If " + pet.name() + " doesn't know it yet, you'll pick which trick it means. " + + "Keep holding the " + treatName(pet) + " and stay close, or the session ends."); + } + } + + private String practiceList(Pet pet) { + TrainingSettings training = runtime.config().training(); + java.util.List words = new java.util.ArrayList<>(); + for (java.util.Map.Entry entry : pet.words().entrySet()) { + double progress = pet.progress(entry.getValue()); + if (progress < training.learnedAt()) { + words.add("“" + entry.getKey() + "”"); + } + } + return words.isEmpty() ? "" : " Still practicing: " + String.join(", ", words) + "."; } private boolean spendTrainingEffort(Player player, Pet pet) { if (TrainingMath.attentionBlocked(pet.need(Need.HUNGER), pet.need(Need.ENERGY), sick(pet))) { - runtime.sessions().clearTraining(player.getUniqueId()); String reason = pet.need(Need.ENERGY) < 25.0 ? "tired" : "attention"; - PetFx.bar(player, PetTexts.refusal(pet.name(), pet.sex(), reason)); + hologram(pet, PetTexts.refusal(pet.name(), pet.sex(), reason), NamedTextColor.RED, ATTEMPT_HOLOGRAM_TICKS); + endTraining(player, pet, focusReason(pet)); return false; } TrainingSettings training = runtime.config().training(); @@ -586,17 +708,12 @@ private void reward(Player player, Pet pet, TrainingSession session, ItemStack h return; } boolean success = Boolean.TRUE.equals(session.rewardSuccess()); - pet.progress(trick, TrainingMath.afterReward( - pet.progress(trick), - success, - pet.need(Need.MOOD), - pet.bond(), - runtime.config().training())); + TrainingSettings training = runtime.config().training(); + double before = pet.progress(trick); + double after = TrainingMath.afterReward(before, success, pet.need(Need.MOOD), pet.bond(), training); + pet.progress(trick, after); pet.need(Need.HUNGER, pet.need(Need.HUNGER) + runtime.config().training().treatHungerGain()); session.clearReward(); - if (session.bored()) { - runtime.sessions().clearTraining(player.getUniqueId()); - } Entity entity = runtime.entity(pet); if (entity != null) { PetFx.eat(entity); @@ -604,8 +721,65 @@ private void reward(Player player, Pet pet, TrainingSession session, ItemStack h PetFx.hearts(entity, 3); } } - PetFx.bar(player, success ? pet.name() + " aprende" : "Eso casi no ayuda"); comfort(pet, System.currentTimeMillis()); + showProgress(player, pet, trick, session.lastWord(), before, after, success, training); + if (session.bored()) { + endForRest(player, pet); + } + } + + public void endForRest(Player player, Pet pet) { + endTraining(player, pet, PetTexts.he(pet.sex()) + " needs a break. Let " + PetTexts.him(pet.sex()) + + " rest for a while before training again"); + if (player.isOnline() && hints.firstTime(player, "rest")) { + PetFx.tip(player, "Pets can only focus for a few tries at a time. Progress is kept, so come back after the break."); + } + } + + private void showProgress(Player player, Pet pet, Trick trick, String word, double before, double after, boolean success, + TrainingSettings training) { + String name = PetTexts.trickName(trick); + String say = "“" + (word == null ? name.toLowerCase(Locale.ROOT) : word) + "”"; + if (before < training.learnedAt() && after >= training.learnedAt()) { + hologram(pet, "✦ Learned " + name + "! ✦", NamedTextColor.GOLD, LEARNED_HOLOGRAM_TICKS); + Entity entity = runtime.entity(pet); + if (entity != null) { + PetFx.particle(entity, Particle.TOTEM_OF_UNDYING, 20); + } + PetFx.tell(player, pet.name() + " has learned " + name + "! Say " + say + + " any time and " + PetTexts.he(pet.sex()) + "'ll do it, no treats needed."); + PetFx.cue(player, Sound.UI_TOAST_CHALLENGE_COMPLETE, 1.0f); + return; + } + Component progress = StatLook.bar(TrainingMath.percentLearned(after, training), NamedTextColor.AQUA); + if (before < training.sometimesAt() && after >= training.sometimesAt()) { + holograms.show(runtime.entity(pet), Component.text("Catching on to " + name + " ", NamedTextColor.AQUA).append(progress), + REWARD_HOLOGRAM_TICKS); + PetFx.tell(player, pet.name() + " is catching on to " + name + "! From now on " + PetTexts.he(pet.sex()) + + " gets it right more often, and every success you reward counts a lot more."); + PetFx.cue(player, Sound.ENTITY_PLAYER_LEVELUP, 1.4f); + return; + } + holograms.show(runtime.entity(pet), + Component.text((success ? "Good job! " : "A little closer ") + name + " ", + success ? NamedTextColor.GREEN : NamedTextColor.YELLOW).append(progress), + REWARD_HOLOGRAM_TICKS); + PetFx.bar(player, Component.text(name + " ", NamedTextColor.WHITE).append(progress) + .append(Component.text(" Say " + say + " again to keep practicing", NamedTextColor.GRAY))); + } + + private String focusReason(Pet pet) { + if (sick(pet)) { + return PetTexts.he(pet.sex()) + " is too unwell to focus"; + } + if (pet.need(Need.ENERGY) < 25.0) { + return PetTexts.he(pet.sex()) + " is too tired to focus. Let " + PetTexts.him(pet.sex()) + " rest"; + } + return PetTexts.he(pet.sex()) + " is too hungry to focus. Feed " + PetTexts.him(pet.sex()) + " first"; + } + + private void hologram(Pet pet, String text, NamedTextColor color, long ticks) { + holograms.show(runtime.entity(pet), Component.text(text, color), ticks); } private void perform(Player player, Pet pet, Entity entity, Trick trick, boolean partial) { @@ -647,8 +821,10 @@ private void perform(Player player, Pet pet, Entity entity, Trick trick, boolean } } case BEG -> { + pet.forcedSitUntilMillis(now + (partial ? 800L : 2_000L)); if (entity != null) { PetFx.beg(entity); + Bukkit.getScheduler().runTaskLater(runtime.plugin(), () -> PetFx.stopBeg(entity), partial ? 16L : 40L); } } case PAW -> { @@ -682,51 +858,6 @@ public void run() { }.runTaskTimer(runtime.plugin(), 0L, 2L); } - private void startPlay(Player player, Pet pet) { - if (sick(pet)) { - PetFx.bar(player, PetTexts.refusal(pet.name(), pet.sex(), "sick")); - return; - } - if (pet.need(Need.ENERGY) < 25.0) { - PetFx.bar(player, PetTexts.refusal(pet.name(), pet.sex(), "tired")); - return; - } - releaseFetch(pet, player, true); - pet.activity(Activity.PLAYING); - pet.playUntilMillis(System.currentTimeMillis() + Math.round(runtime.config().care().playSeconds() * 1000.0)); - PetFx.bar(player, pet.name() + " juega"); - } - - private void toggleSleep(Player player, Pet pet) { - if (pet.activity() == Activity.SLEEPING) { - wake(pet, pet.need(Need.ENERGY) < 25.0); - PetFx.bar(player, pet.name() + " se despierta"); - return; - } - if (pet.need(Need.ENERGY) >= 60.0 && pet.illness() != Illness.WEAKENED) { - PetFx.bar(player, PetTexts.refusal(pet.name(), pet.sex(), "sleepy")); - return; - } - releaseFetch(pet, player, true); - pet.activity(Activity.SLEEPING); - PetFx.bar(player, pet.name() + " se echa"); - } - - private void toggleSit(Player player, Pet pet) { - if (pet.activity() == Activity.SLEEPING) { - wake(pet, pet.need(Need.ENERGY) < 25.0); - } - if (pet.order() == PetOrder.SIT) { - pet.order(PetOrder.FOLLOW); - pet.staying(false); - PetFx.bar(player, pet.name() + " te sigue"); - } else { - pet.order(PetOrder.SIT); - pet.staying(false); - PetFx.bar(player, pet.name() + " se sienta"); - } - } - private void wake(Pet pet, boolean penalize) { pet.activity(Activity.NONE); if (penalize) { @@ -749,65 +880,11 @@ private void feed(Player player, Pet pet, Entity entity, ItemStack hand, double } } - private void feedFromInventory(Player player, Pet pet, Entity entity) { - PetTypeDef type = runtime.config().type(pet.typeId()); - if (type == null) { - return; - } - ItemStack chosen = null; - int slot = -1; - for (int index = 0; index < player.getInventory().getSize(); index++) { - ItemStack stack = player.getInventory().getItem(index); - if (stack != null && type.foodGain(stack.getType()) != null) { - chosen = stack; - slot = index; - if (stack.getType() == type.favoriteFood()) { - break; - } - } - } - if (chosen == null) { - PetFx.bar(player, PetTexts.refusal(pet.name(), pet.sex(), "food")); - return; - } - feed(player, pet, entity, chosen, type.foodGain(chosen.getType()), chosen.getType() == type.favoriteFood()); - if (slot >= 0 && chosen.getAmount() <= 0) { - player.getInventory().setItem(slot, null); - } - } - - private void healFromInventory(Player player, Pet pet, Entity entity) { - PetTypeDef type = runtime.config().type(pet.typeId()); - if (type == null) { - return; - } - if (pet.illness() != Illness.SICK && pet.illness() != Illness.WEAKENED) { - PetFx.bar(player, PetTexts.refusal(pet.name(), pet.sex(), "medicine")); - return; - } - int slot = player.getInventory().first(type.medicine()); - if (slot < 0) { - PetFx.bar(player, "No tienes la medicina"); - return; - } - ItemStack stack = player.getInventory().getItem(slot); - if (!consumeHand(player, stack)) { - return; - } - pet.treated(true); - pet.need(Need.HEALTH, pet.need(Need.HEALTH) + runtime.config().care().medicineHealthBump()); - comfort(pet, System.currentTimeMillis()); - if (entity != null) { - PetFx.hearts(entity, 3); - } - PetFx.bar(player, pet.name() + " empieza a recuperarse"); - } - private void throwToy(Player player, ItemStack hand) { Material material = hand.getType(); Pet pet = nearestToyPet(player, material); if (pet == null) { - PetFx.bar(player, "No hay una mascota contigo que juegue con eso"); + PetFx.bar(player, "None of your pets nearby wants to play with that"); return; } if (sick(pet) || pet.illness() == Illness.WEAKENED || pet.need(Need.ENERGY) < 25.0) { @@ -946,7 +1023,7 @@ private void takeOut(Player player, Pet pet) { PetTypeDef type = runtime.config().type(pet.typeId()); Entity entity = runtime.bodies().spawn(pet, type, PetRuntime.beside(player), player); if (entity == null) { - PetFx.bar(player, "No he podido sacar a " + pet.name()); + PetFx.bar(player, "There's no room here for " + pet.name() + " to come out"); return; } pet.stored(false); @@ -993,18 +1070,19 @@ private void call(Player player, Pet pet) { entity = Bukkit.getEntity(pet.entityId()); } if (entity == null || !entity.isValid() || entity.isDead()) { - PetFx.bar(player, "No encuentro a " + pet.name() + ". Guárdala para recuperarla."); + PetFx.bar(player, pet.name() + " can't be found. Send " + PetTexts.him(pet.sex()) + " to the kennel to bring " + + PetTexts.him(pet.sex()) + " back"); return; } entity.teleport(PetRuntime.beside(player)); runtime.remember(pet, entity); - PetFx.bar(player, pet.name() + " viene"); + PetFx.bar(player, pet.name() + " comes running to your side"); } private void placeKennel(Player player, ItemStack hand, Block clicked, BlockFace face) { Block place = clicked.getRelative(face); if (!place.getType().isAir() && !place.isReplaceable()) { - PetFx.bar(player, "Ahí no cabe la caseta"); + PetFx.bar(player, "There isn't enough room for a kennel there"); return; } if (!consumeHand(player, hand)) { @@ -1012,7 +1090,7 @@ private void placeKennel(Player player, ItemStack hand, Block clicked, BlockFace } place.setType(runtime.config().kennel()); runtime.store().kennel(PetStore.kennelKey(place.getWorld().getName(), place.getX(), place.getY(), place.getZ()), player.getUniqueId()); - PetFx.bar(player, "Has colocado la caseta"); + PetFx.bar(player, "Kennel built. Right-click it to look after your pets"); } private boolean isKennel(Block block) { @@ -1096,6 +1174,7 @@ public void clearPendingWord(Player player, String word) { Pet pet = runtime.store().get(session.petId()); if (pet == null || !pet.knowsWord(word)) { session.pendingWord(null); + PetFx.bar(player, "“" + word + "” wasn't linked to a trick. Say it again to choose one"); } } } diff --git a/src/main/java/net/tfminecraft/companionpets/runtime/PetTicker.java b/src/main/java/net/tfminecraft/companionpets/runtime/PetTicker.java index 7356bf3..8b6c5c0 100644 --- a/src/main/java/net/tfminecraft/companionpets/runtime/PetTicker.java +++ b/src/main/java/net/tfminecraft/companionpets/runtime/PetTicker.java @@ -13,6 +13,9 @@ import org.bukkit.entity.Mob; import org.bukkit.entity.Player; +import net.kyori.adventure.text.Component; +import net.kyori.adventure.text.format.NamedTextColor; + import net.tfminecraft.companionpets.behavior.Locomotion; import net.tfminecraft.companionpets.care.CareInput; import net.tfminecraft.companionpets.care.CareNotice; @@ -24,13 +27,13 @@ import net.tfminecraft.companionpets.pet.Activity; import net.tfminecraft.companionpets.pet.Illness; import net.tfminecraft.companionpets.pet.Need; -import net.tfminecraft.companionpets.pet.NeedBand; import net.tfminecraft.companionpets.pet.Pet; import net.tfminecraft.companionpets.pet.Presence; import net.tfminecraft.companionpets.play.FavoriteToy; import net.tfminecraft.companionpets.play.FetchJob; import net.tfminecraft.companionpets.play.FetchPhase; import net.tfminecraft.companionpets.session.TrainingSession; +import net.tfminecraft.companionpets.gui.StatLook; import net.tfminecraft.companionpets.text.PetTexts; public final class PetTicker implements Runnable { @@ -103,7 +106,7 @@ private void care(long now, long elapsed) { } runtime.store().remove(pet.id()); if (online) { - owner.sendMessage(pet.name() + " no ha podido seguir."); + PetFx.tell(owner, pet.name() + " grew too weak without care and has passed away."); } continue; } @@ -129,8 +132,9 @@ private void care(long now, long elapsed) { if ((before == null && favorite.toy() != null) || (before != null && !before.equals(favorite.toy()))) { pet.favoriteToy(favorite.toy()); if (favorite.notifyLost() && online) { - String next = favorite.toy() == null ? "ninguno" : favorite.toy().toLowerCase(); - owner.sendMessage(pet.name() + " cambia de juguete favorito. Ahora prefiere " + next + "."); + PetFx.tell(owner, favorite.toy() == null + ? pet.name() + " has lost interest in " + PetTexts.his(pet.sex()) + " favorite toy." + : pet.name() + " has a new favorite toy: the " + PetTexts.itemName(favorite.toy()) + "."); } } if (pet.carriedToy() != null && withOwner && body != null && owner != null) { @@ -149,7 +153,7 @@ private void care(long now, long elapsed) { if (pet.activity() == Activity.SLEEPING && pet.need(Need.ENERGY) >= 100.0) { pet.activity(Activity.NONE); if (online) { - PetFx.bar(owner, pet.name() + " se despierta"); + PetFx.bar(owner, pet.name() + " wakes up, fully rested"); } } } @@ -378,13 +382,19 @@ private void watchTraining(long now) { boolean close = body != null && body.getWorld().equals(player.getWorld()) && body.getLocation().distance(player.getLocation()) <= runtime.config().training().sessionDistance(); - if (pet == null || pet.stored() || !holding || !close) { - runtime.sessions().clearTraining(player.getUniqueId()); - if (player.isOnline()) { - PetFx.bar(player, "La sesión de entrenamiento termina"); - } + if (pet == null || pet.stored()) { + actions.endTraining(player, pet, "your pet went back to the kennel"); + } else if (!holding) { + String treatName = actions.treatName(pet); + actions.endTraining(player, pet, player.getInventory().contains(treat) + ? "you put the " + treatName + " away" + : "you ran out of " + treatName); + } else if (!close) { + actions.endTraining(player, pet, "you walked too far from " + PetTexts.him(pet.sex())); } else if (session.bored() && now > session.rewardUntil()) { - runtime.sessions().clearTraining(player.getUniqueId()); + actions.endForRest(player, pet); + } else if (session.rewardTrick() != null && now > session.rewardUntil()) { + actions.missedReward(player, pet, session); } } } @@ -397,23 +407,12 @@ private void lookBars() { continue; } TrainingSession session = runtime.sessions().training(player.getUniqueId()); - if (session != null && session.petId().equals(pet.id())) { - if (session.pendingWord() == null && session.rewardTrick() == null) { - PetFx.status(player, "Di la orden mirando a " + pet.name() - + ". Energía " + Math.round(pet.need(Need.ENERGY))); - } - continue; - } - if (pet.illness() != Illness.NONE) { - PetFx.status(player, PetTexts.illness(pet.name(), pet.sex(), pet.illness())); + boolean training = session != null && session.petId().equals(pet.id()); + if (training && (session.pendingWord() != null || session.rewardTrick() != null)) { continue; } - Need dominant = DominantNeed.select(pet); - if (dominant != null && NeedBand.of(pet.need(dominant)) == NeedBand.CRITICAL) { - PetFx.status(player, PetTexts.lowNeed(pet.name(), pet.sex(), dominant)); - } else { - PetFx.status(player, PetTexts.summary(pet)); - } + Component tag = training ? StatLook.tag("Training · say a command", NamedTextColor.AQUA) : null; + PetFx.status(player, StatLook.summary(pet, tag)); } } diff --git a/src/main/java/net/tfminecraft/companionpets/session/HatchPrompt.java b/src/main/java/net/tfminecraft/companionpets/session/HatchPrompt.java index 6cdc842..a1b5000 100644 --- a/src/main/java/net/tfminecraft/companionpets/session/HatchPrompt.java +++ b/src/main/java/net/tfminecraft/companionpets/session/HatchPrompt.java @@ -1,11 +1,13 @@ package net.tfminecraft.companionpets.session; +import net.tfminecraft.companionpets.pet.PetSex; + public final class HatchPrompt { private final String typeId; private final long expiresAt; + private PetSex sex; private String name; private boolean confirming; - private boolean choosingSex; public HatchPrompt(String typeId, long expiresAt) { this.typeId = typeId; @@ -20,6 +22,14 @@ public long expiresAt() { return expiresAt; } + public PetSex sex() { + return sex; + } + + public void sex(PetSex sex) { + this.sex = sex; + } + public String name() { return name; } @@ -35,12 +45,4 @@ public boolean confirming() { public void confirming(boolean confirming) { this.confirming = confirming; } - - public boolean choosingSex() { - return choosingSex; - } - - public void choosingSex(boolean choosingSex) { - this.choosingSex = choosingSex; - } } diff --git a/src/main/java/net/tfminecraft/companionpets/session/PlayerHints.java b/src/main/java/net/tfminecraft/companionpets/session/PlayerHints.java new file mode 100644 index 0000000..03f56c8 --- /dev/null +++ b/src/main/java/net/tfminecraft/companionpets/session/PlayerHints.java @@ -0,0 +1,27 @@ +package net.tfminecraft.companionpets.session; + +import java.util.Arrays; + +import org.bukkit.NamespacedKey; +import org.bukkit.entity.Player; +import org.bukkit.persistence.PersistentDataContainer; +import org.bukkit.persistence.PersistentDataType; +import org.bukkit.plugin.Plugin; + +public final class PlayerHints { + private final NamespacedKey key; + + public PlayerHints(Plugin plugin) { + this.key = new NamespacedKey(plugin, "hints"); + } + + public boolean firstTime(Player player, String hint) { + PersistentDataContainer data = player.getPersistentDataContainer(); + String seen = data.getOrDefault(key, PersistentDataType.STRING, ""); + if (!seen.isEmpty() && Arrays.asList(seen.split(",")).contains(hint)) { + return false; + } + data.set(key, PersistentDataType.STRING, seen.isEmpty() ? hint : seen + "," + hint); + return true; + } +} diff --git a/src/main/java/net/tfminecraft/companionpets/session/Sessions.java b/src/main/java/net/tfminecraft/companionpets/session/Sessions.java index 286baf1..d9eac48 100644 --- a/src/main/java/net/tfminecraft/companionpets/session/Sessions.java +++ b/src/main/java/net/tfminecraft/companionpets/session/Sessions.java @@ -58,6 +58,10 @@ public boolean resting(UUID petId, long now) { return true; } + public long restUntil(UUID petId) { + return trainingRest.getOrDefault(petId, 0L); + } + public void rest(UUID petId, long until) { trainingRest.put(petId, until); } diff --git a/src/main/java/net/tfminecraft/companionpets/session/TrainingSession.java b/src/main/java/net/tfminecraft/companionpets/session/TrainingSession.java index 781b748..73d7452 100644 --- a/src/main/java/net/tfminecraft/companionpets/session/TrainingSession.java +++ b/src/main/java/net/tfminecraft/companionpets/session/TrainingSession.java @@ -12,11 +12,20 @@ public final class TrainingSession { private String pendingWord; private Trick rewardTrick; private boolean bored; + private String lastWord; public TrainingSession(UUID petId) { this.petId = petId; } + public String lastWord() { + return lastWord; + } + + public void lastWord(String lastWord) { + this.lastWord = lastWord; + } + public UUID petId() { return petId; } diff --git a/src/main/java/net/tfminecraft/companionpets/store/PetStore.java b/src/main/java/net/tfminecraft/companionpets/store/PetStore.java index 0905464..29e4804 100644 --- a/src/main/java/net/tfminecraft/companionpets/store/PetStore.java +++ b/src/main/java/net/tfminecraft/companionpets/store/PetStore.java @@ -98,6 +98,7 @@ public void save() { yaml.set(path + ".type", pet.typeId()); yaml.set(path + ".name", pet.name()); yaml.set(path + ".sex", pet.sex().name()); + yaml.set(path + ".born-at", pet.bornAt()); yaml.set(path + ".order", pet.order().name()); yaml.set(path + ".staying", pet.staying()); yaml.set(path + ".stored", pet.stored()); @@ -170,6 +171,7 @@ private static Pet readPet(UUID id, ConfigurationSection section) { section.getString("type"), section.getString("name", "Mascota"), enumValue(PetSex.class, section.getString("sex"), PetSex.FEMALE)); + pet.bornAt(section.contains("born-at") ? section.getLong("born-at") : System.currentTimeMillis()); pet.order(enumValue(PetOrder.class, section.getString("order"), PetOrder.FOLLOW)); pet.staying(section.getBoolean("staying")); pet.stored(section.getBoolean("stored")); diff --git a/src/main/java/net/tfminecraft/companionpets/text/Names.java b/src/main/java/net/tfminecraft/companionpets/text/Names.java index a1d1c57..5620053 100644 --- a/src/main/java/net/tfminecraft/companionpets/text/Names.java +++ b/src/main/java/net/tfminecraft/companionpets/text/Names.java @@ -1,8 +1,16 @@ package net.tfminecraft.companionpets.text; -import java.util.Locale; +import java.util.Set; + +import net.tfminecraft.companionpets.chat.SpokenOrder; +import net.tfminecraft.companionpets.pet.PetSex; public final class Names { + private static final Set YES = Set.of("yes", "y", "yeah", "yep", "sure", "ok", "okay", "confirm"); + private static final Set NO = Set.of("no", "nope", "cancel"); + private static final Set MALE = Set.of("male", "boy", "m"); + private static final Set FEMALE = Set.of("female", "girl", "f"); + private Names() { } @@ -18,12 +26,21 @@ public static String sanitize(String raw) { } public static boolean confirms(String text) { - String line = text == null ? "" : text.trim().toLowerCase(Locale.ROOT); - return line.equals("si") || line.equals("sí") || line.equals("yes") || line.equals("vale"); + return YES.contains(SpokenOrder.key(text)); } public static boolean cancels(String text) { - String line = text == null ? "" : text.trim().toLowerCase(Locale.ROOT); - return line.equals("no") || line.equals("cancelar"); + return NO.contains(SpokenOrder.key(text)); + } + + public static PetSex sex(String text) { + String key = SpokenOrder.key(text); + if (MALE.contains(key)) { + return PetSex.MALE; + } + if (FEMALE.contains(key)) { + return PetSex.FEMALE; + } + return null; } } diff --git a/src/main/java/net/tfminecraft/companionpets/text/PetTexts.java b/src/main/java/net/tfminecraft/companionpets/text/PetTexts.java index 35589e5..ebe2ac6 100644 --- a/src/main/java/net/tfminecraft/companionpets/text/PetTexts.java +++ b/src/main/java/net/tfminecraft/companionpets/text/PetTexts.java @@ -1,5 +1,7 @@ package net.tfminecraft.companionpets.text; +import java.util.Locale; + import net.tfminecraft.companionpets.pet.Illness; import net.tfminecraft.companionpets.pet.Need; import net.tfminecraft.companionpets.pet.Pet; @@ -10,71 +12,191 @@ public final class PetTexts { private PetTexts() { } + public static String he(PetSex sex) { + return sex == PetSex.FEMALE ? "she" : "he"; + } + + public static String He(PetSex sex) { + return sex == PetSex.FEMALE ? "She" : "He"; + } + + public static String him(PetSex sex) { + return sex == PetSex.FEMALE ? "her" : "him"; + } + + public static String his(PetSex sex) { + return sex == PetSex.FEMALE ? "her" : "his"; + } + + public static String sexName(PetSex sex) { + return sex == PetSex.FEMALE ? "Female" : "Male"; + } + + public static String speciesName(String typeId) { + if (typeId == null || typeId.isBlank()) { + return "Pet"; + } + String clean = typeId.replace('_', ' ').replace('-', ' ').trim(); + return clean.substring(0, 1).toUpperCase(Locale.ROOT) + clean.substring(1).toLowerCase(Locale.ROOT); + } + + public static String itemName(String material) { + if (material == null || material.isBlank()) { + return "nothing"; + } + return material.toLowerCase(Locale.ROOT).replace('_', ' '); + } + + public static String needName(Need need) { + return switch (need) { + case HUNGER -> "Hunger"; + case MOOD -> "Mood"; + case ENERGY -> "Energy"; + case CLEANLINESS -> "Cleanliness"; + case HEALTH -> "Health"; + }; + } + public static String lowNeed(String name, PetSex sex, Need need) { return switch (need) { - case HUNGER -> name + " tiene hambre"; - case MOOD -> name + " quiere atención"; - case ENERGY -> name + (sex == PetSex.FEMALE ? " está cansada" : " está cansado"); - case CLEANLINESS -> name + (sex == PetSex.FEMALE ? " está sucia" : " está sucio"); - case HEALTH -> name + (sex == PetSex.FEMALE ? " está enferma" : " está enfermo"); + case HUNGER -> name + "'s stomach is rumbling"; + case MOOD -> name + " is craving some attention"; + case ENERGY -> name + " is worn out and needs a rest"; + case CLEANLINESS -> name + " could really use a good brushing"; + case HEALTH -> name + " isn't looking well"; }; } public static String illness(String name, PetSex sex, Illness illness) { return switch (illness) { - case UNWELL -> name + " se encuentra mal"; - case SICK -> name + (sex == PetSex.FEMALE ? " está enferma" : " está enfermo"); - case WEAKENED -> name + (sex == PetSex.FEMALE ? " está débil" : " está débil"); + case UNWELL -> name + " seems a little under the weather"; + case SICK -> name + " is sick and needs medicine"; + case WEAKENED -> name + " is too weak to stand"; case NONE -> name; }; } - public static String summary(Pet pet) { - return "Hambre " + Math.round(pet.need(Need.HUNGER)) - + " Ánimo " + Math.round(pet.need(Need.MOOD)) - + " Energía " + Math.round(pet.need(Need.ENERGY)) - + " Limpieza " + Math.round(pet.need(Need.CLEANLINESS)) - + " Salud " + Math.round(pet.need(Need.HEALTH)); + public static String petted(String name, PetSex sex, String typeId, boolean devoted) { + String reaction = switch (typeId == null ? "" : typeId.toLowerCase(Locale.ROOT)) { + case "wolf" -> devoted ? " rolls over for belly rubs, tail wagging wildly" : " wags " + his(sex) + " tail happily"; + case "cat" -> devoted ? " purrs loudly and headbutts your hand" : " purrs and rubs against your hand"; + case "fox" -> devoted ? " yips with joy and nuzzles into your hand" : " nuzzles your hand with a happy chirp"; + default -> " leans happily into your hand"; + }; + return "You pet " + name + ". " + He(sex) + reaction; + } + + public static String illnessCheck(String name, PetSex sex, Illness illness, String medicine) { + return switch (illness) { + case UNWELL -> name + " feels warm and sluggish. Look after " + his(sex) + " needs and " + he(sex) + "'ll perk up"; + case SICK -> name + " whimpers weakly. " + He(sex) + " is sick and needs " + medicine; + case WEAKENED -> name + " is too weak to stand. " + He(sex) + " needs " + medicine + " and care right away"; + case NONE -> name + " seems fine"; + }; + } + + public static String needCheck(String name, PetSex sex, Need need, boolean critical) { + return switch (need) { + case HUNGER -> critical + ? name + "'s stomach growls loudly. " + He(sex) + " is starving" + : name + " sniffs your empty hand, hoping for food"; + case MOOD -> critical + ? name + " looks miserable. " + He(sex) + " needs some fun and attention" + : name + " barely reacts. " + He(sex) + " is bored and wants to play"; + case ENERGY -> critical + ? name + " is exhausted and can barely stand" + : name + " yawns and can barely keep " + his(sex) + " eyes open"; + case CLEANLINESS -> critical + ? name + " is filthy and itchy. " + He(sex) + " badly needs a brush" + : name + "'s coat is getting matted. A brush would help"; + case HEALTH -> name + " doesn't look well"; + }; + } + + public static String restingCheck(String name, PetSex sex) { + return name + " leans into your hand, worn out from training. Let " + him(sex) + " rest a while"; + } + + public static String trainingPrompt(Pet pet) { + return "Look at " + pet.name() + " and say a command"; + } + + public static String age(long bornAtMillis, long now) { + if (bornAtMillis <= 0L || now < bornAtMillis) { + return "Unknown"; + } + long minutes = (now - bornAtMillis) / 60_000L; + if (minutes < 1L) { + return "Newborn"; + } + if (minutes < 60L) { + return count(minutes, "minute"); + } + long hours = minutes / 60L; + if (hours < 24L) { + long rest = minutes % 60L; + return rest == 0L ? count(hours, "hour") : count(hours, "hour") + " and " + count(rest, "minute"); + } + long days = hours / 24L; + long restHours = hours % 24L; + return restHours == 0L ? count(days, "day") : count(days, "day") + " and " + count(restHours, "hour"); + } + + private static String count(long amount, String unit) { + return amount + " " + unit + (amount == 1L ? "" : "s"); } public static String trickName(Trick trick) { return switch (trick) { - case SIT -> "Sentarse"; - case COME -> "Venir"; - case STAY -> "Quieto"; - case SPEAK -> "Hablar"; - case JUMP -> "Saltar"; - case SPIN -> "Girar"; - case BEG -> "Pedir"; - case PAW -> "Dar la pata"; + case SIT -> "Sit"; + case COME -> "Come"; + case STAY -> "Stay"; + case SPEAK -> "Speak"; + case JUMP -> "Jump"; + case SPIN -> "Spin"; + case BEG -> "Beg"; + case PAW -> "Shake Paw"; + }; + } + + public static String trickDescription(Trick trick) { + return switch (trick) { + case SIT -> "Sits down and waits for you"; + case COME -> "Runs back to your side"; + case STAY -> "Stays put until you call"; + case SPEAK -> "Barks, meows or yips on cue"; + case JUMP -> "Leaps up into the air"; + case SPIN -> "Twirls around in a circle"; + case BEG -> "Sits up and begs for a treat"; + case PAW -> "Offers you a paw to shake"; }; } public static String reaction(String name, PetSex sex, Trick trick) { return switch (trick) { - case SIT -> name + " se sienta"; - case COME -> name + " viene"; - case STAY -> name + (sex == PetSex.FEMALE ? " se queda quieta" : " se queda quieto"); - case SPEAK -> name + " habla"; - case JUMP -> name + " salta"; - case SPIN -> name + " gira"; - case BEG -> name + " pide"; - case PAW -> name + " da la pata"; + case SIT -> name + " sits down and looks up at you"; + case COME -> name + " comes bounding over"; + case STAY -> name + " stays put, watching you closely"; + case SPEAK -> name + " speaks up proudly"; + case JUMP -> name + " leaps into the air"; + case SPIN -> name + " chases " + his(sex) + " own tail in a circle"; + case BEG -> name + " sits up and begs"; + case PAW -> name + " offers you a paw"; }; } public static String refusal(String name, PetSex sex, String reason) { return switch (reason) { - case "sick" -> name + (sex == PetSex.FEMALE ? " está enferma y no quiere jugar" : " está enfermo y no quiere jugar"); - case "tired" -> name + (sex == PetSex.FEMALE ? " está demasiado cansada" : " está demasiado cansado"); - case "sleepy" -> name + " no tiene sueño"; - case "food" -> "Eso no se lo come"; - case "medicine" -> name + (sex == PetSex.FEMALE ? " no está enferma" : " no está enfermo"); - case "attention" -> name + " no presta atención"; - case "owner" -> "Solo su dueño puede hacer eso"; - case "full-out" -> "El cupo de mascotas fuera está lleno"; - case "full-stored" -> "La caseta está llena"; - case "bored" -> name + " se aburre"; + case "sick" -> name + " is feeling too poorly to play"; + case "tired" -> name + " is too tired to keep going"; + case "sleepy" -> name + " isn't sleepy right now"; + case "food" -> name + " turns up " + his(sex) + " nose at that"; + case "medicine" -> name + " isn't sick and doesn't need medicine"; + case "attention" -> name + " is too distracted to listen"; + case "owner" -> "Only " + his(sex) + " owner can do that"; + case "full-out" -> "You already have as many pets out as you can look after"; + case "full-stored" -> "Your kennel has no room left"; + case "bored" -> name + " has had enough training for now"; default -> reason; }; } diff --git a/src/main/java/net/tfminecraft/companionpets/training/TrainingMath.java b/src/main/java/net/tfminecraft/companionpets/training/TrainingMath.java index 030f9a3..4c9f6e0 100644 --- a/src/main/java/net/tfminecraft/companionpets/training/TrainingMath.java +++ b/src/main/java/net/tfminecraft/companionpets/training/TrainingMath.java @@ -43,6 +43,13 @@ public static double afterReward(double progress, boolean success, double mood, return Math.min(100.0, progress + gain); } + public static int percentLearned(double progress, TrainingSettings settings) { + if (settings.learnedAt() <= 0.0) { + return 100; + } + return (int) Math.min(100L, Math.max(0L, Math.round(progress / settings.learnedAt() * 100.0))); + } + public static boolean attentionBlocked(double hunger, double energy, boolean sickOrWeak) { return hunger < 25.0 || energy < 25.0 || sickOrWeak; } diff --git a/src/test/java/net/tfminecraft/companionpets/behavior/LocomotionAndOrdersTest.java b/src/test/java/net/tfminecraft/companionpets/behavior/LocomotionAndOrdersTest.java index e96fa56..84bd098 100644 --- a/src/test/java/net/tfminecraft/companionpets/behavior/LocomotionAndOrdersTest.java +++ b/src/test/java/net/tfminecraft/companionpets/behavior/LocomotionAndOrdersTest.java @@ -36,9 +36,10 @@ void highBondFollowsCloserAndSicknessSlowsTheWalk() { @Test void aChatLineIsTheWholeOrder() { assertTrue(SpokenOrder.matches("sit", "sit")); - assertTrue(SpokenOrder.matches(" Siéntate ", "siéntate")); + assertTrue(SpokenOrder.matches(" SIT! ", "sit")); + assertTrue(SpokenOrder.matches("Roll over.", "roll over")); assertFalse(SpokenOrder.matches("please sit", "sit")); - assertEquals("Luna tiene hambre", PetTexts.lowNeed("Luna", PetSex.FEMALE, net.tfminecraft.companionpets.pet.Need.HUNGER)); + assertEquals("Luna's stomach is rumbling", PetTexts.lowNeed("Luna", PetSex.FEMALE, net.tfminecraft.companionpets.pet.Need.HUNGER)); } private static Locomotion.Mode choose( diff --git a/src/test/java/net/tfminecraft/companionpets/text/NamesTest.java b/src/test/java/net/tfminecraft/companionpets/text/NamesTest.java new file mode 100644 index 0000000..c618f50 --- /dev/null +++ b/src/test/java/net/tfminecraft/companionpets/text/NamesTest.java @@ -0,0 +1,29 @@ +package net.tfminecraft.companionpets.text; + +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 org.junit.jupiter.api.Test; + +import net.tfminecraft.companionpets.pet.PetSex; + +class NamesTest { + @Test + void answersIgnoreCaseSpacingAndPunctuation() { + assertTrue(Names.confirms(" YES! ")); + assertTrue(Names.confirms("Okay.")); + assertTrue(Names.cancels("Cancel")); + assertTrue(Names.cancels("no.")); + assertEquals(PetSex.FEMALE, Names.sex(" Female ")); + assertEquals(PetSex.MALE, Names.sex("BOY!")); + } + + @Test + void spanishAnswersAreNotAccepted() { + assertFalse(Names.confirms("sí")); + assertFalse(Names.cancels("cancelar")); + assertNull(Names.sex("hembra")); + } +} diff --git a/src/test/java/net/tfminecraft/companionpets/text/PetTextsTest.java b/src/test/java/net/tfminecraft/companionpets/text/PetTextsTest.java new file mode 100644 index 0000000..703f47d --- /dev/null +++ b/src/test/java/net/tfminecraft/companionpets/text/PetTextsTest.java @@ -0,0 +1,28 @@ +package net.tfminecraft.companionpets.text; + +import static org.junit.jupiter.api.Assertions.assertEquals; + +import org.junit.jupiter.api.Test; + +class PetTextsTest { + @Test + void ageUsesTheTimeSinceBirth() { + long born = 1_000_000L; + assertEquals("Unknown", PetTexts.age(0L, born)); + assertEquals("Newborn", PetTexts.age(born, born + 30_000L)); + assertEquals("1 minute", PetTexts.age(born, born + 60_000L)); + assertEquals("12 minutes", PetTexts.age(born, born + 12 * 60_000L)); + assertEquals("1 hour", PetTexts.age(born, born + 60 * 60_000L)); + assertEquals("2 hours and 5 minutes", PetTexts.age(born, born + (2 * 60 + 5) * 60_000L)); + assertEquals("1 day", PetTexts.age(born, born + 24 * 60 * 60_000L)); + assertEquals("3 days and 4 hours", PetTexts.age(born, born + (3 * 24 + 4) * 60 * 60_000L)); + } + + @Test + void speciesAndItemNamesReadNaturally() { + assertEquals("Wolf", PetTexts.speciesName("wolf")); + assertEquals("Snow fox", PetTexts.speciesName("snow_fox")); + assertEquals("Pet", PetTexts.speciesName("")); + assertEquals("glow berries", PetTexts.itemName("GLOW_BERRIES")); + } +} diff --git a/src/test/java/net/tfminecraft/companionpets/training/TrainingMathTest.java b/src/test/java/net/tfminecraft/companionpets/training/TrainingMathTest.java index 4318925..6257416 100644 --- a/src/test/java/net/tfminecraft/companionpets/training/TrainingMathTest.java +++ b/src/test/java/net/tfminecraft/companionpets/training/TrainingMathTest.java @@ -31,6 +31,14 @@ void successWithoutARewardDoesNotStickAndAFailedRewardBarelyMoves() { assertEquals(12.0, TrainingMath.afterReward(10, false, 100, 100, settings), 0.001); } + @Test + void percentLearnedIsMeasuredAgainstTheLearnedThreshold() { + assertEquals(0, TrainingMath.percentLearned(0, settings)); + assertEquals(50, TrainingMath.percentLearned(40, settings)); + assertEquals(100, TrainingMath.percentLearned(80, settings)); + assertEquals(100, TrainingMath.percentLearned(95, settings)); + } + @Test void criticalNeedsAndSicknessBlockAttention() { assertTrue(TrainingMath.attentionBlocked(10, 100, false)); From 80266fe962d1644a7db8da0b279ec942ed1c3339 Mon Sep 17 00:00:00 2001 From: Nowko Date: Thu, 24 Sep 2026 11:54:09 +0200 Subject: [PATCH 2/2] fix: keep unknown ages and let worn-out pets recover energy Older saves have no birth time, so loading them as newborns rewrote a fake age. Missing dates stay unknown, and an exhausted pet now lies down on its own so energy can recover without a button on the info screen. Co-authored-by: Cursor --- README.md | 5 +++-- .../net/tfminecraft/companionpets/runtime/PetTicker.java | 9 +++++++++ .../net/tfminecraft/companionpets/store/PetStore.java | 2 +- 3 files changed, 13 insertions(+), 3 deletions(-) diff --git a/README.md b/README.md index 1197eb1..2b2d70b 100644 --- a/README.md +++ b/README.md @@ -14,9 +14,10 @@ plugins are installed. only after the name is confirmed and there is room in the outside quota. - Sneak and right-click the pet with an empty hand for its info screen. A plain right-click checks on the pet: it tells you what is wrong, or you pet - it when it is fine. Pets only sit through the trained Sit trick. Food, a + it when it is fine. Pets only sit through the trained Sit trick. A worn-out + pet lies down on its own and recovers energy while it sleeps. Food, a brush, and medicine act immediately. Anyone nearby can feed, clean, and heal. Play, - sleep, tricks, and the whistle belong to the owner. + tricks, and the whistle belong to the owner. - Sneak and right-click with the kennel item to place a kennel. The kennel and the whistle open the same list: take a pet out, call one, store one, or rename it. diff --git a/src/main/java/net/tfminecraft/companionpets/runtime/PetTicker.java b/src/main/java/net/tfminecraft/companionpets/runtime/PetTicker.java index 8b6c5c0..d1efc9b 100644 --- a/src/main/java/net/tfminecraft/companionpets/runtime/PetTicker.java +++ b/src/main/java/net/tfminecraft/companionpets/runtime/PetTicker.java @@ -91,6 +91,15 @@ private void care(long now, long elapsed) { pet.order(), pet.staying()); boolean withOwner = !pet.stored() && online && distance <= runtime.config().ownerNearRadius(); + if (!pet.stored() + && pet.activity() == Activity.NONE + && pet.fetch() == null + && pet.need(Need.ENERGY) < 25.0) { + pet.activity(Activity.SLEEPING); + if (online) { + PetFx.bar(owner, pet.name() + " lies down to rest"); + } + } List notices = NeedClock.advance(pet, new CareInput( presence, mode == Locomotion.Mode.FOLLOW, diff --git a/src/main/java/net/tfminecraft/companionpets/store/PetStore.java b/src/main/java/net/tfminecraft/companionpets/store/PetStore.java index 29e4804..092f4b9 100644 --- a/src/main/java/net/tfminecraft/companionpets/store/PetStore.java +++ b/src/main/java/net/tfminecraft/companionpets/store/PetStore.java @@ -171,7 +171,7 @@ private static Pet readPet(UUID id, ConfigurationSection section) { section.getString("type"), section.getString("name", "Mascota"), enumValue(PetSex.class, section.getString("sex"), PetSex.FEMALE)); - pet.bornAt(section.contains("born-at") ? section.getLong("born-at") : System.currentTimeMillis()); + pet.bornAt(section.getLong("born-at", 0L)); pet.order(enumValue(PetOrder.class, section.getString("order"), PetOrder.FOLLOW)); pet.staying(section.getBoolean("staying")); pet.stored(section.getBoolean("stored"));