diff --git a/CLAUDE.md b/CLAUDE.md index f4cac2d..2d1183c 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -179,3 +179,12 @@ call is ambiguous between overloads, so pass `new String[0]`. Locale strings can also carry `[actionbar]`, `[title]`, `[subtitle]` and `[sound:...]` markers — BentoBox routes and formats those itself, so prefer them over calling `sendActionBar`/`playSound` around a message. +**Never name an item or a port by hand** (GitHub #7): `[material]` values +come from `economy.ItemNames.label(user, material)` — the locale's +`tradewinds.materials.` line if written, else a `` tag the +client translates itself — and `[name]` for a port from +`PortNames.display(addon, user, spec)`, which transliterates the name's +syllables through `tradewinds.name-token.*`. `IslandSpec.name()` is the +island's identity (registry, PDC, logs, admin arguments), never its label. +`PriceEngine.prettify` is for logs and admin audits only. Enum names reach a +player only through `getLocaleKey()`. diff --git a/src/main/java/world/bentobox/tradewinds/PortNames.java b/src/main/java/world/bentobox/tradewinds/PortNames.java new file mode 100644 index 0000000..96ac639 --- /dev/null +++ b/src/main/java/world/bentobox/tradewinds/PortNames.java @@ -0,0 +1,69 @@ +package world.bentobox.tradewinds; + +import java.util.List; +import java.util.Locale; + +import world.bentobox.bentobox.api.user.User; +import world.bentobox.tradewinds.ocean.IslandSpec; +import world.bentobox.tradewinds.ocean.NameGenerator; +import world.bentobox.tradewinds.ocean.OceanEngine; + +/** + * How a port is named to a player (GitHub #7). + *

+ * Port names are procedural, so no locale can list them - but every name is a + * short sequence of syllable tokens, and a locale CAN list those. Each token + * is looked up as {@code tradewinds.name-token.}; a Chinese locale maps + * "la" to one character and "ve" to another, and "Lave" comes out in Chinese + * for every Chinese-speaking sailor, the same for all of them. A blank or + * missing line leaves that token as it is, so en-US reads unchanged. + *

+ * {@link IslandSpec#name()} stays the canonical English name and the island's + * identity everywhere that is not a player's screen: island registry, resident + * tags, logs, admin commands that take a name as an argument. This class is + * display only. + * + * @author tastybento + */ +public final class PortNames { + + private static final String TOKEN_PREFIX = "tradewinds.name-token."; + private static final String SPAWN_KEY = "tradewinds.spawn.name"; + + private PortNames() { + // Static use only + } + + /** + * The name of a port as this player should read it. + * + * @param addon the addon, for the ocean the port belongs to + * @param user the viewer - decides which locale's tokens apply + * @param spec the port + * @return the transliterated name, or the canonical name where the ocean + * engine is not up or the spec is not one of its islands + */ + public static String display(TradeWinds addon, User user, IslandSpec spec) { + if (OceanEngine.SPAWN_NAME.equals(spec.name())) { + // The one island with a given name rather than a generated one + return user.getTranslation(SPAWN_KEY); + } + OceanEngine engine = addon.getOceanEngine(); + if (engine == null) { + return spec.name(); + } + List tokens = engine.nameTokens(spec); + if (!NameGenerator.join(tokens).equals(spec.name())) { + // Not this ocean's island (a renamed or hand-built spec): no tokens to map + return spec.name(); + } + StringBuilder sb = new StringBuilder(); + for (String token : tokens) { + String syllable = token.toLowerCase(Locale.ENGLISH); + String mapped = user.getTranslationOrNothing(TOKEN_PREFIX + syllable); + sb.append(mapped.isBlank() ? syllable : mapped); + } + // Capitalize the way the canonical name is; a no-op for scripts without case + return Character.toUpperCase(sb.charAt(0)) + sb.substring(1); + } +} diff --git a/src/main/java/world/bentobox/tradewinds/commands/AdminCustomsCommand.java b/src/main/java/world/bentobox/tradewinds/commands/AdminCustomsCommand.java index e45e969..171b710 100644 --- a/src/main/java/world/bentobox/tradewinds/commands/AdminCustomsCommand.java +++ b/src/main/java/world/bentobox/tradewinds/commands/AdminCustomsCommand.java @@ -68,7 +68,7 @@ private void reportIslandCustoms(User user, TradeWinds addon, IslandSpec island) addon.getReputationService().standing(user.getUniqueId()), addon.getSettings().getScanUpstandingFactor(), addon.getSettings().getScanOffenderFactor()); user.sendMessage("tradewinds.commands.admin.customs.island", "[name]", island.name(), "[band]", - island.band().getDisplayName()); + user.getTranslation(island.band().getLocaleKey())); user.sendMessage("tradewinds.commands.admin.customs.scan-chance", VALUE_PLACEHOLDER, String.format("%.0f%%", effective * 100)); user.sendMessage("tradewinds.commands.admin.customs.buys", VALUE_PLACEHOLDER, diff --git a/src/main/java/world/bentobox/tradewinds/commands/AdminIslandsCommand.java b/src/main/java/world/bentobox/tradewinds/commands/AdminIslandsCommand.java index f466b23..ddb02e8 100644 --- a/src/main/java/world/bentobox/tradewinds/commands/AdminIslandsCommand.java +++ b/src/main/java/world/bentobox/tradewinds/commands/AdminIslandsCommand.java @@ -57,9 +57,9 @@ public boolean execute(User user, String label, List args) { user.sendMessage("tradewinds.commands.admin.islands.entry", "[index]", String.valueOf(i + 1), TextVariables.NAME, s.name(), - "[type]", s.type().name(), + "[type]", user.getTranslation(s.type().getLocaleKey()), "[tech]", String.valueOf(s.techLevel()), - "[band]", s.band().getDisplayName(), + "[band]", user.getTranslation(s.band().getLocaleKey()), "[x]", String.valueOf(s.centerX()), "[z]", String.valueOf(s.centerZ()), "[distance]", String.valueOf(dist)); diff --git a/src/main/java/world/bentobox/tradewinds/commands/TWChartCommand.java b/src/main/java/world/bentobox/tradewinds/commands/TWChartCommand.java index d2c269d..719fa09 100644 --- a/src/main/java/world/bentobox/tradewinds/commands/TWChartCommand.java +++ b/src/main/java/world/bentobox/tradewinds/commands/TWChartCommand.java @@ -6,6 +6,8 @@ import world.bentobox.bentobox.api.commands.CompositeCommand; import world.bentobox.bentobox.api.localization.TextVariables; import world.bentobox.bentobox.api.user.User; +import world.bentobox.tradewinds.economy.ItemNames; +import world.bentobox.tradewinds.PortNames; import world.bentobox.tradewinds.TradeWinds; import world.bentobox.tradewinds.ocean.OceanEngine; import world.bentobox.tradewinds.ocean.IslandSpec; @@ -91,8 +93,8 @@ private void chartList(TradeWinds addon, User user) { int cost = routeCost(addon, origin, spec, x, z); boolean reachable = cost <= fuelAboard; user.sendMessage(reachable ? "tradewinds.chart.entry-reachable" : "tradewinds.chart.entry-far", - TextVariables.NAME, spec.name(), - "[type]", spec.type().name(), + TextVariables.NAME, PortNames.display(addon, user, spec), + "[type]", user.getTranslation(spec.type().getLocaleKey()), "[tech]", String.valueOf(spec.techLevel()), "[band]", user.getTranslation(spec.band().getLocaleKey()), "[distance]", String.valueOf((int) Math.sqrt(spec.distanceSquared(x, z))), @@ -123,7 +125,7 @@ private void reportBoat(TradeWinds addon, User user, int x, int z, boolean old) return; } var hold = boatRecord.get(); - String material = world.bentobox.tradewinds.economy.PriceEngine.prettify(hold.getMaterial()); + String material = ItemNames.label(user, org.bukkit.Material.matchMaterial(hold.getMaterial())); if (hold.getWorld() == null || hold.getWorld().isEmpty()) { user.sendMessage(key + "-lost", MATERIAL_PLACEHOLDER, material); return; diff --git a/src/main/java/world/bentobox/tradewinds/commands/TWPricesCommand.java b/src/main/java/world/bentobox/tradewinds/commands/TWPricesCommand.java index f434ab8..e9189f5 100644 --- a/src/main/java/world/bentobox/tradewinds/commands/TWPricesCommand.java +++ b/src/main/java/world/bentobox/tradewinds/commands/TWPricesCommand.java @@ -8,6 +8,7 @@ import world.bentobox.bentobox.api.commands.CompositeCommand; import world.bentobox.bentobox.api.localization.TextVariables; import world.bentobox.bentobox.api.user.User; +import world.bentobox.tradewinds.PortNames; import world.bentobox.tradewinds.TradeWinds; import world.bentobox.tradewinds.economy.Money; import world.bentobox.tradewinds.economy.TradeCategory; @@ -32,6 +33,7 @@ public class TWPricesCommand extends CompositeCommand { private static final int MAX_ROWS = 12; private static final String VALUE_PLACEHOLDER = "[value]"; + private static final String KEY_NONE = "tradewinds.general.none"; public TWPricesCommand(CompositeCommand parent) { super(parent, "prices"); @@ -105,7 +107,7 @@ private void sortAndDisplayHeader(User user, world.bentobox.tradewinds.dataobjec known.sort(Comparator.comparingInt( (IslandSpec spec) -> data.loggedPrices(spec).getOrDefault(sought.name(), 0)).reversed()); user.sendMessage("tradewinds.commands.prices.header-category", VALUE_PLACEHOLDER, - prettyCategory(sought)); + user.getTranslation(sought.getLocaleKey())); } else { known.sort(Comparator.comparingLong(data::lastSeenPrices).reversed()); user.sendMessage("tradewinds.commands.prices.header"); @@ -121,11 +123,13 @@ private void displayRows(User user, TradeWinds addon, world.bentobox.tradewinds. if (price == null || price <= 0) { continue; } - user.sendMessage("tradewinds.commands.prices.row-category", "[name]", spec.name(), "[price]", + user.sendMessage("tradewinds.commands.prices.row-category", "[name]", + PortNames.display(addon, user, spec), "[price]", Money.format(addon, price), "[age]", age); } else { - user.sendMessage("tradewinds.commands.prices.row", "[name]", spec.name(), VALUE_PLACEHOLDER, - best(addon, prices), "[age]", age); + user.sendMessage("tradewinds.commands.prices.row", "[name]", PortNames.display(addon, user, spec), + VALUE_PLACEHOLDER, + best(user, addon, prices), "[age]", age); } } } @@ -134,11 +138,11 @@ private void displayRows(User user, TradeWinds addon, world.bentobox.tradewinds. * The best-paying category remembered at a port - the one line worth showing * when not filtering. */ - private String best(TradeWinds addon, Map prices) { + private String best(User user, TradeWinds addon, Map prices) { return prices.entrySet().stream().max(Map.Entry.comparingByValue()) - .map(entry -> prettyCategory(category(entry.getKey())) + " " + .map(entry -> prettyCategory(user, category(entry.getKey())) + " " + Money.format(addon, entry.getValue())) - .orElse("-"); + .orElse(user.getTranslation(KEY_NONE)); } private static TradeCategory category(String name) { @@ -150,9 +154,8 @@ private static TradeCategory category(String name) { return null; } - private static String prettyCategory(TradeCategory category) { - return category == null ? "-" - : world.bentobox.tradewinds.economy.PriceEngine.prettify(category.name()); + private static String prettyCategory(User user, TradeCategory category) { + return user.getTranslation(category == null ? KEY_NONE : category.getLocaleKey()); } /** diff --git a/src/main/java/world/bentobox/tradewinds/commands/TWRankCommand.java b/src/main/java/world/bentobox/tradewinds/commands/TWRankCommand.java index dc99412..b77a190 100644 --- a/src/main/java/world/bentobox/tradewinds/commands/TWRankCommand.java +++ b/src/main/java/world/bentobox/tradewinds/commands/TWRankCommand.java @@ -54,7 +54,8 @@ public boolean execute(User user, String label, List args) { String name = addon.getPlayers().getName(entry.playerId()); user.sendMessage("tradewinds.rank.board-entry", "[place]", String.valueOf(place), - TextVariables.NAME, name.isEmpty() ? "?" : name, + TextVariables.NAME, + name.isEmpty() ? user.getTranslation("tradewinds.general.unknown") : name, TextVariables.NUMBER, String.valueOf(entry.charted()), RANK_PLACEHOLDER, user.getTranslation(ranks.rankFor(entry.charted()).localeKey())); } diff --git a/src/main/java/world/bentobox/tradewinds/crime/CustomsService.java b/src/main/java/world/bentobox/tradewinds/crime/CustomsService.java index 753a4f8..022e01c 100644 --- a/src/main/java/world/bentobox/tradewinds/crime/CustomsService.java +++ b/src/main/java/world/bentobox/tradewinds/crime/CustomsService.java @@ -18,6 +18,7 @@ import world.bentobox.bentobox.api.localization.TextVariables; import world.bentobox.bentobox.api.user.User; import world.bentobox.bentobox.hooks.VaultHook; +import world.bentobox.tradewinds.PortNames; import world.bentobox.tradewinds.TradeWinds; import world.bentobox.tradewinds.ocean.IslandSpec; import world.bentobox.tradewinds.ocean.SecurityBand; @@ -218,9 +219,10 @@ private void detect(Player player, IslandSpec island, int aboard, boolean flagge seize(player, island); return; } + String port = PortNames.display(addon, user, island); user.sendMessage(flagged ? "tradewinds.customs.flagged" : "tradewinds.customs.detected", - NAME_VAR, island.name(), TextVariables.NUMBER, String.valueOf(aboard)); - user.sendMessage("tradewinds.customs.detected-chat", NAME_VAR, island.name(), TextVariables.NUMBER, + NAME_VAR, port, TextVariables.NUMBER, String.valueOf(aboard)); + user.sendMessage("tradewinds.customs.detected-chat", NAME_VAR, port, TextVariables.NUMBER, String.valueOf(aboard)); chases.put(player.getUniqueId(), new Chase(island, System.currentTimeMillis(), units)); addon.log("Customs at " + island.name() + " detected " + aboard + " contraband on " + player.getName() @@ -234,8 +236,9 @@ private void detect(Player player, IslandSpec island, int aboard, boolean flagge private void seize(Player player, IslandSpec island) { int seized = confiscate(player); double fine = charge(player, seized); - User.getInstance(player).sendMessage("tradewinds.customs.seized", TextVariables.NUMBER, - String.valueOf(seized), "[amount]", format(fine), NAME_VAR, island.name()); + User user = User.getInstance(player); + user.sendMessage("tradewinds.customs.seized", TextVariables.NUMBER, + String.valueOf(seized), "[amount]", format(fine), NAME_VAR, PortNames.display(addon, user, island)); addon.getReputationService().recordCrime(player, Crime.SMUGGLING); } @@ -294,8 +297,9 @@ private double nearestUnitDistance(Player player, Chase chase) { void caught(Player player, Chase chase) { int seized = confiscate(player); double fine = charge(player, seized); - User.getInstance(player).sendMessage("tradewinds.customs.caught", TextVariables.NUMBER, - String.valueOf(seized), "[amount]", format(fine), NAME_VAR, chase.island().name()); + User user = User.getInstance(player); + user.sendMessage("tradewinds.customs.caught", TextVariables.NUMBER, String.valueOf(seized), "[amount]", + format(fine), NAME_VAR, PortNames.display(addon, user, chase.island())); addon.getReputationService().recordCrime(player, Crime.SMUGGLING); } @@ -353,7 +357,8 @@ private String format(double amount) { * of time. No flee flag: nobody ran, so the port has nothing to remember. */ private void calledOff(Player player, Chase chase) { - User.getInstance(player).sendMessage("tradewinds.customs.called-off", NAME_VAR, chase.island().name()); + User user = User.getInstance(player); + user.sendMessage("tradewinds.customs.called-off", NAME_VAR, PortNames.display(addon, user, chase.island())); } /** @@ -363,7 +368,8 @@ private void calledOff(Player player, Chase chase) { private void escaped(Player player, Chase chase) { String key = key(player, chase.island()); fleeFlags.put(key, System.currentTimeMillis() + addon.getSettings().getFleeFlagMinutes() * 60_000L); - User.getInstance(player).sendMessage("tradewinds.customs.escaped", NAME_VAR, chase.island().name()); + User user = User.getInstance(player); + user.sendMessage("tradewinds.customs.escaped", NAME_VAR, PortNames.display(addon, user, chase.island())); } private void end(UUID id, Chase chase) { diff --git a/src/main/java/world/bentobox/tradewinds/crime/PoliceService.java b/src/main/java/world/bentobox/tradewinds/crime/PoliceService.java index 3d009a5..3fa6bad 100644 --- a/src/main/java/world/bentobox/tradewinds/crime/PoliceService.java +++ b/src/main/java/world/bentobox/tradewinds/crime/PoliceService.java @@ -21,6 +21,7 @@ import org.bukkit.scheduler.BukkitTask; import world.bentobox.bentobox.api.user.User; +import world.bentobox.tradewinds.PortNames; import world.bentobox.tradewinds.TradeWinds; import world.bentobox.tradewinds.ocean.IslandSpec; @@ -122,7 +123,8 @@ private void update(Player player, Standing standing) { return; // This band has nobody to send - lawless water } responses.put(player.getUniqueId(), new Response(island, units)); - User.getInstance(player).sendMessage("tradewinds.police.responding", "[name]", island.name()); + User user = User.getInstance(player); + user.sendMessage("tradewinds.police.responding", "[name]", PortNames.display(addon, user, island)); return; } // Mobs forget, and a target seated in a boat is forgotten fast: keep diff --git a/src/main/java/world/bentobox/tradewinds/economy/ItemNames.java b/src/main/java/world/bentobox/tradewinds/economy/ItemNames.java new file mode 100644 index 0000000..c0b48f7 --- /dev/null +++ b/src/main/java/world/bentobox/tradewinds/economy/ItemNames.java @@ -0,0 +1,130 @@ +package world.bentobox.tradewinds.economy; + +import java.util.Locale; + +import org.bukkit.Material; +import org.bukkit.enchantments.Enchantment; + +import net.kyori.adventure.text.minimessage.MiniMessage; +import world.bentobox.bentobox.api.user.User; + +/** + * How a vanilla item is named to a player (GitHub #7). + *

+ * A material's name used to be its enum prettified - "Oak Log" - which no + * locale could touch. Now the name is, in order: + *

    + *
  1. the locale's own line, {@code tradewinds.materials.}, when the + * translator wrote one;
  2. + *
  3. otherwise a MiniMessage {@code } tag carrying the + * item's client translation key, with the English name as fallback.
  4. + *
+ * The second is the interesting one. BentoBox substitutes variables into a + * locale string by plain replacement and then parses the whole thing as + * MiniMessage, so wherever the string becomes a Component - dialogs, GUI item + * names and lore, holograms, boss bars - the CLIENT renders the item name in + * its own language, for every language Mojang ships, with no translator + * effort. Plain chat is flattened to legacy text by BentoBox, and its + * flattener uses the tag's fallback, so chat degrades to the English name (or + * the locale's override) rather than to a raw key. + * + * @author tastybento + */ +public final class ItemNames { + + private static final String MATERIAL_PREFIX = "tradewinds.materials."; + private static final String UNKNOWN_KEY = "tradewinds.general.unknown"; + + private ItemNames() { + // Static use only + } + + /** + * The name of a material for this player, ready to substitute into a + * locale string as {@code [material]}. + * + * @param user the viewer - decides which locale's override applies + * @param material the material; null renders as the locale's unknown mark + * @return the locale's line for it, else a client-translated tag with an + * English fallback + */ + public static String label(User user, Material material) { + if (material == null) { + return user.getTranslation(UNKNOWN_KEY); + } + String override = user.getTranslationOrNothing(MATERIAL_PREFIX + material.name().toLowerCase(Locale.ENGLISH)); + if (!override.isBlank()) { + return override; + } + return tag(translationKey(material), PriceEngine.prettify(material.name())); + } + + /** + * An enchantment with its level, client-translated: "Sharpness III" in + * whatever language the client speaks. Vanilla has level names for I to X; + * beyond that the number itself is the fallback. + */ + public static String label(Enchantment enchantment, int level) { + String name = tag(translationKey(enchantment), PriceEngine.prettify(enchantment.getKey().getKey())); + return name + " " + tag("enchantment.level." + level, String.valueOf(level)); + } + + /** + * Player-written text (an item's given name) about to be substituted into + * a locale string. Substitution is by plain replacement and the result is + * parsed as MiniMessage, so a sword named {@code } would otherwise + * inject formatting into the dialog. This is escaping, not rendering - the + * User API still does all the rendering. + */ + public static String escape(String playerText) { + return MiniMessage.miniMessage().escapeTags(playerText); + } + + /** + * The MiniMessage tag that asks the client to translate a key, with the + * fallback the server prints where a Component cannot reach. + */ + static String tag(String key, String fallback) { + return ""; + } + + /** + * Escape a value for a single-quoted MiniMessage tag argument. + */ + private static String quote(String text) { + return text.replace("\\", "\\\\").replace("'", "\\'"); + } + + /** + * The client translation key for a material. Paper resolves it through the + * item registry, which the headless test harness does not have; there we + * derive the obvious key from the namespaced key instead. Both give + * {@code block.minecraft.oak_log} for a log; only a real server gets the + * special cases (wheat the item versus wheat the crop) right, which is + * exactly where it matters. + */ + static String translationKey(Material material) { + try { + String key = material.translationKey(); + if (key != null && !key.isBlank()) { + return key; + } + } catch (RuntimeException e) { + // Registry not available (tests) - derive below + } + return (material.isBlock() ? "block." : "item.") + material.getKey().getNamespace() + "." + + material.getKey().getKey(); + } + + private static String translationKey(Enchantment enchantment) { + try { + String key = enchantment.translationKey(); + if (key != null && !key.isBlank()) { + return key; + } + } catch (RuntimeException e) { + // Registry not available (tests) - derive below + } + return "enchantment." + enchantment.getKey().getNamespace() + "." + enchantment.getKey().getKey(); + } +} diff --git a/src/main/java/world/bentobox/tradewinds/economy/MarketService.java b/src/main/java/world/bentobox/tradewinds/economy/MarketService.java index 9ccc979..a0d5bce 100644 --- a/src/main/java/world/bentobox/tradewinds/economy/MarketService.java +++ b/src/main/java/world/bentobox/tradewinds/economy/MarketService.java @@ -10,6 +10,7 @@ import world.bentobox.bentobox.api.user.User; import world.bentobox.bentobox.hooks.VaultHook; +import world.bentobox.tradewinds.PortNames; import world.bentobox.tradewinds.TradeWinds; import world.bentobox.tradewinds.api.events.TWTradeEvent; import world.bentobox.tradewinds.ocean.IslandSpec; @@ -140,7 +141,7 @@ public int buyToInventory(Player player, IslandSpec spec, Material material, int player.getInventory().addItem(stores).values() .forEach(left -> player.getWorld().dropItem(player.getLocation(), left)); user.sendMessage("tradewinds.trade.bought", VAR_AMOUNT, String.valueOf(affordable), VAR_MATERIAL, - pretty(material), VAR_PRICE, Money.format(addon, total)); + ItemNames.label(user, material), VAR_PRICE, Money.format(addon, total)); chime(player); return affordable; } @@ -327,7 +328,7 @@ public boolean buyFromShelf(Player player, IslandSpec spec, int index) { // Only now take it off the shelf, so a full hold cannot destroy a listing addon.getIslandDataManager().takeFromShelf(spec, index); vault.get().withdraw(user, price.get()); - user.sendMessage("tradewinds.trade.shelf-bought", VAR_MATERIAL, pretty(item.getType()), VAR_PRICE, + user.sendMessage("tradewinds.trade.shelf-bought", VAR_MATERIAL, ItemNames.label(user, item.getType()), VAR_PRICE, Money.format(addon, price.get())); chime(player); return true; @@ -593,8 +594,9 @@ public int sell(Player player, IslandSpec spec, ItemStack item, int amount) { } // Too rich for this port to handle - take it somewhere more developed if (!handlesValue(spec, item)) { - User.getInstance(player).sendMessage("tradewinds.trade.too-advanced", VAR_MATERIAL, pretty(material), - "[name]", spec.name()); + User user = User.getInstance(player); + user.sendMessage("tradewinds.trade.too-advanced", VAR_MATERIAL, ItemNames.label(user, material), + "[name]", PortNames.display(addon, user, spec)); thud(player); return 0; } @@ -618,8 +620,9 @@ public int sell(Player player, IslandSpec spec, ItemStack item, int amount) { addon.getIslandDataManager().adjustStockValue(spec, driftPool(material), (int) Math.round(total)); // Notable goods go back out for sale somewhere else rather than vanishing consign(spec, item); - User.getInstance(player).sendMessage("tradewinds.trade.sold", VAR_AMOUNT, String.valueOf(removed), - VAR_MATERIAL, pretty(material), VAR_PRICE, Money.format(addon, total)); + User user = User.getInstance(player); + user.sendMessage("tradewinds.trade.sold", VAR_AMOUNT, String.valueOf(removed), + VAR_MATERIAL, ItemNames.label(user, material), VAR_PRICE, Money.format(addon, total)); chime(player); return removed; } @@ -670,7 +673,7 @@ public int buy(Player player, IslandSpec spec, Material material, int amount) { vault.get().withdraw(user, total); addon.getIslandDataManager().adjustStockValue(spec, driftPool(material), -(int) Math.round(total)); user.sendMessage("tradewinds.trade.bought", VAR_AMOUNT, String.valueOf(added), VAR_MATERIAL, - pretty(material), VAR_PRICE, Money.format(addon, total)); + ItemNames.label(user, material), VAR_PRICE, Money.format(addon, total)); chime(player); return added; } @@ -712,13 +715,13 @@ public boolean buyBoat(Player player, IslandSpec spec, BoatRanks.Rank rank) { addon.getBoatService().logbook("bought by " + player.getName() + " at " + spec.name(), fresh, player.getLocation()); addon.getBoatService().giveBoatItem(player, fresh); - user.sendMessage("tradewinds.trade.boat-bought-first", VAR_MATERIAL, pretty(rank.material()), + user.sendMessage("tradewinds.trade.boat-bought-first", VAR_MATERIAL, ItemNames.label(user, rank.material()), VAR_SLOTS, String.valueOf(rank.slots()), VAR_PRICE, Money.format(addon, price)); } else if (isTradeIn(player, spec, rank)) { // The ship is at the quay and the new hull is bigger: a trade-in // - same record, cargo stays, old hull broken up addon.getBoatService().refit(player, owned.get(), rank.material()); - user.sendMessage("tradewinds.trade.boat-bought", VAR_MATERIAL, pretty(rank.material()), + user.sendMessage("tradewinds.trade.boat-bought", VAR_MATERIAL, ItemNames.label(user, rank.material()), VAR_SLOTS, String.valueOf(rank.slots()), VAR_PRICE, Money.format(addon, price)); } else { // Bought outright (ruled 2026-08-05): the yard ALWAYS sells - a @@ -728,7 +731,7 @@ public boolean buyBoat(Player player, IslandSpec spec, BoatRanks.Rank rank) { // served. The dialog confirmed this before the money moved. var old = owned.get(); String oldName = Material.matchMaterial(old.getMaterial()) == null ? old.getMaterial() - : pretty(Material.matchMaterial(old.getMaterial())); + : ItemNames.label(user, Material.matchMaterial(old.getMaterial())); var fresh = addon.getBoatService().createFor(player, rank.material()); addon.getBoatService().logbook("bought by " + player.getName() + " at " + spec.name() + ", replacing their " + old.getMaterial(), fresh, player.getLocation()); @@ -741,7 +744,7 @@ public boolean buyBoat(Player player, IslandSpec spec, BoatRanks.Rank rank) { addon.getBoatService().shedCarriedHull(player, old); // The plate on the abandoned hull flips to UNOWNED, if it is loaded addon.getBoatService().relabel(old); - user.sendMessage("tradewinds.trade.boat-replaced", VAR_MATERIAL, pretty(rank.material()), + user.sendMessage("tradewinds.trade.boat-replaced", VAR_MATERIAL, ItemNames.label(user, rank.material()), VAR_SLOTS, String.valueOf(rank.slots()), VAR_PRICE, Money.format(addon, price), "[old]", oldName); } @@ -856,10 +859,6 @@ public boolean claimCharity(Player player) { return true; } - static String pretty(Material material) { - return PriceEngine.prettify(material.name()); - } - /** * A deal struck: bright coin-on-the-counter chime. Dialogs cover the chat * box, so the sound carries the outcome. diff --git a/src/main/java/world/bentobox/tradewinds/economy/TradeCategory.java b/src/main/java/world/bentobox/tradewinds/economy/TradeCategory.java index 2bc765e..3fd05eb 100644 --- a/src/main/java/world/bentobox/tradewinds/economy/TradeCategory.java +++ b/src/main/java/world/bentobox/tradewinds/economy/TradeCategory.java @@ -28,6 +28,16 @@ public enum TradeCategory { */ SALVAGE; + /** + * The locale key for this category's name. The enum name stays the + * command argument and the logbook key; this is what the player reads. + * + * @return locale key + */ + public String getLocaleKey() { + return "tradewinds.category." + name().toLowerCase(Locale.ENGLISH); + } + /** * Raw goods - what low-tech islands live on. High-tech ports pay over the * odds for these (they feed the works), low-tech ports sell them cheap. diff --git a/src/main/java/world/bentobox/tradewinds/economy/TradeDialog.java b/src/main/java/world/bentobox/tradewinds/economy/TradeDialog.java index 7edcba8..be2126b 100644 --- a/src/main/java/world/bentobox/tradewinds/economy/TradeDialog.java +++ b/src/main/java/world/bentobox/tradewinds/economy/TradeDialog.java @@ -18,6 +18,7 @@ import net.kyori.adventure.text.Component; import net.kyori.adventure.text.event.ClickCallback; import world.bentobox.bentobox.api.user.User; +import world.bentobox.tradewinds.PortNames; import world.bentobox.tradewinds.TradeWinds; import world.bentobox.tradewinds.ocean.IslandSpec; import world.bentobox.tradewinds.travel.CargoStore; @@ -48,6 +49,8 @@ public class TradeDialog { private static final String VAR_MATERIAL = "[material]"; private static final String VAR_AMOUNT = "[amount]"; private static final String KEY_SELL_QTY_TOOLTIP = "market.sell-qty-tooltip"; + /** The locale's "nothing here" mark - a dash in English, but not everywhere. */ + private static final String KEY_NONE = "tradewinds.general.none"; private final TradeWinds addon; @@ -73,7 +76,7 @@ public void openMain(Player player, IslandSpec spec) { List buttons = buildMainButtons(player, spec, boatHere, lowFuel, fuelInTradeCatalog); List body = buildMainBody(player, spec, boatHere, lowFuel); - show(player, ui(player, "market.title", VAR_NAME, spec.name()), body, buttons, closeButton(player), 1); + show(player, ui(player, "market.title", VAR_NAME, portName(player, spec)), body, buttons, closeButton(player), 1); } /** @@ -130,7 +133,8 @@ private void addReportButton(List buttons, Player player, IslandSp */ private List buildMainBody(Player player, IslandSpec spec, boolean boatHere, boolean lowFuel) { List body = new ArrayList<>(List.of( - ui(player, "market.subtitle", "[type]", spec.type().name(), "[tech]", + ui(player, "market.subtitle", "[type]", + User.getInstance(player).getTranslation(spec.type().getLocaleKey()), "[tech]", String.valueOf(spec.techLevel()), "[band]", User.getInstance(player).getTranslation(spec.band().getLocaleKey())), statusLine(player))); @@ -183,7 +187,7 @@ public void openSell(Player player, IslandSpec spec) { ui(player, "market.sell-pick", VAR_MATERIAL, itemLabel(player, offer.item()), VAR_AMOUNT, String.valueOf(offer.amount()), VAR_PRICE, Money.format(addon, offer.unitPrice())), ui(player, "market.sell-pick-tooltip", VAR_PRICE, Money.format(addon, offer.unitPrice()), - "[depth]", depthOf(spec, offer)), + "[depth]", depthOf(player, spec, offer)), () -> openSellItem(player, spec, offer.item()))); } List body = new ArrayList<>(List.of(ui(player, "market.selling-body", NO_VARS), @@ -191,7 +195,7 @@ public void openSell(Player player, IslandSpec spec) { if (offers.size() > MAX_ROWS) { body.add(ui(player, "market.selling-truncated", "[number]", String.valueOf(MAX_ROWS))); } - show(player, ui(player, "market.selling-title", VAR_NAME, spec.name()), body, buttons, + show(player, ui(player, "market.selling-title", VAR_NAME, portName(player, spec)), body, buttons, backButton(player, spec), 2); } @@ -220,7 +224,7 @@ public void openSellItem(Player player, IslandSpec spec, ItemStack item) { VAR_PRICE, each))) .showTooltip(true).showDecorations(true).width(32).height(32).build()); body.add(DialogBody.plainMessage(ui(player, "market.sell-item-depth", "[depth]", - depthOf(spec, offer)))); + depthOf(player, spec, offer)))); body.add(DialogBody.plainMessage(statusLine(player))); int batch = Math.min(MID_BATCH, offer.amount()); @@ -241,7 +245,7 @@ public void openSellItem(Player player, IslandSpec spec, ItemStack item) { ui(player, KEY_SELL_QTY_TOOLTIP, NO_VARS), () -> sellThenReopen(player, spec, offer.item(), Integer.MAX_VALUE))); - showBodies(player, ui(player, "market.selling-title", VAR_NAME, spec.name()), body, buttons, + showBodies(player, ui(player, "market.selling-title", VAR_NAME, portName(player, spec)), body, buttons, button(ui(player, "market.back", NO_VARS), ui(player, "market.back-tooltip", NO_VARS), () -> openSell(player, spec)), 3); @@ -254,11 +258,11 @@ public void openSellItem(Player player, IslandSpec spec, ItemStack item) { * beside it is for. */ private String itemLabel(Player player, ItemStack item) { - String material = MarketService.pretty(item.getType()); + String material = ItemNames.label(User.getInstance(player), item.getType()); var meta = item.getItemMeta(); if (meta != null && meta.hasDisplayName()) { - return net.kyori.adventure.text.serializer.plain.PlainTextComponentSerializer.plainText() - .serialize(meta.displayName()); + return ItemNames.escape(net.kyori.adventure.text.serializer.plain.PlainTextComponentSerializer + .plainText().serialize(meta.displayName())); } if (!item.getEnchantments().isEmpty()) { return uiText(player, "market.item-enchanted", VAR_MATERIAL, material); @@ -289,7 +293,8 @@ public void openShelf(Player player, IslandSpec spec) { ItemStack item = shelf.get(i); final int index = i; String price = addon.getMarketService().shelfPrice(item) - .map(value -> Money.format(addon, value)).orElse("-"); + .map(value -> Money.format(addon, value)) + .orElse(User.getInstance(player).getTranslation(KEY_NONE)); // Secondhand goods are notable by definition, so the icon and its real // tooltip are the whole point of the page body.add(DialogBody.item(item) @@ -297,31 +302,31 @@ public void openShelf(Player player, IslandSpec spec) { itemLabel(player, item), VAR_PRICE, price))) .showTooltip(true).showDecorations(true).build()); buttons.add(button(ui(player, "market.shelf-buy", VAR_MATERIAL, itemLabel(player, item), - VAR_PRICE, price), ui(player, "market.shelf-buy-tooltip", "[details]", details(item)), + VAR_PRICE, price), ui(player, "market.shelf-buy-tooltip", "[details]", details(player, item)), () -> { addon.getMarketService().buyFromShelf(player, spec, index); openShelf(player, spec); })); } - showBodies(player, ui(player, "market.shelf-title", VAR_NAME, spec.name()), body, buttons, + showBodies(player, ui(player, "market.shelf-title", VAR_NAME, portName(player, spec)), body, buttons, backButton(player, spec), 2); } /** * What makes a shelf item worth looking at - its name, or its enchantments. */ - private String details(ItemStack item) { + private String details(Player player, ItemStack item) { var meta = item.getItemMeta(); if (meta != null && meta.hasDisplayName()) { - return net.kyori.adventure.text.serializer.plain.PlainTextComponentSerializer.plainText() - .serialize(meta.displayName()); + return ItemNames.escape(net.kyori.adventure.text.serializer.plain.PlainTextComponentSerializer + .plainText().serialize(meta.displayName())); } if (!item.getEnchantments().isEmpty()) { return item.getEnchantments().entrySet().stream() - .map(e -> PriceEngine.prettify(e.getKey().getKey().getKey()) + " " + e.getValue()) + .map(e -> ItemNames.label(e.getKey(), e.getValue())) .reduce((a, b) -> a + ", " + b).orElse(""); } - return MarketService.pretty(item.getType()); + return ItemNames.label(User.getInstance(player), item.getType()); } /** @@ -331,11 +336,11 @@ private String details(ItemStack item) { * * @return the count, or "-" where the port is already saturated */ - private String depthOf(IslandSpec spec, SellOffer offer) { + private String depthOf(Player player, IslandSpec spec, SellOffer offer) { int headroom = addon.getIslandDataManager().absorbableValue(spec, addon.getMarketService().driftPool(offer.material())); if (offer.unitPrice() <= 0) { - return "-"; + return User.getInstance(player).getTranslation(KEY_NONE); } return String.valueOf((int) Math.floor(headroom / offer.unitPrice())); } @@ -353,7 +358,7 @@ public void openBuy(Player player, IslandSpec spec, List catalog, Stri for (Material material : catalog) { Optional price = addon.getMarketService().playerBuysAt(spec, material); price.ifPresent(unit -> { - String name = MarketService.pretty(material); + String name = ItemNames.label(User.getInstance(player), material); Component each = ui(player, "market.buy-tooltip-each", VAR_PRICE, Money.format(addon, unit)); buttons.add(button(ui(player, "market.buy-one", VAR_MATERIAL, name, VAR_PRICE, Money.format(addon, unit)), each, @@ -371,7 +376,7 @@ public void openBuy(Player player, IslandSpec spec, List catalog, Stri openMain(player, spec); return; } - show(player, ui(player, "market." + page + "-title", "[name]", spec.name()), + show(player, ui(player, "market." + page + "-title", "[name]", portName(player, spec)), List.of(ui(player, "market." + page + "-body", NO_VARS), statusLine(player)), buttons, backButton(player, spec), 3); } @@ -391,7 +396,7 @@ public void openOutfitter(Player player, IslandSpec spec) { List buttons = new ArrayList<>(); for (Material material : addon.getMarketService().outfitterCatalog(spec)) { addon.getMarketService().playerBuysAt(spec, material).ifPresent(unit -> { - String name = MarketService.pretty(material); + String name = ItemNames.label(User.getInstance(player), material); Component each = ui(player, "market.outfit-tooltip", VAR_PRICE, Money.format(addon, unit)); buttons.add(button(ui(player, "market.outfit-one", VAR_MATERIAL, name, VAR_PRICE, Money.format(addon, unit)), each, @@ -413,7 +418,7 @@ public void openOutfitter(Player player, IslandSpec spec) { openMain(player, spec); return; } - show(player, ui(player, "market.outfitter-title", "[name]", spec.name()), + show(player, ui(player, "market.outfitter-title", "[name]", portName(player, spec)), List.of(ui(player, "market.outfitter-body", NO_VARS), statusLine(player)), buttons, backButton(player, spec), 2); } @@ -436,7 +441,7 @@ public void openShipwright(Player player, IslandSpec spec) { .shopListing(current, spec.techLevel())) { double price = addon.getBoatRanks().price(rank); buttons.add(button( - ui(player, "market.hull", VAR_MATERIAL, MarketService.pretty(rank.material()), VAR_PRICE, + ui(player, "market.hull", VAR_MATERIAL, ItemNames.label(User.getInstance(player), rank.material()), VAR_PRICE, Money.format(addon, price)), ui(player, "market.hull-tooltip", "[slots]", String.valueOf(rank.slots())), () -> { @@ -444,8 +449,8 @@ public void openShipwright(Player player, IslandSpec spec) { // lies - that gets the same confirmation a capture // does, because it is the same decision if (addon.getMarketService().wouldReplaceCurrent(player, spec, rank)) { - confirmBoatCapture(player, MarketService.pretty(rank.material()), - MarketService.pretty(current), () -> { + confirmBoatCapture(player, ItemNames.label(User.getInstance(player), rank.material()), + ItemNames.label(User.getInstance(player), current), () -> { addon.getMarketService().buyBoat(player, spec, rank); openShipwright(player, spec); }); @@ -485,7 +490,7 @@ public void openShipwright(Player player, IslandSpec spec) { openShipwright(player, spec); })); } - show(player, ui(player, "market.shipwright-title", "[name]", spec.name()), body, buttons, + show(player, ui(player, "market.shipwright-title", "[name]", portName(player, spec)), body, buttons, backButton(player, spec), 1); } @@ -605,6 +610,10 @@ private java.util.Map distinctGoods(Player player) { * A translated UI component. All dialog text goes through the locale so it * can be translated - never build player-facing strings in code. */ + private String portName(Player player, IslandSpec spec) { + return PortNames.display(addon, User.getInstance(player), spec); + } + private Component ui(Player player, String key, String... variables) { return User.getInstance(player).getTranslationAsComponent("tradewinds.ui." + key, variables); } diff --git a/src/main/java/world/bentobox/tradewinds/listeners/SpawnRespawnListener.java b/src/main/java/world/bentobox/tradewinds/listeners/SpawnRespawnListener.java index 8caf2c9..199c986 100644 --- a/src/main/java/world/bentobox/tradewinds/listeners/SpawnRespawnListener.java +++ b/src/main/java/world/bentobox/tradewinds/listeners/SpawnRespawnListener.java @@ -9,6 +9,7 @@ import org.bukkit.event.player.PlayerRespawnEvent; import world.bentobox.bentobox.api.user.User; +import world.bentobox.tradewinds.economy.ItemNames; import world.bentobox.tradewinds.TradeWinds; /** @@ -132,9 +133,9 @@ private void grantRespawnBoat(org.bukkit.entity.Player player) { boolean replacing = addon.getHoldService().active(player.getUniqueId()).isPresent(); var hold = addon.getBoatService().createFor(player, boat); addon.getBoatService().giveBoatItem(player, hold); - User.getInstance(player).sendMessage( - replacing ? "tradewinds.boat.respawn-given-replacing" : "tradewinds.boat.respawn-given", - "[material]", world.bentobox.tradewinds.economy.PriceEngine.prettify(boat.name())); + User user = User.getInstance(player); + user.sendMessage(replacing ? "tradewinds.boat.respawn-given-replacing" : "tradewinds.boat.respawn-given", + "[material]", ItemNames.label(user, boat)); } /** diff --git a/src/main/java/world/bentobox/tradewinds/ocean/IslandType.java b/src/main/java/world/bentobox/tradewinds/ocean/IslandType.java index 62a5684..c811c5c 100644 --- a/src/main/java/world/bentobox/tradewinds/ocean/IslandType.java +++ b/src/main/java/world/bentobox/tradewinds/ocean/IslandType.java @@ -42,6 +42,17 @@ public List getBiomeKeys() { return biomeKeys; } + /** + * The locale key for this type's name, for every player-facing line that + * says what kind of island a port is. The enum name is an identifier + * (config keys, island metadata), never text a sailor should read. + * + * @return locale key + */ + public String getLocaleKey() { + return "tradewinds.type." + name().toLowerCase(java.util.Locale.ENGLISH); + } + /** * @return true if this type's surrounding waters freeze into fast ice lanes */ diff --git a/src/main/java/world/bentobox/tradewinds/ocean/NameGenerator.java b/src/main/java/world/bentobox/tradewinds/ocean/NameGenerator.java index f95725b..8a46753 100644 --- a/src/main/java/world/bentobox/tradewinds/ocean/NameGenerator.java +++ b/src/main/java/world/bentobox/tradewinds/ocean/NameGenerator.java @@ -1,10 +1,20 @@ package world.bentobox.tradewinds.ocean; +import java.util.ArrayList; +import java.util.List; +import java.util.Locale; + /** * Elite-BBC-style procedural island name generator: names are built from 2-4 * two-letter tokens drawn from the classic digraph table, so they come out * pronounceable and distinct ("Lave", "Zaonce", "Tibedied"...). Pure function * of the input hash. + *

+ * The token sequence is exposed as well as the joined name because a locale + * may transliterate each token into its own script (GitHub #7): "LA"+"VE" + * is "Lave" in English and whatever the Chinese locale says those syllables + * are. The joined English name is the island's IDENTITY - island registry, + * resident tags, logs - and must never change for a given hash. * * @author tastybento */ @@ -29,29 +39,62 @@ private NameGenerator() { * @return a capitalized, pronounceable island name (2-8 letters) */ public static String name(long hash) { + return join(tokens(hash)); + } + + /** + * The tokens a name is built from, in order, skips removed: upper-case + * digraphs from the table, plus the single letter "A" (the table's "A." + * slot). {@link #name(long)} is exactly the join of these. + * + * @param hash seed for this name + * @return the tokens, never fewer than needed for a 3-letter name + */ + public static List tokens(long hash) { long h = hash; - StringBuilder sb = new StringBuilder(); + List tokens = new ArrayList<>(); + int letters = 0; // 3 or 4 token slots - int tokens = 3 + (int) (Hashing.mix(h) & 1); - for (int i = 0; i < tokens; i++) { + int slots = 3 + (int) (Hashing.mix(h) & 1); + for (int i = 0; i < slots; i++) { h = Hashing.mix(h + i + 1); - String pair = PAIRS[Math.floorMod(h, PAIRS.length)]; - for (char c : pair.toCharArray()) { - if (c != '.') { - sb.append(c); - } - } + letters += add(tokens, PAIRS[Math.floorMod(h, PAIRS.length)]); } - while (sb.length() < 3) { + while (letters < 3) { // Degenerate skip-heavy roll: keep appending until pronounceable h = Hashing.mix(h); - for (char c : PAIRS[1 + Math.floorMod(h, PAIRS.length - 1)].toCharArray()) { - if (c != '.') { - sb.append(c); - } - } + letters += add(tokens, PAIRS[1 + Math.floorMod(h, PAIRS.length - 1)]); } - String s = sb.toString().toLowerCase(); + return tokens; + } + + /** + * Every token the table can produce, for a locale to transliterate: the + * 30 digraphs and the lone "A". + */ + public static List vocabulary() { + List all = new ArrayList<>(); + for (String pair : PAIRS) { + add(all, pair); + } + return all; + } + + /** + * The canonical English rendering of a token sequence: lower case, first + * letter capitalized. + */ + public static String join(List tokens) { + String s = String.join("", tokens).toLowerCase(Locale.ENGLISH); return Character.toUpperCase(s.charAt(0)) + s.substring(1); } + + /** Add a table entry minus its skip markers; returns the letters added. */ + private static int add(List into, String pair) { + String token = pair.replace(".", ""); + if (!token.isEmpty()) { + into.add(token); + } + return token.length(); + } } diff --git a/src/main/java/world/bentobox/tradewinds/ocean/OceanEngine.java b/src/main/java/world/bentobox/tradewinds/ocean/OceanEngine.java index f03744d..f16922b 100644 --- a/src/main/java/world/bentobox/tradewinds/ocean/OceanEngine.java +++ b/src/main/java/world/bentobox/tradewinds/ocean/OceanEngine.java @@ -236,6 +236,18 @@ public Seabed getSeabed() { return seabed; } + /** + * The name tokens of an island generated by this engine, for a locale to + * transliterate (GitHub #7). Recomputed from the cell, so it is exactly + * what {@link IslandSpec#name()} was joined from. + * + * @param spec an island of this ocean + * @return its name tokens + */ + public List nameTokens(IslandSpec spec) { + return NameGenerator.tokens(Hashing.cellHash(config.seed(), spec.cellX(), spec.cellZ(), SALT_NAME)); + } + public OceanConfig getConfig() { return config; } diff --git a/src/main/java/world/bentobox/tradewinds/ocean/SecurityBand.java b/src/main/java/world/bentobox/tradewinds/ocean/SecurityBand.java index a58e84e..3cd5c03 100644 --- a/src/main/java/world/bentobox/tradewinds/ocean/SecurityBand.java +++ b/src/main/java/world/bentobox/tradewinds/ocean/SecurityBand.java @@ -8,24 +8,11 @@ * @author tastybento */ public enum SecurityBand { - SAFE("Safe"), - POLICED("Policed"), - FRONTIER("Frontier"), - LAWLESS("Lawless"), - ANARCHIC("Anarchic"); - - private final String displayName; - - SecurityBand(String displayName) { - this.displayName = displayName; - } - - public String getDisplayName() { - return displayName; - } + SAFE, POLICED, FRONTIER, LAWLESS, ANARCHIC; /** - * The locale key for this band's name. Bands are coloured in the locale so + * The locale key for this band's name - the ONLY way a band is named to a + * player; there is deliberately no English display name on the enum. Bands are coloured in the locale so * a sailor can read where they are at a glance - arriving in anarchic water * and having no way to tell is how people lose cargo. * diff --git a/src/main/java/world/bentobox/tradewinds/tasks/NavigationBarTask.java b/src/main/java/world/bentobox/tradewinds/tasks/NavigationBarTask.java index f837c92..c68527a 100644 --- a/src/main/java/world/bentobox/tradewinds/tasks/NavigationBarTask.java +++ b/src/main/java/world/bentobox/tradewinds/tasks/NavigationBarTask.java @@ -12,6 +12,7 @@ import net.kyori.adventure.bossbar.BossBar; import net.kyori.adventure.text.Component; import world.bentobox.bentobox.api.user.User; +import world.bentobox.tradewinds.PortNames; import world.bentobox.tradewinds.TradeWinds; import world.bentobox.tradewinds.ocean.DockPlan; import world.bentobox.tradewinds.ocean.OceanEngine; @@ -179,7 +180,7 @@ record Reading(IslandSpec island, int dockDistance, float progress) { private void show(Player player, Reading reading) { User user = User.getInstance(player); Component name = user.getTranslationAsComponent("tradewinds.hud.navigation", - "[name]", reading.island().name(), + "[name]", PortNames.display(addon, user, reading.island()), "[tech]", String.valueOf(reading.island().techLevel()), "[band]", user.getTranslation(reading.island().band().getLocaleKey()), "[standing]", addon.getPlayerStanding(user, player.getUniqueId()), diff --git a/src/main/java/world/bentobox/tradewinds/travel/BoatCraftListener.java b/src/main/java/world/bentobox/tradewinds/travel/BoatCraftListener.java index dc79da2..df4afc6 100644 --- a/src/main/java/world/bentobox/tradewinds/travel/BoatCraftListener.java +++ b/src/main/java/world/bentobox/tradewinds/travel/BoatCraftListener.java @@ -9,6 +9,7 @@ import org.bukkit.inventory.ItemStack; import world.bentobox.bentobox.api.user.User; +import world.bentobox.tradewinds.economy.ItemNames; import world.bentobox.tradewinds.TradeWinds; /** @@ -92,8 +93,8 @@ private void refitBoat(org.bukkit.entity.HumanEntity crafter, ItemStack result, () -> stampCrafted(player, result.getType(), hold), 5L); } removeOldBoat(player, old, hold); - User.getInstance(player).sendMessage("tradewinds.trade.boat-crafted", "[material]", - world.bentobox.tradewinds.economy.PriceEngine.prettify(result.getType().name()), + User user = User.getInstance(player); + user.sendMessage("tradewinds.trade.boat-crafted", "[material]", ItemNames.label(user, result.getType()), "[slots]", String.valueOf(newSlots)); } diff --git a/src/main/java/world/bentobox/tradewinds/travel/BoatListener.java b/src/main/java/world/bentobox/tradewinds/travel/BoatListener.java index 6be1507..3322ae3 100644 --- a/src/main/java/world/bentobox/tradewinds/travel/BoatListener.java +++ b/src/main/java/world/bentobox/tradewinds/travel/BoatListener.java @@ -26,6 +26,7 @@ import org.bukkit.scheduler.BukkitTask; import world.bentobox.bentobox.api.user.User; +import world.bentobox.tradewinds.economy.ItemNames; import world.bentobox.tradewinds.TradeWinds; import world.bentobox.tradewinds.dataobjects.BoatHold; import world.bentobox.tradewinds.ocean.IslandSpec; @@ -429,7 +430,7 @@ public void onPickup(EntityPickupItemEvent event) { addon.getBoatService().claim(player, hold); addon.getBoatService().giveBoatItem(player, hold); User.getInstance(player).sendMessage("tradewinds.boat.claimed", MATERIAL_PLACEHOLDER, - pretty(Material.matchMaterial(hold.getMaterial()))); + pretty(player, Material.matchMaterial(hold.getMaterial()))); return; } // Someone else's hull, and they have a boat of their own: ask. Pickup @@ -457,13 +458,13 @@ public void onPickup(EntityPickupItemEvent event) { * @param demoted the boat this displaced, or null if it was already theirs */ private void announceOwnPickup(Player player, BoatHold hold, BoatHold demoted) { - String material = pretty(Material.matchMaterial(hold.getMaterial())); + String material = pretty(player, Material.matchMaterial(hold.getMaterial())); if (demoted == null) { User.getInstance(player).sendMessage("tradewinds.boat.own-aboard", MATERIAL_PLACEHOLDER, material); return; } User.getInstance(player).sendMessage("tradewinds.boat.own-resumed", MATERIAL_PLACEHOLDER, material, - "[old]", pretty(Material.matchMaterial(demoted.getMaterial()))); + "[old]", pretty(player, Material.matchMaterial(demoted.getMaterial()))); } /** @@ -512,7 +513,7 @@ void takeBoat(Player player, BoatHold hold) { addon.getHoldManager().clearOldBoat(player.getUniqueId()); } User.getInstance(player).sendMessage(old.isEmpty() ? "tradewinds.boat.merged-empty" - : "tradewinds.boat.merged-partial", MATERIAL_PLACEHOLDER, pretty(Material.matchMaterial(old.getMaterial()))); + : "tradewinds.boat.merged-partial", MATERIAL_PLACEHOLDER, pretty(player, Material.matchMaterial(old.getMaterial()))); } /** @@ -547,7 +548,7 @@ private void offerFoundBoat(Player player, BoatHold hold, Runnable onTake) { } else { onTransfer = null; } - addon.getTradeDialog().confirmBoatFound(player, pretty(taking), pretty(leaving), cargo, onTake, + addon.getTradeDialog().confirmBoatFound(player, pretty(player, taking), pretty(player, leaving), cargo, onTake, onTransfer); } @@ -667,7 +668,7 @@ public void onJoin(PlayerJoinEvent event) { BoatHold raft = addon.getBoatService().createFor(player, respawnBoat()); addon.getBoatService().giveBoatItem(player, raft); User.getInstance(player).sendMessage("tradewinds.boat.adrift-raft", MATERIAL_PLACEHOLDER, - pretty(respawnBoat())); + pretty(player, respawnBoat())); } } }, 20L); @@ -708,7 +709,7 @@ private boolean inOurWorlds(World world) { return world.equals(addon.getOverWorld()) || world.equals(addon.getNetherWorld()); } - private static String pretty(Material material) { - return material == null ? "?" : world.bentobox.tradewinds.economy.PriceEngine.prettify(material.name()); + private static String pretty(Player player, Material material) { + return ItemNames.label(User.getInstance(player), material); } } diff --git a/src/main/java/world/bentobox/tradewinds/travel/BoatService.java b/src/main/java/world/bentobox/tradewinds/travel/BoatService.java index 65048b1..3ea982a 100644 --- a/src/main/java/world/bentobox/tradewinds/travel/BoatService.java +++ b/src/main/java/world/bentobox/tradewinds/travel/BoatService.java @@ -17,6 +17,7 @@ import world.bentobox.bentobox.api.user.User; import world.bentobox.tradewinds.TradeWinds; +import world.bentobox.tradewinds.economy.ItemNames; import world.bentobox.tradewinds.dataobjects.BoatHold; /** @@ -105,15 +106,19 @@ public ItemStack stamp(ItemStack stack, BoatHold hold) { ItemMeta meta = stack.getItemMeta(); if (meta != null) { meta.getPersistentDataContainer().set(BOAT_ID_KEY, PersistentDataType.STRING, hold.getUniqueId()); - User console = User.getInstance(Bukkit.getConsoleSender()); + User user = ownerOrConsole(hold); Material material = Material.matchMaterial(hold.getMaterial()); + // The hull's own name, through the locale: item_name replaces the + // vanilla name without the renamed-on-an-anvil italics + meta.itemName(user.getTranslationAsComponent("tradewinds.item.boat-name", "[material]", + ItemNames.label(user, material))); meta.lore(java.util.List.of( - console.getTranslationAsComponent("tradewinds.item.boat-lore-cargo", + user.getTranslationAsComponent("tradewinds.item.boat-lore-cargo", "[used]", String.valueOf(HoldService.slotsUsedIn(hold)), "[slots]", String.valueOf(addon.getBoatRanks().slots(material))), - console.getTranslationAsComponent("tradewinds.item.boat-lore-fuel", + user.getTranslationAsComponent("tradewinds.item.boat-lore-fuel", "[units]", String.format("%.0f", addon.getFuelService().unitsOf(hold))), - console.getTranslationAsComponent("tradewinds.item.boat-lore-open", NO_VARS))); + user.getTranslationAsComponent("tradewinds.item.boat-lore-open", NO_VARS))); stack.setItemMeta(meta); } return stack; @@ -241,17 +246,33 @@ public void label(Entity entity, BoatHold hold) { boat.setCustomNameVisible(false); return; } - User console = User.getInstance(Bukkit.getConsoleSender()); + User user = ownerOrConsole(hold); if (hold.isUnowned()) { - boat.customName(console.getTranslationAsComponent("tradewinds.boat.label-unowned", NO_VARS)); + boat.customName(user.getTranslationAsComponent("tradewinds.boat.label-unowned", NO_VARS)); } else { String name = Bukkit.getOfflinePlayer(UUID.fromString(hold.getOwner())).getName(); - boat.customName(console.getTranslationAsComponent("tradewinds.boat.label-owned", "[name]", - name == null ? "?" : name)); + boat.customName(user.getTranslationAsComponent("tradewinds.boat.label-owned", "[name]", + name == null ? user.getTranslation("tradewinds.general.unknown") : name)); } boat.setCustomNameVisible(true); } + /** + * Whose locale a boat's baked-in text (item name, lore, floating label) is + * written in: the owner's when they are online, else the server's. Text on + * an item or an entity is one string for every viewer, so the owner - who + * reads it most - is the best choice there is. + */ + private User ownerOrConsole(BoatHold hold) { + if (!hold.isUnowned()) { + Player owner = Bukkit.getPlayer(UUID.fromString(hold.getOwner())); + if (owner != null) { + return User.getInstance(owner); + } + } + return User.getInstance(Bukkit.getConsoleSender()); + } + /** * The record's placed avatar in the world, if any is loaded: the Boat * entity it rides as, or the dropped item that carries its stamp. diff --git a/src/main/java/world/bentobox/tradewinds/travel/ChartHolograms.java b/src/main/java/world/bentobox/tradewinds/travel/ChartHolograms.java index d02c00e..c88eccc 100644 --- a/src/main/java/world/bentobox/tradewinds/travel/ChartHolograms.java +++ b/src/main/java/world/bentobox/tradewinds/travel/ChartHolograms.java @@ -17,6 +17,7 @@ import net.kyori.adventure.text.Component; import world.bentobox.bentobox.api.user.User; +import world.bentobox.tradewinds.PortNames; import world.bentobox.tradewinds.TradeWinds; import world.bentobox.tradewinds.ocean.DockPlan; import world.bentobox.tradewinds.ocean.IslandSpec; @@ -300,11 +301,12 @@ private Component label(Player player, Marker marker) { IslandSpec spec = marker.island(); if (marker.dock()) { return User.getInstance(player).getTranslationAsComponent("tradewinds.hologram.dock", - "[name]", spec.name(), PLACEHOLDER_DISTANCE, String.valueOf(marker.distance())); + "[name]", PortNames.display(addon, User.getInstance(player), spec), PLACEHOLDER_DISTANCE, + String.valueOf(marker.distance())); } return User.getInstance(player).getTranslationAsComponent("tradewinds.hologram.island", - "[name]", spec.name(), - "[type]", spec.type().name(), + "[name]", PortNames.display(addon, User.getInstance(player), spec), + "[type]", User.getInstance(player).getTranslation(spec.type().getLocaleKey()), "[tech]", String.valueOf(spec.techLevel()), "[band]", User.getInstance(player).getTranslation(spec.band().getLocaleKey()), PLACEHOLDER_DISTANCE, String.valueOf(marker.distance())); diff --git a/src/main/java/world/bentobox/tradewinds/travel/ChartingListener.java b/src/main/java/world/bentobox/tradewinds/travel/ChartingListener.java index ce2ab57..4ac527a 100644 --- a/src/main/java/world/bentobox/tradewinds/travel/ChartingListener.java +++ b/src/main/java/world/bentobox/tradewinds/travel/ChartingListener.java @@ -11,6 +11,7 @@ import org.bukkit.event.vehicle.VehicleEnterEvent; import world.bentobox.bentobox.api.user.User; +import world.bentobox.tradewinds.PortNames; import world.bentobox.tradewinds.TradeWinds; import world.bentobox.tradewinds.api.events.IslandChartedEvent; import world.bentobox.tradewinds.dataobjects.TWPlayerData; @@ -80,7 +81,8 @@ public void onMove(PlayerMoveEvent event) { .forEach(spec -> { // The locale string carries an [actionbar] marker, so // BentoBox delivers and formats it - never do that here - User.getInstance(player).sendMessage("tradewinds.chart.charted", "[name]", spec.name()); + User user = User.getInstance(player); + user.sendMessage("tradewinds.chart.charted", "[name]", PortNames.display(addon, user, spec)); addon.getPlayerDataManager().save(player.getUniqueId()); Bukkit.getPluginManager().callEvent(new IslandChartedEvent(player.getUniqueId(), spec)); }); diff --git a/src/main/java/world/bentobox/tradewinds/travel/HoldGui.java b/src/main/java/world/bentobox/tradewinds/travel/HoldGui.java index debb79d..ebd9bb2 100644 --- a/src/main/java/world/bentobox/tradewinds/travel/HoldGui.java +++ b/src/main/java/world/bentobox/tradewinds/travel/HoldGui.java @@ -26,7 +26,10 @@ import org.bukkit.inventory.ItemStack; import org.bukkit.inventory.meta.ItemMeta; +import net.kyori.adventure.text.Component; import world.bentobox.bentobox.api.user.User; +import world.bentobox.bentobox.util.Util; +import world.bentobox.tradewinds.economy.ItemNames; import world.bentobox.tradewinds.TradeWinds; /** @@ -195,7 +198,7 @@ private ItemStack tnt(User user) { ItemStack tnt = new ItemStack(Material.TNT); ItemMeta meta = tnt.getItemMeta(); meta.displayName(user.getTranslationAsComponent("tradewinds.hold.tnt", NO_VARS)); - meta.lore(List.of(user.getTranslationAsComponent("tradewinds.hold.tnt-lore", NO_VARS))); + meta.lore(loreLines(user, "tradewinds.hold.tnt-lore")); tnt.setItemMeta(meta); return tnt; } @@ -207,7 +210,7 @@ private ItemStack expanderItem(User user, UUID id, int index) { PLACEHOLDER_NUMBER, String.valueOf(index + 1))); String loreKey = addon.getHoldService().expandersOpenable(id) ? "tradewinds.hold.expander-lore" : "tradewinds.hold.expander-inert-lore"; - meta.lore(List.of(user.getTranslationAsComponent(loreKey, NO_VARS))); + meta.lore(loreLines(user, loreKey)); item.setItemMeta(meta); return item; } @@ -253,12 +256,27 @@ private static List stacksOf(Map contents) { return stacks; } + /** + * A lore entry as lines: the locale string split on newlines, one lore + * line each, the same convention BentoBox's own panels use. A tooltip + * never wraps, so a long instruction must be broken by hand - and each + * line must carry its own colour tags, because a tag left open on one + * line does not reach the next. + */ + public static List loreLines(User user, String key) { + List lines = new ArrayList<>(); + for (String line : user.getTranslationNoColor(key).split("\n")) { + lines.add(Util.parseMiniMessageOrLegacy(line)); + } + return lines; + } + private ItemStack pane(Material material, User user, String nameKey, String loreKey) { ItemStack pane = new ItemStack(material); ItemMeta meta = pane.getItemMeta(); meta.displayName(user.getTranslationAsComponent(nameKey, NO_VARS)); if (loreKey != null) { - meta.lore(List.of(user.getTranslationAsComponent(loreKey, NO_VARS))); + meta.lore(loreLines(user, loreKey)); } pane.setItemMeta(meta); return pane; @@ -525,7 +543,7 @@ private void handleCargoClick(Player player, UUID id, ItemStack shown, ClickType selected.put(id, new Selection(shown, shown.getAmount(), -1)); User user = User.getInstance(player); user.sendMessage("tradewinds.hold.selected", PLACEHOLDER_AMOUNT, String.valueOf(shown.getAmount()), - PLACEHOLDER_MATERIAL, world.bentobox.tradewinds.economy.PriceEngine.prettify(shown.getType().name())); + PLACEHOLDER_MATERIAL, ItemNames.label(user, shown.getType())); return; } if (click.isShiftClick()) { @@ -577,7 +595,7 @@ private void heldClick(Player player, Inventory clicked, Inventory top, int slot return; } user.sendMessage("tradewinds.hold.withdrawn", PLACEHOLDER_AMOUNT, String.valueOf(taken), PLACEHOLDER_MATERIAL, - world.bentobox.tradewinds.economy.PriceEngine.prettify(holding.item().getType().name())); + ItemNames.label(user, holding.item().getType())); chime(player); clearHeld(player); } @@ -653,7 +671,7 @@ private void withdrawCargo(Player player, ItemStack shown) { thud(player); } else { user.sendMessage("tradewinds.hold.withdrawn", PLACEHOLDER_AMOUNT, String.valueOf(taken), PLACEHOLDER_MATERIAL, - world.bentobox.tradewinds.economy.PriceEngine.prettify(shown.getType().name())); + ItemNames.label(user, shown.getType())); chime(player); } } @@ -663,7 +681,7 @@ private void moveCargoFuel(Player player, ItemStack shown) { User user = User.getInstance(player); if (moved > 0) { user.sendMessage("tradewinds.hold.moved-to-fuel", PLACEHOLDER_AMOUNT, String.valueOf(moved), - PLACEHOLDER_MATERIAL, world.bentobox.tradewinds.economy.PriceEngine.prettify(shown.getType().name())); + PLACEHOLDER_MATERIAL, ItemNames.label(user, shown.getType())); chime(player); } else { user.sendMessage(MESSAGE_FUEL_FULL); @@ -683,7 +701,7 @@ private void nestedTopClick(Player player, int slot, ItemStack shown, int index) int destroyed = addon.getHoldService().removeFromExpander(id, index, selection.item(), selection.amount()); user.sendMessage("tradewinds.hold.destroyed", PLACEHOLDER_AMOUNT, String.valueOf(destroyed), PLACEHOLDER_MATERIAL, - world.bentobox.tradewinds.economy.PriceEngine.prettify(selection.item().getType().name())); + ItemNames.label(user, selection.item().getType())); player.playSound(player.getLocation(), Sound.ENTITY_GENERIC_EXPLODE, 0.4f, 1.4f); return; } @@ -698,7 +716,7 @@ private void nestedTopClick(Player player, int slot, ItemStack shown, int index) } nestedSelected.put(id, new Selection(shown, shown.getAmount(), -1)); user.sendMessage("tradewinds.hold.selected", PLACEHOLDER_AMOUNT, String.valueOf(shown.getAmount()), - PLACEHOLDER_MATERIAL, world.bentobox.tradewinds.economy.PriceEngine.prettify(shown.getType().name())); + PLACEHOLDER_MATERIAL, ItemNames.label(user, shown.getType())); } private void destroySelection(Player player, Selection selection) { @@ -719,7 +737,7 @@ private void destroySelection(Player player, Selection selection) { } int destroyed = addon.getHoldService().remove(player, selection.item(), selection.amount()); user.sendMessage("tradewinds.hold.destroyed", PLACEHOLDER_AMOUNT, String.valueOf(destroyed), PLACEHOLDER_MATERIAL, - world.bentobox.tradewinds.economy.PriceEngine.prettify(selection.item().getType().name())); + ItemNames.label(user, selection.item().getType())); player.playSound(player.getLocation(), Sound.ENTITY_GENERIC_EXPLODE, 0.4f, 1.4f); } diff --git a/src/main/java/world/bentobox/tradewinds/travel/IntersticeService.java b/src/main/java/world/bentobox/tradewinds/travel/IntersticeService.java index 8ecdc8a..088ed6b 100644 --- a/src/main/java/world/bentobox/tradewinds/travel/IntersticeService.java +++ b/src/main/java/world/bentobox/tradewinds/travel/IntersticeService.java @@ -29,6 +29,7 @@ import net.kyori.adventure.text.event.ClickCallback; import world.bentobox.bentobox.api.user.User; import world.bentobox.bentobox.util.Util; +import world.bentobox.tradewinds.PortNames; import world.bentobox.tradewinds.TradeWinds; import world.bentobox.tradewinds.api.events.TWWarpFailedEvent; import world.bentobox.tradewinds.ocean.IslandSpec; @@ -411,7 +412,8 @@ public void openReEngageDialog(Player player) { } User user = User.getInstance(player); ActionButton engage = ActionButton.create( - user.getTranslationAsComponent("tradewinds.ui.interstice.re-engage", "[name]", target.name()), + user.getTranslationAsComponent("tradewinds.ui.interstice.re-engage", "[name]", + PortNames.display(addon, user, target)), user.getTranslationAsComponent("tradewinds.ui.interstice.re-engage-tooltip", NO_VARS), 320, DialogAction.customClick((response, audience) -> reEngage(player, target), ClickCallback.Options.builder().build())); diff --git a/src/main/java/world/bentobox/tradewinds/travel/StarChartRenderer.java b/src/main/java/world/bentobox/tradewinds/travel/StarChartRenderer.java index f1af5bc..d88cf8f 100644 --- a/src/main/java/world/bentobox/tradewinds/travel/StarChartRenderer.java +++ b/src/main/java/world/bentobox/tradewinds/travel/StarChartRenderer.java @@ -14,6 +14,8 @@ import org.bukkit.map.MapPalette; import org.bukkit.map.MinecraftFont; +import world.bentobox.tradewinds.PortNames; +import world.bentobox.tradewinds.ocean.IslandSpec; import world.bentobox.tradewinds.TradeWinds; import world.bentobox.tradewinds.ocean.OceanEngine; import world.bentobox.tradewinds.ocean.SecurityBand; @@ -128,13 +130,14 @@ private void drawChartedIslands(MapCanvas canvas, Player player, OceanEngine eng String[] cell = key.split(","); engine.islandInCell(Integer.parseInt(cell[0]), Integer.parseInt(cell[1])).ifPresent(spec -> { int[] pixel = toPixel(spec.centerX() - (long) px, spec.centerZ() - (long) pz, bpp); + String name = drawableName(player, spec); if (pixel[2] == 1) { canvas.setPixelColor(pixel[0], pixel[1], EDGE); - drawName(canvas, pixel[0], pixel[1], spec.name()); + drawName(canvas, pixel[0], pixel[1], name); } else { Color color = bandColor(spec.band()); fillDot(canvas, pixel[0], pixel[1], islandPixelRadius, color); - drawName(canvas, pixel[0], pixel[1], spec.name()); + drawName(canvas, pixel[0], pixel[1], name); } }); } @@ -205,6 +208,21 @@ private void fillDot(MapCanvas canvas, int cx, int cz, int radius, Color color) } } + /** + * The port's name in the player's locale if the map font can draw it, + * else the canonical Latin name. {@link MinecraftFont} is a bitmap of the + * ASCII range and {@code drawText} throws on anything else, so a Chinese + * locale gets Chinese names everywhere but the chart. + */ + public static String drawableName(IslandSpec spec, String localized) { + return MinecraftFont.Font.isValid(localized) ? localized : spec.name(); + } + + private String drawableName(Player player, IslandSpec spec) { + return drawableName(spec, + PortNames.display(addon, world.bentobox.bentobox.api.user.User.getInstance(player), spec)); + } + /** * Island names, in white. MapCanvas takes its text colour from a * "section-sign, palette index, semicolon" prefix; the default was a mid diff --git a/src/main/java/world/bentobox/tradewinds/travel/WarpService.java b/src/main/java/world/bentobox/tradewinds/travel/WarpService.java index 0498cc2..35edf98 100644 --- a/src/main/java/world/bentobox/tradewinds/travel/WarpService.java +++ b/src/main/java/world/bentobox/tradewinds/travel/WarpService.java @@ -24,6 +24,7 @@ import net.kyori.adventure.text.Component; import net.kyori.adventure.text.event.ClickCallback; import world.bentobox.bentobox.util.Util; +import world.bentobox.tradewinds.PortNames; import world.bentobox.tradewinds.TradeWinds; import world.bentobox.tradewinds.api.events.TWWarpCompletedEvent; import world.bentobox.tradewinds.api.events.TWWarpEvent; @@ -114,7 +115,7 @@ public void openDialog(Player player, IslandSpec origin) { List buttons = destinations.stream().map(dest -> button(player, origin, dest)).toList(); Dialog dialog = Dialog.create(factory -> factory.empty() .base(DialogBase.builder(user(player).getTranslationAsComponent("tradewinds.ui.warp.title", - NAME_PLACEHOLDER, origin.name())) + NAME_PLACEHOLDER, PortNames.display(addon, user(player), origin))) .body(List.of(DialogBody.plainMessage( user(player).getTranslationAsComponent("tradewinds.ui.warp.fuel", "[amount]", String.valueOf((int) fuelAboard))))) @@ -131,7 +132,8 @@ private ActionButton button(Player player, IslandSpec origin, Destination dest) // A home has no port sheet - no type, no tech, no market Component homeLabel = user(player).getTranslationAsComponent( dest.affordable() ? "tradewinds.ui.warp.home" : "tradewinds.ui.warp.home-poor", - NAME_PLACEHOLDER, spec.name(), "[fuel]", String.valueOf(dest.fuelCost())); + NAME_PLACEHOLDER, PortNames.display(addon, user(player), spec), "[fuel]", + String.valueOf(dest.fuelCost())); Component homeTooltip = user(player).getTranslationAsComponent( "tradewinds.ui.warp.home-tooltip", "[distance]", distance); return ActionButton.create(homeLabel, homeTooltip, 250, DialogAction.customClick( @@ -145,10 +147,12 @@ private ActionButton button(Player player, IslandSpec origin, Destination dest) } Component label = user(player).getTranslationAsComponent( dest.affordable() ? "tradewinds.ui.warp.destination" : "tradewinds.ui.warp.destination-poor", - NAME_PLACEHOLDER, spec.name(), "[fuel]", String.valueOf(dest.fuelCost())); + NAME_PLACEHOLDER, PortNames.display(addon, user(player), spec), "[fuel]", + String.valueOf(dest.fuelCost())); Component tooltip = user(player).getTranslationAsComponent("tradewinds.ui.warp.destination-tooltip", - "[type]", spec.type().name(), "[tech]", String.valueOf(spec.techLevel()), - "[band]", spec.band().getDisplayName(), "[distance]", distance); + "[type]", user(player).getTranslation(spec.type().getLocaleKey()), + "[tech]", String.valueOf(spec.techLevel()), + "[band]", user(player).getTranslation(spec.band().getLocaleKey()), "[distance]", distance); DialogAction action = DialogAction.customClick( (response, audience) -> { if (dest.affordable()) { @@ -332,7 +336,8 @@ private void deliver(Player player, IslandSpec to, IslandSpec bearingFrom, int f } }, (addon.getSettings().getWarpBlindnessSeconds() + 1) * 20L); } - user(player).sendMessage("tradewinds.warp.arrived", NAME_PLACEHOLDER, to.name()); + user(player).sendMessage("tradewinds.warp.arrived", NAME_PLACEHOLDER, + PortNames.display(addon, user(player), to)); Bukkit.getPluginManager().callEvent(new TWWarpCompletedEvent(player, bearingFrom, to, fuelCost)); }); } diff --git a/src/main/resources/locales/en-US.yml b/src/main/resources/locales/en-US.yml index 68f5a2a..30adad6 100644 --- a/src/main/resources/locales/en-US.yml +++ b/src/main/resources/locales/en-US.yml @@ -12,6 +12,9 @@ tradewinds: worlds: overworld: "the open ocean" interstice: "the interstice" + # Shown where a value is missing: no price yet, no known name + none: "-" + unknown: "?" commands: help: description: "TradeWinds trading command" @@ -113,6 +116,8 @@ tradewinds: hull-carried: "Not restored: they are already carrying it." restored: "[name]'s [material] restored to their pack from the database, cargo record intact." spawn: + # The spawn port's name, wherever a port is named to a player + name: "Spawn" teleported: "You come ashore at the spawn port. Your boat is your hold and your livelihood - keep it close." resumed: "You take up your voyage where you left it." already-at-sea: "You are already at sea. There is no commanding your way across it - row, or warp from an island's border." @@ -220,6 +225,240 @@ tradewinds: frontier: "Frontier" lawless: "Lawless" anarchic: "ANARCHIC" + # The economic character of a port, wherever a line says what kind it is + type: + agricultural: "Agricultural" + forest: "Forest" + fishing: "Fishing" + mining: "Mining" + industrial: "Industrial" + luxury: "Luxury" + frozen: "Frozen" + # Port names are built from these syllables (2-4 per name: LA+VE is + # "Lave"). Write each one in your own script and every port name follows, + # the same for every player on this locale. Leave a line blank to keep that + # syllable as it is. The star chart map can only draw Latin letters, so it + # keeps the English names when a syllable is written in another script. + name-token: + le: "le" + xe: "xe" + ge: "ge" + za: "za" + ce: "ce" + bi: "bi" + so: "so" + us: "us" + es: "es" + ar: "ar" + ma: "ma" + in: "in" + di: "di" + re: "re" + a: "a" + er: "er" + at: "at" + en: "en" + be: "be" + ra: "ra" + la: "la" + ve: "ve" + ti: "ti" + ed: "ed" + or: "or" + qu: "qu" + an: "an" + te: "te" + is: "is" + ri: "ri" + # "on" must stay quoted: bare on/off/yes/no are YAML booleans, not words + "on": "on" + # Kinds of goods, as named in the logbook (/tw prices). The command still + # takes the English word as its argument. + category: + crops: "Crops" + food: "Food" + fish: "Fish" + wood: "Wood" + stone: "Stone" + ores: "Ores" + metals: "Metals" + gems: "Gems" + luxury: "Luxury" + misc: "Misc" + salvage: "Salvage" + # Names of the goods, fuels and hulls the ocean trades, one line per + # material (lower-case enum name). LEAVE A LINE BLANK and the player's own + # client translates that item into its language wherever the name appears + # in a dialog, menu or hologram - no translation needed. Fill a line in to + # override that, and to name the item in plain chat lines too, which the + # client cannot translate. Any material not listed here (salvage) is always + # translated by the client. + materials: + wheat: "" + carrot: "" + potato: "" + beetroot: "" + sugar_cane: "" + sugar: "" + pumpkin: "" + melon_slice: "" + bread: "" + cooked_beef: "" + cooked_cod: "" + cake: "" + golden_apple: "" + egg: "" + hay_block: "" + cod: "" + salmon: "" + tropical_fish: "" + pufferfish: "" + kelp: "" + oak_log: "" + spruce_log: "" + birch_log: "" + dark_oak_log: "" + acacia_log: "" + jungle_log: "" + cherry_log: "" + mangrove_log: "" + pale_oak_log: "" + stone: "" + cobblestone: "" + granite: "" + diorite: "" + andesite: "" + deepslate: "" + sand: "" + gravel: "" + coal: "" + charcoal: "" + raw_iron: "" + raw_copper: "" + raw_gold: "" + flint: "" + iron_ingot: "" + copper_ingot: "" + gold_ingot: "" + iron_nugget: "" + gold_nugget: "" + diamond: "" + emerald: "" + amethyst_shard: "" + quartz: "" + lapis_lazuli: "" + redstone: "" + leather: "" + white_wool: "" + orange_wool: "" + magenta_wool: "" + light_blue_wool: "" + yellow_wool: "" + lime_wool: "" + pink_wool: "" + gray_wool: "" + light_gray_wool: "" + cyan_wool: "" + purple_wool: "" + blue_wool: "" + brown_wool: "" + green_wool: "" + red_wool: "" + black_wool: "" + string: "" + beef: "" + porkchop: "" + chicken: "" + mutton: "" + bone: "" + bone_meal: "" + gunpowder: "" + spider_eye: "" + rotten_flesh: "" + feather: "" + ink_sac: "" + glow_ink_sac: "" + slime_ball: "" + rabbit: "" + rabbit_hide: "" + rabbit_foot: "" + ender_pearl: "" + blaze_rod: "" + ghast_tear: "" + phantom_membrane: "" + nether_star: "" + prismarine_shard: "" + prismarine_crystals: "" + nautilus_shell: "" + heart_of_the_sea: "" + shulker_shell: "" + echo_shard: "" + totem_of_undying: "" + dragon_breath: "" + turtle_scute: "" + armadillo_scute: "" + honeycomb: "" + saddle: "" + name_tag: "" + potion: "" + splash_potion: "" + lingering_potion: "" + apple: "" + melon: "" + sweet_berries: "" + glow_berries: "" + cocoa_beans: "" + bamboo: "" + cactus: "" + brown_mushroom: "" + red_mushroom: "" + sea_pickle: "" + sponge: "" + nether_wart: "" + clay_ball: "" + snowball: "" + dirt: "" + obsidian: "" + netherrack: "" + soul_sand: "" + end_stone: "" + glowstone_dust: "" + magma_cream: "" + oak_boat: "" + oak_chest_boat: "" + dried_kelp_block: "" + coal_block: "" + lava_bucket: "" + bamboo_raft: "" + spruce_boat: "" + bamboo_chest_raft: "" + birch_boat: "" + jungle_boat: "" + spruce_chest_boat: "" + acacia_boat: "" + birch_chest_boat: "" + dark_oak_boat: "" + jungle_chest_boat: "" + mangrove_boat: "" + acacia_chest_boat: "" + cherry_boat: "" + dark_oak_chest_boat: "" + pale_oak_boat: "" + mangrove_chest_boat: "" + cherry_chest_boat: "" + pale_oak_chest_boat: "" + # Outfitter gear + iron_sword: "" + shield: "" + iron_helmet: "" + iron_chestplate: "" + iron_leggings: "" + iron_boots: "" + white_bed: "" + cooked_mutton: "" + fishing_rod: "" + compass: "" + iron_pickaxe: "" fuel: low-actionbar: "[actionbar]LOW FUEL - [fuel]/[needed] to warp out. Buy fuel at the market." low-chat: "Low fuel at this port. You have [fuel] units; the cheapest warp from here needs [needed]. Buy at least [number] more from the market before you leave, or you will be rowing." @@ -327,6 +566,8 @@ tradewinds: stay: "Stay a while" stay-tooltip: "Close this and explore. /tw go brings the offer back whenever you are ready - the fuel is already spent." item: + # The hull's item name; [material] is the boat type + boat-name: "[material]" boat-lore-cargo: "Cargo: [used]/[slots] slots" boat-lore-fuel: "Fuel: [units] units" boat-lore-open: "Right-click to open the hold" @@ -383,13 +624,14 @@ tradewinds: title: "Ship's Hold - [slots] slots" border: " " tnt: "Destroy cargo" - tnt-lore: "Right-click a cargo stack to select it, then click here. Destroyed means GONE." + # Lore lines (-lore keys) break on \n; give every line its own colour tags + tnt-lore: "Right-click a cargo stack to select it,\nthen click here. Destroyed means GONE." locked: "Locked" locked-lore: "A bigger boat unlocks this slot." fuel-slot: "Fuel slot" fuel-only: "Only fuel goes in the tank." fuel-slot-lore: "Wood, coal, lava. Fuel moves freely in and out." - border-lore: "Pick up a stack and drop it: on the fuel row to fuel it, in your inventory to take it ashore, anywhere here to put it back. Shift-click for the quick route. Right-click selects for the TNT." + border-lore: "Pick up a stack and drop it:\non the fuel row to fuel it,\nin your inventory to take it ashore,\nanywhere here to put it back.\nShift-click for the quick route.\nRight-click selects for the TNT." withdrawn: "Taken from the hold: [amount] x [material]" withdraw-failed: "No room in your inventory for that." bought-cargo-locked: "That cargo was bought for trade - it leaves the hold by sale or by the TNT, not back ashore." diff --git a/src/main/resources/locales/zh-CN.yml b/src/main/resources/locales/zh-CN.yml new file mode 100644 index 0000000..9bf36c4 --- /dev/null +++ b/src/main/resources/locales/zh-CN.yml @@ -0,0 +1,702 @@ +--- +########################################################################################### +# 这是一个 YML 文件,编辑时请小心。可以用 YAML 检查器(例如 # +# http://yaml-online-parser.appspot.com)核对你的修改。 # +# # +# 颜色和格式使用 MiniMessage 标签:文字、文字 等。 # +# 旧式 & 颜色代码已弃用,请勿再添加。 # +########################################################################################### + +tradewinds: + general: + worlds: + overworld: "开阔海洋" + interstice: "间隙之海" + # 用于缺少数值的地方:暂无价格、名字未知 + none: "-" + unknown: "?" + commands: + help: + description: "TradeWinds 贸易命令" + spawn: + description: "起航 - 回到你上次离开海洋的地方" + warp: + description: "打开跃迁对话框(需在岛屿水域内乘船)" + chart: + description: "升起海图罗盘 - 岛屿、码头和你的船(加 'list' 显示文字)" + prices: + description: "你的航海日志:你到访过的港口曾出什么价,以及多久之前" + parameters: "[category]" + header: "航海日志 - 你到访过的港口:" + header-category: "航海日志 - 记忆中 [value] 的最佳价格:" + row: "[name] - 最佳:[value]([age])" + row-category: "[name] 出价 [price]([age])" + nothing-logged: "你的航海日志是空的。价格要在柜台前学到 - 到访一个港口,打开它的市场。" + unknown-category: "没有这种货物:[value]" + truncated: "……还有更多。只显示前 [number] 条。" + age-minutes: "[number] 分钟前" + age-hours: "[number] 小时前" + age-days: "[number] 天前" + starchart: + description: "领取一张标有你已测绘岛屿的星图" + trade: + description: "打开岛屿市场(需在其保护范围内)" + restart: + description: "重新开始你的贸易生涯(次数有限)" + fine: + description: "在港口了结你的犯罪记录(恢复为清白)" + rank: + description: "你的航海者等级,以及按测绘岛屿数排名的前十名水手" + claim: + description: "认领你脚下的荒野小岛作为你的岛屿" + sethome: + description: "设置你的家(需站在自己的岛屿上)" + home: + description: "前往你的家(需站在自己的岛屿上)" + unclaim: + description: "把你认领的小岛还给荒野(仅限岛主,队伍必须为空,不退款)" + admin: + help: + description: "TradeWinds 管理命令" + islands: + description: "列出最近的贸易岛屿" + none: "找不到贸易岛屿 - 海洋密度是 0 吗?" + header: "最近的贸易岛屿(用 /twadmin tpisland <#> 前往):" + entry: "[index]. [name] - [type](科技 [tech]),[band],位于 [x], [z] ([distance] 格)" + tpisland: + description: "传送到岛屿列表中的某个贸易岛屿" + parameters: "[number]" + unknown: "没有这个编号的岛屿 - 请先运行 /twadmin islands。" + teleported: "已到达 [name]。" + priceaudit: + description: "审计价格覆盖:哪些可出售、哪些是打捞品、哪些无法出售" + header: "价格审计:" + trade-goods: "已定价的贸易货物:[number]" + salvage: "已定价的打捞品:[number]" + unpriceable: "无法定价(可携带但不可出售):[number]" + written: "完整报告已写入 [file]" + write-failed: "无法写入报告 - 请查看控制台。" + customs: + description: "显示海关对你的看法:船上违禁品、检查几率、巡逻队" + header: "海关报告:" + gate: "犯罪系统已启用:[value]" + contraband: "违禁品列表:[value]" + aboard: "你货舱中的违禁品:[number]" + standing: "你的声望:[value]" + open-sea: "公海 - 这里不属于任何岛屿,没有人会检查你。" + island: "岛屿水域:[name]([band])" + scan-chance: "入境检查几率:[value]" + buys: "此港口收购违禁品:[value]" + patrol: "可派出的巡逻队:[number]" + chased: "追捕进行中:[value]" + warpfail: + description: "让某玩家的下一次跃迁失败(再次运行以取消)" + parameters: "" + rigged: "[name] 的下一次跃迁将会失败。再次运行此命令可取消。" + cleared: "[name] 的跃迁不再被动过手脚。" + rank: + description: "查看或设置玩家的航海者等级(按等级名、岛屿数,或 'reset')" + parameters: " [rank|islands|reset]" + current: "[name] 是 [rank] - [number] 座岛屿(实际测绘 [real],调整 [bonus])。" + unknown-rank: "没有这个等级:[name]。请使用配置中的等级标识、岛屿数量,或 'reset'。" + reflag: + description: "为所有贸易岛屿重新应用治安等级旗标" + done: "已为 [number] 座贸易岛屿重新应用治安旗标。" + boat: + description: "查看玩家的船只记录,或从数据库恢复丢失的现役船只" + parameters: " [restore]" + header: "数据库记录中 [name] 的船只:" + none: "没有任何船只记录。" + active: "现役 [material] ([id])- 货物 [used]/[slots],燃料 [fuel],最后位置 [world] [x],[y],[z] - [avatar]" + old: "旧船 [material] ([id])- 货物 [used]/[slots],燃料 [fuel],最后位置 [world] [x],[y],[z] - [avatar]" + avatar-loaded: "正漂在那里([type],位于 [x],[y],[z])" + avatar-with: "在 [name] 的背包里或脚下" + avatar-unloaded: "不在任何已加载的区块中 - 最后位置是线索(死亡掉落物在未加载区块中会冻结并保留)" + hull-exists: "未恢复:船体正漂在 [x],[y],[z] - 恢复会造出一个复制品。" + hull-carried: "未恢复:他们已经携带着它。" + restored: "已从数据库把 [name] 的 [material] 恢复到其背包,货物记录完好。" + spawn: + # 出生港的名字,用于所有向玩家显示港口名的地方 + name: "出生港" + teleported: "你在出生港上岸。你的船就是你的货舱和生计 - 别离它太远。" + resumed: "你从上次离开的地方继续航程。" + already-at-sea: "你已经在海上了。海洋不是靠命令穿越的 - 划船,或从岛屿边界跃迁。" + starter-kit: + given: "你的贸易生涯从一艘橡木船开始:3 个货物槽,燃料箱里有煤炭。船就是你的货舱 - 右键点击它打开。低买高卖。" + standing: + upstanding: "正直" + clean: "清白" + offender: "违法者" + wanted: "通缉犯" + fugitive: "逃犯" + changed: "[title]你的声望[subtitle][standing]" + crime: + hurt-villager: "市场看到了。声望 -[number]。" + kill-villager: "一名商人死了。声望 -[number],你的人头有了赏金。" + kill-police: "你杀了一名巡逻队员。声望 -[number]。" + kill-innocent: "谋杀。声望 -[number],你的人头被悬赏。" + smuggling: "被查获携带违禁品。声望 -[number]。" + passenger-trade: "消息传开了。声望 -[number]。" + bounty-paid: "已领取 [name] 的赏金:[amount]。" + fine-none: "你没有什么需要交代的。" + fine-quote: "港务长合计了你的记录:了结需要 [amount]。" + fine-paid: "罚款已付。你的记录干净了 - 你的名声就是另一回事了。" + fine-cannot-afford: "你付不起罚款([amount])。" + fine-no-market: "罚款要在贸易岛屿缴纳,不是在这里。" + fine-barred: "“我们不收你这种人的钱。”去个更乱的港口试试。" + police: + responding: "[name] 派出巡逻队来抓你了。他们不会追出边界。" + broken-off: "巡逻队在边界处折返。" + bounty-tag: " [[amount]]" + customs: + clean: "海关登上你的船,翻看货舱,然后挥手放行。" + detected: "[title]海关[subtitle][name] 派出了巡逻队" + detected-chat: "[name] 的海关检查了你的货舱:[number] 件违禁品。巡逻队已出发 - 逃跑、战斗,或抛弃货物。" + seized: "你带着违禁品站在市场里。海关直接没收:[number] 件被没收,罚款 [amount]。" + flagged: "[name] 记得你。这次不用检查 - 巡逻队已经在路上了。" + jettisoned: "你的货物被抛下船。巡逻队折返 - 什么也没查到。" + escaped: "开阔水域。[name] 跟丢了你 - 但它的港务长不会忘记。" + called-off: "[name] 的巡逻队放弃搜索,掉头回家。货物还在你手里。" + caught: "被登船了。[number] 件被没收,罚款 [amount],而且 [name] 记住了你的名字。" + rank: + # 航海者等级名称,与配置 ranks.thresholds 中的标识一一对应 + deck-hand: "甲板水手" + cabin-scout: "舱室侦察员" + coast-finder: "寻岸者" + bay-explorer: "海湾探险家" + island-hopper: "跳岛客" + reef-runner: "礁石飞驰者" + wave-master: "驭浪大师" + chart-maker: "制图师" + storm-navigator: "风暴领航员" + archipelago-ace: "群岛王牌" + tide-captain: "潮汐船长" + horizon-hunter: "地平线猎人" + sea-sentinel: "海洋哨兵" + salty-commodore: "老练准将" + ocean-overlord: "海洋霸主" + pirate-pathfinder: "海盗开路者" + grand-admiral: "大元帅" + globe-voyager: "环球航行者" + mythic-mariner: "传奇水手" + # /tw rank 的输出 + own: "你是 [rank] - 已测绘 [number] 座岛屿。" + next: "下一级:[rank] - 还需 [number] 座岛屿。" + top-of-ladder: "你已站在梯子的顶端。大海是你的了。" + board-header: "航行最远的水手:" + board-entry: "[place]. [name] - [number] 座岛屿([rank])" + claim: + success: "[title]发现陆地![subtitle]这座小岛是你的了。你以 [price] 买下了这座小岛。用队伍命令邀请你的船员 - 并守护属于你的东西。" + not-on-islet: "脚下没有荒野小岛。请站在你想认领的小岛上。" + already-claimed: "这座小岛已被认领。" + has-island: "你已经属于一座岛屿。请先放弃或离开它。" + rank-too-low: "港务长不会把土地交给没有资历的水手:认领需要 [rank] 等级(已测绘 [number] 座岛屿 - 你有 [charted] 座)。" + no-economy: "没有运行中的经济系统 - 本服务器已禁用认领。" + cannot-afford: "土地要花钱:[price],认领时全额支付。" + waters-taken: "这是港口的水域 - 请认领离贸易航线更远的小岛。" + home: + no-island: "你没有岛屿。请先认领一座荒野小岛。" + not-on-island: "只能在自己的岛上使用。家是在岸上安排的,不是隔着大海下命令。" + set: "家已设置。你将在此重生,在岛上时 /tw home 会把你带到这里。" + teleported: "你到家了。" + bearing: "家在 [direction] 方向,[number] 格之外。一帆风顺。" + port-name: "家" + course-on: "航向已设定。白天太阳指出方位;夜晚星星还会告诉你距离。再次 /tw go 收起星空。" + course-off: "你不再仰望星空。" + course-arrived: "家乡水域。航向已完成它的使命。" + unclaim: + owner-only: "只有岛主能把岛屿还给荒野。" + not-a-claim: "这座岛屿从来不是认领的小岛 - 你无权放弃它。" + team-first: "你的船员仍占有这座岛屿。请先踢出所有成员 - 放弃本来就该是痛的。" + confirm: "把小岛还给荒野?不退款。建造的一切都会留下,第一个登岛的水手可以全部认领。" + done: "小岛重归荒野。你建造的东西还在 - 直到有人认领它。" + direction: + north: "北" + north-east: "东北" + east: "东" + south-east: "东南" + south: "南" + south-west: "西南" + west: "西" + north-west: "西北" + band: + safe: "安全" + policed: "有治安" + frontier: "边疆" + lawless: "无法" + anarchic: "无政府" + # 港口的经济类型,用于所有说明港口种类的文字 + type: + agricultural: "农业" + forest: "林业" + fishing: "渔业" + mining: "矿业" + industrial: "工业" + luxury: "奢侈品" + frozen: "冰封" + # 港口名由这些音节拼成(每个名字 2-4 个:LA+VE 是 "Lave")。用你的文字写出 + # 每个音节,所有港口名都会随之改变,本语言的每个玩家看到的都一样。留空则 + # 保留该音节原样。星图只能绘制拉丁字母,所以当音节使用其他文字时,星图上 + # 仍显示英文名。 + name-token: + le: "勒" + xe: "谢" + ge: "格" + za: "扎" + ce: "塞" + bi: "比" + so: "索" + us: "乌斯" + es: "埃斯" + ar: "阿尔" + ma: "马" + in: "因" + di: "迪" + re: "雷" + a: "亚" + er: "尔" + at: "阿特" + en: "恩" + be: "贝" + ra: "拉" + la: "莱" + ve: "维" + ti: "蒂" + ed: "埃德" + or: "奥尔" + qu: "库" + an: "安" + te: "特" + is: "伊斯" + ri: "里" + # "on" 必须加引号:不加引号的 on/off/yes/no 会被当作 YAML 布尔值 + "on": "翁" + # 货物种类,用于航海日志(/tw prices)。命令参数仍使用英文单词。 + category: + crops: "农作物" + food: "食物" + fish: "鱼类" + wood: "木材" + stone: "石材" + ores: "矿石" + metals: "金属" + gems: "宝石" + luxury: "奢侈品" + misc: "杂货" + salvage: "打捞品" + # 海洋上交易的货物、燃料和船体的名称,每种材料一行(小写枚举名)。留空则由 + # 玩家自己的客户端在对话框、菜单和全息文字中翻译该物品 - 无需翻译。填上则 + # 覆盖客户端翻译,并且在客户端无法翻译的纯聊天文字中也会使用该名称。这里 + # 未列出的材料(打捞品)始终由客户端翻译。 + materials: + wheat: "小麦" + carrot: "胡萝卜" + potato: "马铃薯" + beetroot: "甜菜根" + sugar_cane: "甘蔗" + sugar: "糖" + pumpkin: "南瓜" + melon_slice: "西瓜片" + bread: "面包" + cooked_beef: "牛排" + cooked_cod: "烤鳕鱼" + cake: "蛋糕" + golden_apple: "金苹果" + egg: "鸡蛋" + hay_block: "干草块" + cod: "鳕鱼" + salmon: "鲑鱼" + tropical_fish: "热带鱼" + pufferfish: "河豚" + kelp: "海带" + oak_log: "橡木原木" + spruce_log: "云杉原木" + birch_log: "白桦原木" + dark_oak_log: "深色橡木原木" + acacia_log: "金合欢原木" + jungle_log: "丛林原木" + cherry_log: "樱花原木" + mangrove_log: "红树原木" + pale_oak_log: "苍白橡木原木" + stone: "石头" + cobblestone: "圆石" + granite: "花岗岩" + diorite: "闪长岩" + andesite: "安山岩" + deepslate: "深板岩" + sand: "沙子" + gravel: "沙砾" + coal: "煤炭" + charcoal: "木炭" + raw_iron: "粗铁" + raw_copper: "粗铜" + raw_gold: "粗金" + flint: "燧石" + iron_ingot: "铁锭" + copper_ingot: "铜锭" + gold_ingot: "金锭" + iron_nugget: "铁粒" + gold_nugget: "金粒" + diamond: "钻石" + emerald: "绿宝石" + amethyst_shard: "紫水晶碎片" + quartz: "下界石英" + lapis_lazuli: "青金石" + redstone: "红石粉" + leather: "皮革" + white_wool: "白色羊毛" + orange_wool: "橙色羊毛" + magenta_wool: "品红色羊毛" + light_blue_wool: "淡蓝色羊毛" + yellow_wool: "黄色羊毛" + lime_wool: "黄绿色羊毛" + pink_wool: "粉红色羊毛" + gray_wool: "灰色羊毛" + light_gray_wool: "淡灰色羊毛" + cyan_wool: "青色羊毛" + purple_wool: "紫色羊毛" + blue_wool: "蓝色羊毛" + brown_wool: "棕色羊毛" + green_wool: "绿色羊毛" + red_wool: "红色羊毛" + black_wool: "黑色羊毛" + string: "线" + beef: "生牛肉" + porkchop: "生猪排" + chicken: "生鸡肉" + mutton: "生羊肉" + bone: "骨头" + bone_meal: "骨粉" + gunpowder: "火药" + spider_eye: "蜘蛛眼" + rotten_flesh: "腐肉" + feather: "羽毛" + ink_sac: "墨囊" + glow_ink_sac: "荧光墨囊" + slime_ball: "黏液球" + rabbit: "生兔肉" + rabbit_hide: "兔子皮" + rabbit_foot: "兔子脚" + ender_pearl: "末影珍珠" + blaze_rod: "烈焰棒" + ghast_tear: "恶魂之泪" + phantom_membrane: "幻翼膜" + nether_star: "下界之星" + prismarine_shard: "海晶碎片" + prismarine_crystals: "海晶砂粒" + nautilus_shell: "鹦鹉螺壳" + heart_of_the_sea: "海洋之心" + shulker_shell: "潜影壳" + echo_shard: "回响碎片" + totem_of_undying: "不死图腾" + dragon_breath: "龙息" + turtle_scute: "海龟鳞甲" + armadillo_scute: "犰狳鳞甲" + honeycomb: "蜜脾" + saddle: "鞍" + name_tag: "命名牌" + potion: "药水" + splash_potion: "喷溅药水" + lingering_potion: "滞留药水" + apple: "苹果" + melon: "西瓜" + sweet_berries: "甜浆果" + glow_berries: "发光浆果" + cocoa_beans: "可可豆" + bamboo: "竹子" + cactus: "仙人掌" + brown_mushroom: "棕色蘑菇" + red_mushroom: "红色蘑菇" + sea_pickle: "海泡菜" + sponge: "海绵" + nether_wart: "下界疣" + clay_ball: "黏土球" + snowball: "雪球" + dirt: "泥土" + obsidian: "黑曜石" + netherrack: "下界岩" + soul_sand: "灵魂沙" + end_stone: "末地石" + glowstone_dust: "荧石粉" + magma_cream: "岩浆膏" + oak_boat: "橡木船" + oak_chest_boat: "橡木运输船" + dried_kelp_block: "干海带块" + coal_block: "煤炭块" + lava_bucket: "熔岩桶" + bamboo_raft: "竹筏" + spruce_boat: "云杉木船" + bamboo_chest_raft: "竹运输筏" + birch_boat: "白桦木船" + jungle_boat: "丛林木船" + spruce_chest_boat: "云杉木运输船" + acacia_boat: "金合欢木船" + birch_chest_boat: "白桦木运输船" + dark_oak_boat: "深色橡木船" + jungle_chest_boat: "丛林木运输船" + mangrove_boat: "红树木船" + acacia_chest_boat: "金合欢木运输船" + cherry_boat: "樱花木船" + dark_oak_chest_boat: "深色橡木运输船" + pale_oak_boat: "苍白橡木船" + mangrove_chest_boat: "红树木运输船" + cherry_chest_boat: "樱花木运输船" + pale_oak_chest_boat: "苍白橡木运输船" + # 补给站装备 + iron_sword: "铁剑" + shield: "盾牌" + iron_helmet: "铁头盔" + iron_chestplate: "铁胸甲" + iron_leggings: "铁护腿" + iron_boots: "铁靴子" + white_bed: "白色床" + cooked_mutton: "熟羊肉" + fishing_rod: "钓鱼竿" + compass: "指南针" + iron_pickaxe: "铁镐" + fuel: + low-actionbar: "[actionbar]燃料不足 - 跃迁离开需要 [fuel]/[needed]。到市场购买燃料。" + low-chat: "在此港口燃料不足。 你有 [fuel] 单位;从这里最便宜的跃迁需要 [needed]。离开前至少再从市场买 [number] 单位,否则你就得划船了。" + hud: + navigation: "[name] | 科技 [tech] | [band] | [standing] | 码头 [distance]m" + course-day: "凭太阳判断:家在 [direction] 方向。星星会告诉你更多。" + course-night: "凭星星判断:家在 [direction] 方向,[distance] 格" + hologram: + island: "[name][type] T[tech],[band][distance]m" + boat: "船[distance]m" + old-boat: "旧船[distance]m" + dock: "码头[name][distance]m" + home: "家[distance]m" + ui: + capture: + title: "[material]" + body: "拿走 [taking],它就成为你的船 - 你的 [leaving] 和船上的一切会被留下,无主,任何人都可以拿走。用 /tw chart 再找到它。" + body-no-boat: "你没有船。拿走 [taking],它连同货物都是你的。" + body-cargo: "它载有 [cargo] 件物品。" + body-too-far: "你的 [leaving] 太远,无法装货 - 货物不会自己穿越海洋。" + confirm: "拿走 [material]" + confirm-tooltip: "你的旧船和它的货物留在原地" + transfer: "只拿货物" + transfer-tooltip: "把它载的货物搬进你自己的货舱,留下船体" + cancel: "不要了" + market: + title: "[name] 市场" + subtitle: "[type](科技 [tech]),[band]" + status: "余额:[balance] | 货舱空间:约 [space] 件" + sell: "出售货物" + sell-tooltip: "在这里出售你货舱里的货物" + buy: "购买货物" + buy-tooltip: "把这座岛屿的贸易货物买进你的货舱" + outfitter: "补给站" + outfitter-tooltip: "食物、燃料和装备 - 直接进背包" + outfitter-fuel: "在此购买燃料 - 补给站" + outfitter-fuel-tooltip: "没有更多燃料你哪儿也跃迁不了。在这里买木炭。" + buy-fuel: "在此购买燃料 - 购买货物" + buy-fuel-tooltip: "没有更多燃料你哪儿也跃迁不了。这座岛屿有售。" + no-ship-here: "码头上没有你的船,所以没有货物可交易 - 但补给站和船匠仍会为你服务。" + low-fuel: "燃料不足 - 不够从这里跃迁到任何地方。" + shelf: "二手货" + shelf-tooltip: "其他水手转手的货物,由他们的商人运到这里。" + shelf-title: "[name] 的二手货" + shelf-body: "商人收进来的零碎货色。现状出售。" + shelf-item-line: "[material] - [price]" + shelf-buy: "[material] - [price]" + shelf-buy-tooltip: "[details]" + report: "港口行情" + report-tooltip: "经纪人会把附近 [number] 个港口的行情填进你的航海日志。[price]" + shipwright: "船匠" + shipwright-tooltip: "更大的船 - 升级保留你的货物并扩大货舱" + back: "< 返回" + back-tooltip: "返回市场菜单" + close: "关闭" + selling-title: "[name] - 出售" + selling-body: "岛屿为你货舱里的货物付钱" + selling-truncated: "(只显示前 [number] 种货物 - 卖掉一些以查看其余)" + buying-title: "[name] - 购买" + buying-body: "货物售入你的货舱 - 低买,到别处高卖" + outfitter-title: "[name] - 补给站" + outfitter-body: "航海生活的补给 - 食物、燃料和装备" + shipwright-title: "[name] - 船匠" + shipwright-body: "你的船停在码头时,换更大的船体算以旧换新:旧船体拆解,货物留在船上。其他情况都是直接购买 - 你的旧船会无主地留在原地。聪明的水手会用稀有木材造船,而不是花钱。" + sell-pick: "[material] x[amount]" + sell-pick-tooltip: "每件 [price]。此港口按这个价格还能再收约 [depth] 件。" + sell-qty-one: "出售 1 件 - [price]" + sell-qty-batch: "出售 [amount] 件 - [price]" + sell-qty-all: "全部出售 [amount] 件 - [price]" + sell-qty-tooltip: "随着港口库存填满,价格会下跌 - 留意它的变化。" + sell-item-depth: "此港口按这个价格还能再收约 [depth] 件。" + item-enchanted: "[material] ✦" + item-worn: "[material](磨损)" + sell-item-line: "[material] x[amount] - [price] 每件" + buy-one: "[material] x1 - [price]" + buy-batch: "x[amount] - [price]" + buy-tooltip-each: "每件 [price];受余额和货舱空间限制" + outfit-one: "[material] x1 - [price]" + outfit-batch: "[material] x[amount] - [price]" + outfit-tooltip: "每件 [price] - 放进背包,不进货舱" + hull: "[material] - [price]" + hull-tooltip: "[slots] 个货物槽。替换你现在的船;货物原地保留。" + pouch: "贸易袋 - [price]" + pouch-tooltip: "可装 64 件物品(已携带 [owned]/[cap])。货舱扩展器每个可装 27 组。" + expander: "货舱扩展器 - [price]" + expander-tooltip: "+20 个货物槽,安装到苍白橡木运输船上(已安装 [owned] 个)。每次价格翻倍。" + no-hulls: "船台上没有比你的船更大的" + no-hulls-tooltip: "此港口造不出比你的船更大的船。去找科技更高的港口。" + charity: "港务长的救济" + charity-tooltip: "船毁人穷?拿一只竹筏重新开始。" + warp: + title: "跃迁 - [name]" + fuel: "船上燃料:[amount] 单位" + destination: "[name] - [fuel] 燃料" + destination-poor: "[name] - [fuel] 燃料" + destination-tooltip: "[type](科技 [tech]),[band],[distance] 格" + home: "⌂ [name] - [fuel] 燃料" + home-poor: "⌂ [name] - [fuel] 燃料" + home-tooltip: "你的岛屿,[distance] 格。仅限成员 - 其他人永远看不到这一项。" + interstice: + title: "间隙之海" + body: "黑暗的水面,上方有什么在尖叫。你的航程仍然有效。" + re-engage: "重新启动前往 [name] 的跃迁" + re-engage-tooltip: "免费 - 燃料在你第一次启动时已经花掉了" + stay: "再待一会儿" + stay-tooltip: "关闭并探索。随时用 /tw go 找回这个选项 - 燃料已经付过了。" + item: + # 船体的物品名;[material] 是船的种类 + boat-name: "[material]" + boat-lore-cargo: "货物:[used]/[slots] 槽" + boat-lore-fuel: "燃料:[units] 单位" + boat-lore-open: "右键点击打开货舱" + pouch: "贸易袋" + pouch-lore: "货物空间。存放货物以便出售。" + expander: "货舱扩展器" + expander-lore: "扩展你的货舱。右键点击打开。" + starchart: "星图" + ships-compass: "船长罗盘" + ships-compass-lore: "从任何地方指向你的岛屿。保管好它 - 或送给船员。" + expander-view: "货舱扩展器" + chart: + charted: "[actionbar]已测绘 [name]!" + port-scan: "你抄下了港口的海图:[number] 座岛屿加入了你的海图。" + holograms-shown: "你的海图在空中闪现 - 朝着一个名字划过去。" + starchart-given: "你展开星图。 收起它就是收好了 - 需要时再次运行命令即可。" + empty: "你的海图是空的 - 划进某座岛屿的水域来测绘它。" + header: "你的海图([number] 座岛屿):" + your-boat: "你的 [material]:[x], [z]([distance] 格)" + your-boat-elsewhere: "你的 [material] 在另一个世界([world])。" + your-boat-lost: "你的 [material] 没有位置记录 - 它还没有被放下过。" + no-boat: "你没有船。" + old-boat: "旧 [material]:[x], [z]([distance] 格)- 无主,任何人都可以拿走" + old-boat-elsewhere: "你的旧 [material] 在另一个世界([world])。" + old-boat-lost: "你的旧 [material] 没有位置记录。" + fuel-aboard: "船上燃料:[amount] 单位。可到达的目的地已标出。" + entry-reachable: "[fuel] 燃料 [name] - [type] T[tech],[band],[distance] 格" + entry-far: "[fuel] 燃料 [name] - [type] T[tech],[band],[distance] 格 (燃料不足)" + restart: + confirm: "重新开始你的生涯?余额和装备重置;海图保留。" + none-left: "没有剩余的重新开始次数(最多 [number] 次)。" + done: "崭新的开始。剩余重新开始次数:[number]。" + boat: + label-owned: "[name] 的船" + label-unowned: "无主" + protected: "那艘船属于别人,而且此港口保护它。" + stolen: "你不在时,你的船被人拿走了。" + taken: "有人拿走了你的船!" + merged-empty: "你把所有东西搬了过去,空的 [material] 留在水上漂着。" + merged-partial: "你把装得下的搬了过去 - 其余留在 [material] 里,在你脚边漂着。" + adrift-raft: "没有船,随波漂流 - 港务长的 [material] 在你的背包里。" + respawn-given: "港口借给你一艘 [material] - 她是你的了,你的货舱随她出航。" + respawn-given-replacing: "港口借给你一艘 [material] - 她现在是你的了。 你的旧船漂在你倒下的地方,货物还在船上;你的海图仍标记着她。" + own-aboard: "你的 [material] 回到了你的背包 - 她又是你的货舱了。" + own-resumed: "你的 [material] 又是你的船了 - 你之前驾驶的 [old] 现在成了旧船,无主地留在原地。/tw chart list 标记着她。" + claimed-plain: "[material] 现在是你的了 - 你的货舱等待第一批货物。" + upgraded-plain: "你拿走了 [material] - [slots] 个货物槽。你的旧船已拆解;货物在船上。" + claimed: "你拖回了 [material] - 它现在是你的了,连同货物。" + upgraded-from-salvage: "[material] 比你的船大 - 你连同货物一起拿走了它。你的旧船被丢在它原来的位置。" + overflow-in-old-boat: "装不下的东西留在了你的旧 [material] 里 - 它漂在残骸原来的位置。" + salvage-emptied: "你搜刮了残骸:[amount] 件物品进入你的货舱。空船体现在只是一艘普通的船。" + salvage-partial: "你拿走了 [amount] 件物品 - 你的货舱满了。其余留在水上等下一个人。" + hold: + title: "船舱 - [slots] 槽" + border: " " + tnt: "销毁货物" + # 说明文字(-lore 键)按 \n 换行;每一行都要有自己的颜色标签 + tnt-lore: "右键点击一组货物选中它,\n然后点击这里。销毁就是彻底消失。" + locked: "已锁定" + locked-lore: "更大的船可以解锁这个槽位。" + fuel-slot: "燃料槽" + fuel-only: "燃料箱只能放燃料。" + fuel-slot-lore: "木头、煤炭、熔岩。燃料可以自由存取。" + border-lore: "拿起一组物品再放下:\n放到燃料行是加燃料,\n放进你的背包是带上岸,\n放到这里任何位置是放回。\nShift 点击走快捷路线。\n右键点击选中以供 TNT 销毁。" + withdrawn: "已从货舱取出:[amount] x [material]" + withdraw-failed: "你的背包放不下。" + bought-cargo-locked: "那是为贸易买进的货物 - 它只能通过出售或 TNT 离开货舱,不能带回岸上。" + selected: "[actionbar]已选中 [amount] x [material] - 点击 TNT 销毁。" + select-first: "[actionbar]先选中一组货物,再点击 TNT。" + destroyed: "[actionbar][amount] x [material] 已销毁,彻底消失。" + container-refused: "容器不能作为货物存放。" + boat-refused: "船只不是货物。" + cargo-full: "货舱已满 - 货物只能通过出售或销毁离开。" + fuel-full: "燃料槽已满。" + moved-to-fuel: "[actionbar][amount] x [material] 已移到燃料槽。" + expander-title: "货舱扩展器 [number]" + expander: "货舱扩展器 [number]" + expander-lore: "点击打开。(为空时)右键点击选中以供 TNT 销毁。" + expander-inert-lore: "未激活 - 扩展器只能在苍白橡木运输船上打开。内容物安全。" + expander-selected: "[actionbar]已选中扩展器 [number] - 点击 TNT 拆解。" + expander-not-empty: "先清空扩展器 - TNT 不接受装有物品的扩展器。" + expander-inert: "扩展器只能在苍白橡木运输船上打开。" + expander-destroyed: "[actionbar]扩展器已拆解。" + trade: + not-at-market: "这里没有市场 - 请在岛屿的保护范围内交易。" + nothing-of-that: "你的货舱里没有那种东西。" + boat-bought: "以 [price] 购买了 [material] - [slots] 个货物槽。你的旧船已拆解;货物在船上。" + boat-bought-first: "以 [price] 购买了 [material] - [slots] 个货物槽。她在你的背包里:找片水面把她放下。" + boat-replaced: "以 [price] 购买了 [material] - [slots] 个货物槽,在你的背包里。 你的 [old] 和船上的一切被无主地留在原地 - /tw chart 记得那个地点,但别人也可能知道。" + boat-not-here: "你的船不在这个港口 - 把它开进岛屿水域才能交易。" + boat-not-sold-here: "这个船坞不能卖给你那艘船。" + boat-crafted: "你装配好了 [material] - [slots] 个货物槽。你的旧船已拆解;货物在船上。" + craft-smaller-refused: "那艘船不比你的大 - 你是在烧好木头缩小自己的货舱。" + too-advanced: "[name] 用不上 [material] - 这里没人付得起它的价值。去更发达的港口试试。" + shelf-bought: "归你了:[material],花费 [price]" + shelf-empty: "这里的二手货架上什么也没有。" + report-bought: "经纪人把 [number] 个港口的行情抄进你的航海日志,收费 [price]。用 /tw prices 查看。" + report-nothing: "这个港口的航程内没有其他港口 - 没有什么可报告的。" + contraband-refused: "商人看了看你的货,又看了看你。“这里不行。去个更乱的港口试试。”" + barred: "你一走近,市场就关门了。“我们知道你是什么人。去无法之地卖吧。”" + nothing-to-sell: "你的货舱里没有这座岛屿愿意收购的东西。" + nothing-for-sale: "这座岛屿没有出售的货物 - 它只收购。" + sold: "以 [price] 售出 [amount] x [material]。" + bought: "以 [price] 买入 [amount] x [material]。" + cannot-afford: "你买不起。" + no-hold-space: "货舱没有空间 - 卖掉些东西、销毁些东西,或买艘更大的船。" + needs-chest-boat: "扩展器要存放在运输船上 - 先从船匠那里买一艘。" + expander-bought: "以 [price] 安装了货舱扩展器。" + expander-not-sold-here: "只有最高科技的船坞(科技 7)能安装扩展器。" + expander-needs-flagship: "扩展器只能安装到有空余货物槽的苍白橡木运输船上。" + pouch-bought: "以 [price] 购买了贸易袋。" + pouch-cap: "你不能再携带更多贸易袋了。" + charity-given: "港务长朝一只竹筏挥了挥手。“回去干活吧,水手。”" + charity-not-needed: "港务长打量了你一番。“你自己能行。”" + charity-cooldown: "“我已经帮过你了。[number] 分钟后再来。”" + expander-cap: "你的船装不下更多扩展器了。" + interstice: + stranded: "跃迁崩溃了!黑暗的水面,上方有什么在尖叫。" + no-portal: "框架吞下火焰,冷却下来。海与海之间的海不为火与石打开。" + way-out: "[actionbar]跃迁还欠你一次穿越 - 准备好了就 /tw go。" + encounter: + puffer_shoal: "前方的水面泛起刺痛 - 一群河豚横在你的航线上。小心你的手。" + guardian_picket: "守卫者在你的航路上升起。" + drowned_raiders: "溺尸在船头浮出水面 - 有些拿着三叉戟。" + deep_terror: "某种庞大而古老的东西从深处升起。快跑。" + phantom_flight: "桅杆上方的黑暗中有翅膀。" + pirate_crew: "一艘有船员的船朝你转过来。海盗。" + sea_witch: "一个漂流的女巫,而她已经看见了你。" + warp: + not-in-boat: "你需要在船上才能跃迁。" + not-at-island: "跃迁只能在岛屿水域内启动。" + no-destinations: "没有已测绘的目的地 - 先划进新的水域。" + not-enough-fuel: "船上燃料不够这次跳跃。" + arrived: "跃迁把你释放在 [name]。" + hold-course: "保持航向 [seconds] 秒 - 跃迁正在启动……" + course-broken: "你偏离了航向 - 跃迁熄灭了。燃料已回收。" + enemies-near: "[actionbar]敌人在附近时无法跃迁。" diff --git a/src/test/java/world/bentobox/tradewinds/LocaleKeyTest.java b/src/test/java/world/bentobox/tradewinds/LocaleKeyTest.java index 3c09266..b5a7514 100644 --- a/src/test/java/world/bentobox/tradewinds/LocaleKeyTest.java +++ b/src/test/java/world/bentobox/tradewinds/LocaleKeyTest.java @@ -17,6 +17,14 @@ import org.bukkit.configuration.file.YamlConfiguration; import org.junit.jupiter.api.Test; +import org.bukkit.Material; + +import world.bentobox.tradewinds.economy.TradeCategory; +import world.bentobox.tradewinds.economy.TypeEconomy; +import world.bentobox.tradewinds.ocean.IslandType; +import world.bentobox.tradewinds.ocean.NameGenerator; +import world.bentobox.tradewinds.ocean.SecurityBand; + /** * Every locale key the code asks for must exist in {@code en-US.yml}. *

@@ -93,6 +101,71 @@ void testKeysBuiltAtRuntimeExist() { assertTrue(missing.isEmpty(), "Runtime-built locale keys missing: " + missing); } + /** + * Keys built from an enum name at runtime - {@code getLocaleKey()} on the + * band, island type and trade category. The source scan cannot see them, + * so every constant is checked here; a new enum value without a locale + * line would otherwise render as its key. + */ + @Test + void testEnumLocaleKeysExist() { + YamlConfiguration locale = YamlConfiguration.loadConfiguration(new InputStreamReader( + getClass().getClassLoader().getResourceAsStream("locales/en-US.yml"))); + List keys = new ArrayList<>(); + for (SecurityBand band : SecurityBand.values()) { + keys.add(band.getLocaleKey()); + } + for (IslandType type : IslandType.values()) { + keys.add(type.getLocaleKey()); + } + for (TradeCategory category : TradeCategory.values()) { + keys.add(category.getLocaleKey()); + } + List missing = keys.stream().filter(key -> !(locale.get(key) instanceof String)).sorted() + .toList(); + assertTrue(missing.isEmpty(), "Enum locale keys missing from en-US.yml: " + missing); + } + + /** + * Every good, fuel and hull the ocean trades has a materials line, so a + * translator who copies en-US sees the whole list. A blank line is the + * normal state (the client translates), so only presence is checked. + */ + @Test + void testEveryTradedMaterialHasALocaleLine() { + YamlConfiguration locale = YamlConfiguration.loadConfiguration(new InputStreamReader( + getClass().getClassLoader().getResourceAsStream("locales/en-US.yml"))); + Settings settings = new Settings(); + Set materials = new TreeSet<>(); + materials.addAll(settings.getBasePrices().keySet()); + materials.addAll(settings.getFuelValues().keySet()); + materials.addAll(settings.getBoatRanks().keySet()); + // The outfitter's shelf is code, not config + materials.add(Material.BREAD.name()); + materials.add(Material.CHARCOAL.name()); + for (IslandType type : IslandType.values()) { + TypeEconomy.outfitterExtras(type).forEach(material -> materials.add(material.name())); + } + List missing = materials.stream() + .map(name -> "tradewinds.materials." + name.toLowerCase(java.util.Locale.ENGLISH)) + .filter(key -> !(locale.get(key) instanceof String)).sorted().toList(); + assertTrue(missing.isEmpty(), "Traded materials without a locale line: " + missing); + } + + /** + * Every syllable the name generator can produce has a token line, so a + * translator sees the full table to transliterate. + */ + @Test + void testEveryNameTokenHasALocaleLine() { + YamlConfiguration locale = YamlConfiguration.loadConfiguration(new InputStreamReader( + getClass().getClassLoader().getResourceAsStream("locales/en-US.yml"))); + List missing = NameGenerator.vocabulary().stream() + .map(token -> "tradewinds.name-token." + token.toLowerCase(java.util.Locale.ENGLISH)) + .filter(key -> !(locale.get(key) instanceof String)).sorted().toList(); + assertTrue(missing.isEmpty(), "Name tokens without a locale line: " + missing); + } + private void collect(Matcher matcher, Set into, String prefix) { while (matcher.find()) { into.add(prefix + matcher.group(1)); diff --git a/src/test/java/world/bentobox/tradewinds/PortNamesTest.java b/src/test/java/world/bentobox/tradewinds/PortNamesTest.java new file mode 100644 index 0000000..d721601 --- /dev/null +++ b/src/test/java/world/bentobox/tradewinds/PortNamesTest.java @@ -0,0 +1,101 @@ +package world.bentobox.tradewinds; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.when; + +import java.util.List; + +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; + +import world.bentobox.bentobox.api.user.User; +import world.bentobox.tradewinds.ocean.IslandSpec; +import world.bentobox.tradewinds.ocean.IslandType; +import world.bentobox.tradewinds.ocean.NameGenerator; +import world.bentobox.tradewinds.ocean.OceanConfig; +import world.bentobox.tradewinds.ocean.OceanEngine; +import world.bentobox.tradewinds.ocean.SecurityBand; +import world.bentobox.tradewinds.travel.StarChartRenderer; + +/** + * Port names for #7: tokens transliterated through the locale, the canonical + * name kept as identity. + */ +class PortNamesTest extends CommonTestSetup { + + private TradeWinds addon; + private OceanEngine engine; + private User user; + private IslandSpec port; + + @BeforeEach + void setUpOcean() { + addon = mock(TradeWinds.class); + engine = new OceanEngine(new OceanConfig(11L, 2500, 160, 45, 1.0, 0, 5000, 70)); + when(addon.getOceanEngine()).thenReturn(engine); + user = User.getInstance(mockPlayer); + port = null; + for (int cx = -5; cx <= 5 && port == null; cx++) { + for (int cz = -5; cz <= 5 && port == null; cz++) { + port = engine.islandInCell(cx, cz).orElse(null); + } + } + } + + @Test + void testUnmappedTokensReadAsTheCanonicalName() { + assertEquals(port.name(), PortNames.display(addon, user, port)); + } + + @Test + void testTokensAreTransliterated() { + List tokens = engine.nameTokens(port); + assertEquals(port.name(), NameGenerator.join(tokens)); + StringBuilder expected = new StringBuilder(); + for (String token : tokens) { + String glyph = "\u4e00" + token.toLowerCase(); + when(lm.get(any(), eq("tradewinds.name-token." + token.toLowerCase()))).thenReturn(glyph); + expected.append(glyph); + } + assertEquals(expected.toString(), PortNames.display(addon, user, port)); + // Identity untouched + assertEquals(NameGenerator.join(tokens), port.name()); + } + + @Test + void testAsciiTransliterationKeepsTheCapital() { + List tokens = engine.nameTokens(port); + String first = tokens.get(0).toLowerCase(); + when(lm.get(any(), eq("tradewinds.name-token." + first))).thenReturn("zz"); + String shown = PortNames.display(addon, user, port); + assertEquals("Zz" + port.name().substring(first.length()).toLowerCase(), shown); + } + + @Test + void testNoEngineOrForeignSpecFallsBackToCanonical() { + IslandSpec foreign = new IslandSpec(0, 0, 0, 0, IslandType.FISHING, SecurityBand.SAFE, "minecraft:beach", + "Handmade"); + assertEquals("Handmade", PortNames.display(addon, user, foreign)); + when(addon.getOceanEngine()).thenReturn(null); + assertEquals(port.name(), PortNames.display(addon, user, port)); + } + + @Test + void testSpawnPortIsNamedByTheLocale() { + IslandSpec spawn = engine.islandInCell(0, 0).orElseThrow(); + assertEquals(OceanEngine.SPAWN_NAME, spawn.name()); + // The test locale renders every key as itself + assertEquals("tradewinds.spawn.name", PortNames.display(addon, user, spawn)); + when(lm.get(any(), eq("tradewinds.spawn.name"))).thenReturn("出生港"); + assertEquals("出生港", PortNames.display(addon, user, spawn)); + } + + @Test + void testChartDrawsOnlyWhatTheMapFontCan() { + assertEquals("Lave", StarChartRenderer.drawableName(port, "Lave")); + assertEquals(port.name(), StarChartRenderer.drawableName(port, "\u62c9\u7ef4")); + } +} diff --git a/src/test/java/world/bentobox/tradewinds/economy/ItemNamesTest.java b/src/test/java/world/bentobox/tradewinds/economy/ItemNamesTest.java new file mode 100644 index 0000000..bf6ca86 --- /dev/null +++ b/src/test/java/world/bentobox/tradewinds/economy/ItemNamesTest.java @@ -0,0 +1,109 @@ +package world.bentobox.tradewinds.economy; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertTrue; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.when; + +import org.bukkit.Material; +import org.bukkit.NamespacedKey; +import org.bukkit.enchantments.Enchantment; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; + +import net.kyori.adventure.text.Component; +import net.kyori.adventure.text.serializer.gson.GsonComponentSerializer; +import net.kyori.adventure.text.serializer.plain.PlainTextComponentSerializer; +import world.bentobox.bentobox.api.user.User; +import world.bentobox.bentobox.util.Util; +import world.bentobox.tradewinds.CommonTestSetup; + +/** + * Item names for #7: the locale's own line wins, else the client translates + * through a MiniMessage tag whose fallback is the English name. + */ +class ItemNamesTest extends CommonTestSetup { + + private User user; + + @BeforeEach + void setUpUser() { + user = User.getInstance(mockPlayer); + } + + @Test + void testNoOverrideAsksTheClientToTranslate() { + String label = ItemNames.label(user, Material.OAK_LOG); + assertTrue(label.startsWith(""), label); + } + + @Test + void testLocaleLineOverridesTheClient() { + when(lm.get(any(), eq("tradewinds.materials.oak_log"))).thenReturn("橡木原木"); + assertEquals("橡木原木", ItemNames.label(user, Material.OAK_LOG)); + // A blank line is "no override", not an empty name + when(lm.get(any(), eq("tradewinds.materials.wheat"))).thenReturn(""); + assertTrue(ItemNames.label(user, Material.WHEAT).startsWith("", ItemNames.tag("k", "it's")); + } + + @Test + void testEnchantmentCarriesNameAndLevel() { + Enchantment sharpness = mock(Enchantment.class); + when(sharpness.getKey()).thenReturn(NamespacedKey.minecraft("sharpness")); + when(sharpness.translationKey()).thenReturn("enchantment.minecraft.sharpness"); + assertEquals(" ", + ItemNames.label(sharpness, 3)); + } + + @Test + void testPlayerTextCannotInjectTags() { + String escaped = ItemNames.escape("Sword of Doom"); + // Every tag opener is escaped, so MiniMessage renders it as literal text + assertEquals("\\Sword of \\Doom", escaped); + assertFalse(escaped.matches(".*(?[material]".replace("[material]", label)); + String plain = PlainTextComponentSerializer.plainText() + .serialize(component); + assertEquals("Oak Log", plain, "plain-text flattening should use the fallback"); + assertTrue(GsonComponentSerializer.gson().serialize(component) + .contains("\"translate\""), "the client should receive a translatable component"); + // Chat path: BentoBox flattens to legacy text using the fallback + assertEquals("§fOak Log", Util.componentToLegacy(component)); + } + + @Test + void testDerivedKeyUsesBlockOrItemPrefix() { + // Whatever Paper answers, the key must be a translation key for that material + assertTrue(ItemNames.translationKey(Material.OAK_LOG).endsWith(".minecraft.oak_log")); + assertTrue(ItemNames.translationKey(Material.DIAMOND).endsWith(".minecraft.diamond")); + } +} diff --git a/src/test/java/world/bentobox/tradewinds/ocean/NameGeneratorTest.java b/src/test/java/world/bentobox/tradewinds/ocean/NameGeneratorTest.java index b1511df..2cd43fa 100644 --- a/src/test/java/world/bentobox/tradewinds/ocean/NameGeneratorTest.java +++ b/src/test/java/world/bentobox/tradewinds/ocean/NameGeneratorTest.java @@ -4,6 +4,7 @@ import static org.junit.jupiter.api.Assertions.assertTrue; import java.util.HashSet; +import java.util.List; import java.util.Set; import org.junit.jupiter.api.Test; @@ -32,6 +33,67 @@ void testShape() { } } + /** + * The generator as it shipped before the tokens were exposed (#7). A + * port's name is its identity in every existing world, so the refactor + * must be byte-identical - this is the reference it is held to. + */ + private static final String[] PAIRS = { "..", "LE", "XE", "GE", "ZA", "CE", "BI", "SO", "US", "ES", "AR", "MA", + "IN", "DI", "RE", "A.", "ER", "AT", "EN", "BE", "RA", "LA", "VE", "TI", "ED", "OR", "QU", "AN", "TE", "IS", + "RI", "ON" }; + + private static String reference(long hash) { + long h = hash; + StringBuilder sb = new StringBuilder(); + int tokens = 3 + (int) (Hashing.mix(h) & 1); + for (int i = 0; i < tokens; i++) { + h = Hashing.mix(h + i + 1); + String pair = PAIRS[Math.floorMod(h, PAIRS.length)]; + for (char c : pair.toCharArray()) { + if (c != '.') { + sb.append(c); + } + } + } + while (sb.length() < 3) { + h = Hashing.mix(h); + for (char c : PAIRS[1 + Math.floorMod(h, PAIRS.length - 1)].toCharArray()) { + if (c != '.') { + sb.append(c); + } + } + } + String s = sb.toString().toLowerCase(); + return Character.toUpperCase(s.charAt(0)) + s.substring(1); + } + + @Test + void testNamesAreUnchangedByTheTokenRefactor() { + for (long h = -20000; h < 20000; h++) { + long hash = Hashing.mix(h * 0x9E3779B97F4A7C15L); + assertEquals(reference(hash), NameGenerator.name(hash), "hash " + hash); + } + } + + @Test + void testTokensJoinToTheName() { + for (long h = 0; h < 5000; h++) { + long hash = Hashing.mix(h); + List tokens = NameGenerator.tokens(hash); + assertEquals(NameGenerator.name(hash), NameGenerator.join(tokens)); + for (String token : tokens) { + assertTrue(NameGenerator.vocabulary().contains(token), "Unknown token " + token); + assertTrue(token.equals("A") || token.length() == 2, token); + } + } + } + + @Test + void testVocabularyIsThirtyOneTokens() { + assertEquals(31, NameGenerator.vocabulary().size()); + assertTrue(NameGenerator.vocabulary().contains("A")); + } + @Test void testVariety() { Set names = new HashSet<>(); diff --git a/src/test/java/world/bentobox/tradewinds/travel/HoldLoreTest.java b/src/test/java/world/bentobox/tradewinds/travel/HoldLoreTest.java new file mode 100644 index 0000000..0e9e3b9 --- /dev/null +++ b/src/test/java/world/bentobox/tradewinds/travel/HoldLoreTest.java @@ -0,0 +1,41 @@ +package world.bentobox.tradewinds.travel; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.Mockito.when; + +import java.util.List; + +import org.junit.jupiter.api.Test; + +import net.kyori.adventure.text.Component; +import net.kyori.adventure.text.format.NamedTextColor; +import net.kyori.adventure.text.serializer.plain.PlainTextComponentSerializer; +import world.bentobox.bentobox.api.user.User; +import world.bentobox.tradewinds.CommonTestSetup; + +/** + * Hold tooltips are locale strings broken on newlines into lore lines: a + * tooltip never wraps, so the six-clause border instruction was one clipped + * line (playtest 2026-09-04). + */ +class HoldLoreTest extends CommonTestSetup { + + @Test + void testLoreBreaksOnNewlines() { + when(lm.get(any(), eq("tradewinds.hold.border-lore"))) + .thenReturn("Pick up a stack:\ndrop it here.\nOr not."); + List lines = HoldGui.loreLines(User.getInstance(mockPlayer), "tradewinds.hold.border-lore"); + assertEquals(3, lines.size()); + assertEquals("Pick up a stack:", PlainTextComponentSerializer.plainText().serialize(lines.get(0))); + assertEquals("Or not.", PlainTextComponentSerializer.plainText().serialize(lines.get(2))); + assertEquals(NamedTextColor.RED, lines.get(2).color()); + } + + @Test + void testSingleLineLoreIsOneLine() { + when(lm.get(any(), eq("tradewinds.hold.locked-lore"))).thenReturn("A bigger boat unlocks this."); + assertEquals(1, HoldGui.loreLines(User.getInstance(mockPlayer), "tradewinds.hold.locked-lore").size()); + } +}