diff --git a/README.md b/README.md index 8e25fa7..2b2d70b 100644 --- a/README.md +++ b/README.md @@ -12,9 +12,12 @@ 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, - sleep, tricks, and the whistle belong to the owner. +- 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. 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, + 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/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..d1efc9b 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 { @@ -88,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, @@ -103,7 +115,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 +141,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 +162,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 +391,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 +416,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))); - } + boolean training = session != null && session.petId().equals(pet.id()); + if (training && (session.pendingWord() != null || session.rewardTrick() != null)) { continue; } - if (pet.illness() != Illness.NONE) { - PetFx.status(player, PetTexts.illness(pet.name(), pet.sex(), pet.illness())); - 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..092f4b9 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.getLong("born-at", 0L)); 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));