From 6539de35003e01541df7deb8984847d1d756c578 Mon Sep 17 00:00:00 2001 From: tastybento Date: Thu, 3 Sep 2026 22:39:05 -0700 Subject: [PATCH 1/8] Name island types, bands and categories from the locale, not the enum The market subtitle, warp tooltips, chart holograms and the chart and admin listings printed the raw enum for an island's type (AGRICULTURAL), three places still used a hardcoded English band name although tradewinds.band.* has existed for months, and /tw prices prettified the category enum. All of it now goes through getLocaleKey(), the English display name on SecurityBand is gone, and the dash and question-mark placeholders come from tradewinds.general too. LocaleKeyTest checks every enum constant has its line. First of three commits for #7. Co-Authored-By: Claude Fable 5.1 Claude-Session: https://claude.ai/code/session_011kwguMkwTr1i1C8qxqrbYT --- .../commands/AdminCustomsCommand.java | 2 +- .../commands/AdminIslandsCommand.java | 4 +-- .../tradewinds/commands/TWChartCommand.java | 2 +- .../tradewinds/commands/TWPricesCommand.java | 16 +++++----- .../tradewinds/commands/TWRankCommand.java | 3 +- .../tradewinds/economy/TradeCategory.java | 10 +++++++ .../tradewinds/economy/TradeDialog.java | 16 ++++++---- .../bentobox/tradewinds/ocean/IslandType.java | 11 +++++++ .../tradewinds/ocean/SecurityBand.java | 19 ++---------- .../tradewinds/travel/BoatService.java | 2 +- .../tradewinds/travel/ChartHolograms.java | 2 +- .../tradewinds/travel/WarpService.java | 5 ++-- src/main/resources/locales/en-US.yml | 26 +++++++++++++++++ .../bentobox/tradewinds/LocaleKeyTest.java | 29 +++++++++++++++++++ 14 files changed, 108 insertions(+), 39 deletions(-) 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..1dbf013 100644 --- a/src/main/java/world/bentobox/tradewinds/commands/TWChartCommand.java +++ b/src/main/java/world/bentobox/tradewinds/commands/TWChartCommand.java @@ -92,7 +92,7 @@ private void chartList(TradeWinds addon, User user) { boolean reachable = cost <= fuelAboard; user.sendMessage(reachable ? "tradewinds.chart.entry-reachable" : "tradewinds.chart.entry-far", TextVariables.NAME, spec.name(), - "[type]", spec.type().name(), + "[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))), diff --git a/src/main/java/world/bentobox/tradewinds/commands/TWPricesCommand.java b/src/main/java/world/bentobox/tradewinds/commands/TWPricesCommand.java index f434ab8..54f0730 100644 --- a/src/main/java/world/bentobox/tradewinds/commands/TWPricesCommand.java +++ b/src/main/java/world/bentobox/tradewinds/commands/TWPricesCommand.java @@ -32,6 +32,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 +106,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"); @@ -125,7 +126,7 @@ private void displayRows(User user, TradeWinds addon, world.bentobox.tradewinds. Money.format(addon, price), "[age]", age); } else { user.sendMessage("tradewinds.commands.prices.row", "[name]", spec.name(), VALUE_PLACEHOLDER, - best(addon, prices), "[age]", age); + best(user, addon, prices), "[age]", age); } } } @@ -134,11 +135,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 +151,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/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..44c28f2 100644 --- a/src/main/java/world/bentobox/tradewinds/economy/TradeDialog.java +++ b/src/main/java/world/bentobox/tradewinds/economy/TradeDialog.java @@ -48,6 +48,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; @@ -130,7 +132,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 +186,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), @@ -220,7 +223,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()); @@ -289,7 +292,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) @@ -331,11 +335,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())); } 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/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/travel/BoatService.java b/src/main/java/world/bentobox/tradewinds/travel/BoatService.java index 65048b1..b504c3f 100644 --- a/src/main/java/world/bentobox/tradewinds/travel/BoatService.java +++ b/src/main/java/world/bentobox/tradewinds/travel/BoatService.java @@ -247,7 +247,7 @@ public void label(Entity entity, BoatHold hold) { } else { String name = Bukkit.getOfflinePlayer(UUID.fromString(hold.getOwner())).getName(); boat.customName(console.getTranslationAsComponent("tradewinds.boat.label-owned", "[name]", - name == null ? "?" : name)); + name == null ? console.getTranslation("tradewinds.general.unknown") : name)); } boat.setCustomNameVisible(true); } diff --git a/src/main/java/world/bentobox/tradewinds/travel/ChartHolograms.java b/src/main/java/world/bentobox/tradewinds/travel/ChartHolograms.java index d02c00e..40b013b 100644 --- a/src/main/java/world/bentobox/tradewinds/travel/ChartHolograms.java +++ b/src/main/java/world/bentobox/tradewinds/travel/ChartHolograms.java @@ -304,7 +304,7 @@ private Component label(Player player, Marker marker) { } return User.getInstance(player).getTranslationAsComponent("tradewinds.hologram.island", "[name]", spec.name(), - "[type]", spec.type().name(), + "[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/WarpService.java b/src/main/java/world/bentobox/tradewinds/travel/WarpService.java index 0498cc2..0d5f39c 100644 --- a/src/main/java/world/bentobox/tradewinds/travel/WarpService.java +++ b/src/main/java/world/bentobox/tradewinds/travel/WarpService.java @@ -147,8 +147,9 @@ private ActionButton button(Player player, IslandSpec origin, Destination dest) dest.affordable() ? "tradewinds.ui.warp.destination" : "tradewinds.ui.warp.destination-poor", NAME_PLACEHOLDER, spec.name(), "[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()) { diff --git a/src/main/resources/locales/en-US.yml b/src/main/resources/locales/en-US.yml index 68f5a2a..c0925dd 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" @@ -220,6 +223,29 @@ 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" + # 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" 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." diff --git a/src/test/java/world/bentobox/tradewinds/LocaleKeyTest.java b/src/test/java/world/bentobox/tradewinds/LocaleKeyTest.java index 3c09266..71f556c 100644 --- a/src/test/java/world/bentobox/tradewinds/LocaleKeyTest.java +++ b/src/test/java/world/bentobox/tradewinds/LocaleKeyTest.java @@ -17,6 +17,10 @@ import org.bukkit.configuration.file.YamlConfiguration; import org.junit.jupiter.api.Test; +import world.bentobox.tradewinds.economy.TradeCategory; +import world.bentobox.tradewinds.ocean.IslandType; +import world.bentobox.tradewinds.ocean.SecurityBand; + /** * Every locale key the code asks for must exist in {@code en-US.yml}. *

@@ -93,6 +97,31 @@ 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); + } + private void collect(Matcher matcher, Set into, String prefix) { while (matcher.find()) { into.add(prefix + matcher.group(1)); From d67add504504f2887ecad32b9c9001cda336f155 Mon Sep 17 00:00:00 2001 From: tastybento Date: Thu, 3 Sep 2026 22:45:54 -0700 Subject: [PATCH 2/8] Name goods, fuel and hulls for the player's own language Every item name shown to a player was the material enum prettified, which no locale could touch (#7). ItemNames now gives the locale's own line (tradewinds.materials.) when the translator wrote one, and otherwise a MiniMessage lang_or tag: BentoBox substitutes variables and then parses the string, so in dialogs, GUIs and holograms the client renders the item's name in its own language for free, and chat, which BentoBox flattens to legacy text, falls back to the English name. Enchantment names on the shelf go the same way. Player-given item names are escaped before substitution, closing a MiniMessage injection into the dialogs. en-US.yml lists every traded material, blank by default, and LocaleKeyTest keeps the list complete. Co-Authored-By: Claude Fable 5.1 Claude-Session: https://claude.ai/code/session_011kwguMkwTr1i1C8qxqrbYT --- .../tradewinds/commands/TWChartCommand.java | 3 +- .../tradewinds/economy/ItemNames.java | 130 ++++++++++++++ .../tradewinds/economy/MarketService.java | 26 ++- .../tradewinds/economy/TradeDialog.java | 28 +-- .../listeners/SpawnRespawnListener.java | 7 +- .../tradewinds/travel/BoatCraftListener.java | 5 +- .../tradewinds/travel/BoatListener.java | 17 +- .../bentobox/tradewinds/travel/HoldGui.java | 15 +- src/main/resources/locales/en-US.yml | 161 ++++++++++++++++++ .../bentobox/tradewinds/LocaleKeyTest.java | 20 +++ .../tradewinds/economy/ItemNamesTest.java | 85 +++++++++ 11 files changed, 448 insertions(+), 49 deletions(-) create mode 100644 src/main/java/world/bentobox/tradewinds/economy/ItemNames.java create mode 100644 src/test/java/world/bentobox/tradewinds/economy/ItemNamesTest.java diff --git a/src/main/java/world/bentobox/tradewinds/commands/TWChartCommand.java b/src/main/java/world/bentobox/tradewinds/commands/TWChartCommand.java index 1dbf013..7dd8c98 100644 --- a/src/main/java/world/bentobox/tradewinds/commands/TWChartCommand.java +++ b/src/main/java/world/bentobox/tradewinds/commands/TWChartCommand.java @@ -6,6 +6,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.economy.ItemNames; import world.bentobox.tradewinds.TradeWinds; import world.bentobox.tradewinds.ocean.OceanEngine; import world.bentobox.tradewinds.ocean.IslandSpec; @@ -123,7 +124,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/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..7f8e437 100644 --- a/src/main/java/world/bentobox/tradewinds/economy/MarketService.java +++ b/src/main/java/world/bentobox/tradewinds/economy/MarketService.java @@ -140,7 +140,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 +327,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,7 +593,8 @@ 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), + User user = User.getInstance(player); + user.sendMessage("tradewinds.trade.too-advanced", VAR_MATERIAL, ItemNames.label(user, material), "[name]", spec.name()); thud(player); return 0; @@ -618,8 +619,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 +672,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 +714,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 +730,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 +743,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 +858,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/TradeDialog.java b/src/main/java/world/bentobox/tradewinds/economy/TradeDialog.java index 44c28f2..4e399e6 100644 --- a/src/main/java/world/bentobox/tradewinds/economy/TradeDialog.java +++ b/src/main/java/world/bentobox/tradewinds/economy/TradeDialog.java @@ -257,11 +257,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); @@ -301,7 +301,7 @@ 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); @@ -314,18 +314,18 @@ public void openShelf(Player player, IslandSpec spec) { /** * 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()); } /** @@ -357,7 +357,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, @@ -395,7 +395,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, @@ -440,7 +440,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())), () -> { @@ -448,8 +448,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); }); 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/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/HoldGui.java b/src/main/java/world/bentobox/tradewinds/travel/HoldGui.java index debb79d..a8043ad 100644 --- a/src/main/java/world/bentobox/tradewinds/travel/HoldGui.java +++ b/src/main/java/world/bentobox/tradewinds/travel/HoldGui.java @@ -27,6 +27,7 @@ import org.bukkit.inventory.meta.ItemMeta; import world.bentobox.bentobox.api.user.User; +import world.bentobox.tradewinds.economy.ItemNames; import world.bentobox.tradewinds.TradeWinds; /** @@ -525,7 +526,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 +578,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 +654,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 +664,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 +684,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 +699,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 +720,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/resources/locales/en-US.yml b/src/main/resources/locales/en-US.yml index c0925dd..c1a4afa 100644 --- a/src/main/resources/locales/en-US.yml +++ b/src/main/resources/locales/en-US.yml @@ -246,6 +246,167 @@ tradewinds: 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: "" 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." diff --git a/src/test/java/world/bentobox/tradewinds/LocaleKeyTest.java b/src/test/java/world/bentobox/tradewinds/LocaleKeyTest.java index 71f556c..9818365 100644 --- a/src/test/java/world/bentobox/tradewinds/LocaleKeyTest.java +++ b/src/test/java/world/bentobox/tradewinds/LocaleKeyTest.java @@ -122,6 +122,26 @@ void testEnumLocaleKeysExist() { 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()); + 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); + } + 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/economy/ItemNamesTest.java b/src/test/java/world/bentobox/tradewinds/economy/ItemNamesTest.java new file mode 100644 index 0000000..faa433d --- /dev/null +++ b/src/test/java/world/bentobox/tradewinds/economy/ItemNamesTest.java @@ -0,0 +1,85 @@ +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 world.bentobox.bentobox.api.user.User; +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(".*(? Date: Thu, 3 Sep 2026 22:50:26 -0700 Subject: [PATCH 3/8] Let a locale transliterate port names, syllable by syllable Port names are procedural, so no locale could list them (#7). Every name is 2-4 tokens from the Elite digraph table, though, and a locale CAN list those: NameGenerator now exposes the token sequence (its joined name is byte-identical to before - a 40,000-hash test holds it to the old implementation, because the name is the island's identity in every existing world), and PortNames maps each token through tradewinds.name-token. for the viewer's locale. Every player-facing line switches to the display name; the island registry, resident tags, logs and admin commands keep the canonical one. The star chart draws the transliterated name only when the map font can - it is ASCII bitmap and throws on anything else - so a Chinese locale sees Chinese names everywhere but the chart. The on token is quoted in the locale: bare on/off/yes/no are YAML booleans. Co-Authored-By: Claude Fable 5.1 Claude-Session: https://claude.ai/code/session_011kwguMkwTr1i1C8qxqrbYT --- .../world/bentobox/tradewinds/PortNames.java | 64 +++++++++++++ .../tradewinds/commands/TWChartCommand.java | 3 +- .../tradewinds/commands/TWPricesCommand.java | 7 +- .../tradewinds/crime/CustomsService.java | 22 +++-- .../tradewinds/crime/PoliceService.java | 4 +- .../tradewinds/economy/MarketService.java | 3 +- .../tradewinds/economy/TradeDialog.java | 19 ++-- .../tradewinds/ocean/NameGenerator.java | 75 +++++++++++---- .../tradewinds/ocean/OceanEngine.java | 12 +++ .../tradewinds/tasks/NavigationBarTask.java | 3 +- .../tradewinds/travel/ChartHolograms.java | 6 +- .../tradewinds/travel/ChartingListener.java | 4 +- .../tradewinds/travel/IntersticeService.java | 4 +- .../tradewinds/travel/StarChartRenderer.java | 22 ++++- .../tradewinds/travel/WarpService.java | 12 ++- src/main/resources/locales/en-US.yml | 38 ++++++++ .../bentobox/tradewinds/LocaleKeyTest.java | 15 +++ .../bentobox/tradewinds/PortNamesTest.java | 91 +++++++++++++++++++ .../tradewinds/ocean/NameGeneratorTest.java | 62 +++++++++++++ 19 files changed, 419 insertions(+), 47 deletions(-) create mode 100644 src/main/java/world/bentobox/tradewinds/PortNames.java create mode 100644 src/test/java/world/bentobox/tradewinds/PortNamesTest.java 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..f49a023 --- /dev/null +++ b/src/main/java/world/bentobox/tradewinds/PortNames.java @@ -0,0 +1,64 @@ +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 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) { + 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/TWChartCommand.java b/src/main/java/world/bentobox/tradewinds/commands/TWChartCommand.java index 7dd8c98..719fa09 100644 --- a/src/main/java/world/bentobox/tradewinds/commands/TWChartCommand.java +++ b/src/main/java/world/bentobox/tradewinds/commands/TWChartCommand.java @@ -7,6 +7,7 @@ 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; @@ -92,7 +93,7 @@ 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(), + TextVariables.NAME, PortNames.display(addon, user, spec), "[type]", user.getTranslation(spec.type().getLocaleKey()), "[tech]", String.valueOf(spec.techLevel()), "[band]", user.getTranslation(spec.band().getLocaleKey()), diff --git a/src/main/java/world/bentobox/tradewinds/commands/TWPricesCommand.java b/src/main/java/world/bentobox/tradewinds/commands/TWPricesCommand.java index 54f0730..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; @@ -122,10 +123,12 @@ 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, + user.sendMessage("tradewinds.commands.prices.row", "[name]", PortNames.display(addon, user, spec), + VALUE_PLACEHOLDER, best(user, addon, prices), "[age]", age); } } 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/MarketService.java b/src/main/java/world/bentobox/tradewinds/economy/MarketService.java index 7f8e437..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; @@ -595,7 +596,7 @@ public int sell(Player player, IslandSpec spec, ItemStack item, int amount) { if (!handlesValue(spec, item)) { User user = User.getInstance(player); user.sendMessage("tradewinds.trade.too-advanced", VAR_MATERIAL, ItemNames.label(user, material), - "[name]", spec.name()); + "[name]", PortNames.display(addon, user, spec)); thud(player); return 0; } diff --git a/src/main/java/world/bentobox/tradewinds/economy/TradeDialog.java b/src/main/java/world/bentobox/tradewinds/economy/TradeDialog.java index 4e399e6..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; @@ -75,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); } /** @@ -194,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); } @@ -244,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); @@ -307,7 +308,7 @@ public void openShelf(Player player, IslandSpec spec) { 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); } @@ -375,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); } @@ -417,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); } @@ -489,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); } @@ -609,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/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/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/ChartHolograms.java b/src/main/java/world/bentobox/tradewinds/travel/ChartHolograms.java index 40b013b..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,10 +301,11 @@ 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(), + "[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()), 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/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 0d5f39c..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,7 +147,8 @@ 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]", user(player).getTranslation(spec.type().getLocaleKey()), "[tech]", String.valueOf(spec.techLevel()), @@ -333,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 c1a4afa..c382cb8 100644 --- a/src/main/resources/locales/en-US.yml +++ b/src/main/resources/locales/en-US.yml @@ -232,6 +232,44 @@ tradewinds: 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: diff --git a/src/test/java/world/bentobox/tradewinds/LocaleKeyTest.java b/src/test/java/world/bentobox/tradewinds/LocaleKeyTest.java index 9818365..af874e8 100644 --- a/src/test/java/world/bentobox/tradewinds/LocaleKeyTest.java +++ b/src/test/java/world/bentobox/tradewinds/LocaleKeyTest.java @@ -19,6 +19,7 @@ import world.bentobox.tradewinds.economy.TradeCategory; import world.bentobox.tradewinds.ocean.IslandType; +import world.bentobox.tradewinds.ocean.NameGenerator; import world.bentobox.tradewinds.ocean.SecurityBand; /** @@ -142,6 +143,20 @@ void testEveryTradedMaterialHasALocaleLine() { 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..1609e11 --- /dev/null +++ b/src/test/java/world/bentobox/tradewinds/PortNamesTest.java @@ -0,0 +1,91 @@ +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 testChartDrawsOnlyWhatTheMapFontCan() { + assertEquals("Lave", StarChartRenderer.drawableName(port, "Lave")); + assertEquals(port.name(), StarChartRenderer.drawableName(port, "\u62c9\u7ef4")); + } +} 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<>(); From ea915fb793cc81af3b8293177274153e2d351cc0 Mon Sep 17 00:00:00 2001 From: tastybento Date: Thu, 3 Sep 2026 22:51:54 -0700 Subject: [PATCH 4/8] CLAUDE.md: the item and port naming rule from #7 Co-Authored-By: Claude Fable 5.1 Claude-Session: https://claude.ai/code/session_011kwguMkwTr1i1C8qxqrbYT --- CLAUDE.md | 9 +++++++++ 1 file changed, 9 insertions(+) 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()`. From ebd4313dfc83b30f08d8f736252dcaa9591ea828 Mon Sep 17 00:00:00 2001 From: tastybento Date: Thu, 3 Sep 2026 22:52:44 -0700 Subject: [PATCH 5/8] Pin the two BentoBox behaviours the item-name design rests on Variables are substituted and then the string is parsed as MiniMessage, and the legacy flattener prints a translatable's fallback. Both are checked against the real BentoBox Util so a core change fails the build here rather than in a playtest. Co-Authored-By: Claude Fable 5.1 Claude-Session: https://claude.ai/code/session_011kwguMkwTr1i1C8qxqrbYT --- .../tradewinds/economy/ItemNamesTest.java | 24 +++++++++++++++++++ 1 file changed, 24 insertions(+) diff --git a/src/test/java/world/bentobox/tradewinds/economy/ItemNamesTest.java b/src/test/java/world/bentobox/tradewinds/economy/ItemNamesTest.java index faa433d..bf6ca86 100644 --- a/src/test/java/world/bentobox/tradewinds/economy/ItemNamesTest.java +++ b/src/test/java/world/bentobox/tradewinds/economy/ItemNamesTest.java @@ -14,7 +14,11 @@ 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; /** @@ -76,6 +80,26 @@ void testPlayerTextCannotInjectTags() { 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 From 75d8ebda0258d92bb1fd17d0ab57b76610cd8b5a Mon Sep 17 00:00:00 2001 From: tastybento Date: Fri, 4 Sep 2026 06:59:31 -0700 Subject: [PATCH 6/8] Name the spawn port, the outfitter's gear and the hull itself through the locale Playtest with a Chinese BentoBox locale and an English client (#7): the spawn port read Spawn everywhere because its name is given, not generated, so the token table never touched it - PortNames now maps it to tradewinds.spawn.name. The outfitter's gear (rod, compass, iron kit, bed, pickaxe, mutton) is code, not config, so the materials list and its completeness test had missed it. And a boat's baked-in text - item name, lore, floating label - was written with the console's locale; it is now the owner's when they are online, and the hull's own item name goes through tradewinds.item.boat-name so it follows the locale (or the client) like every other material. Co-Authored-By: Claude Fable 5.1 Claude-Session: https://claude.ai/code/session_011kwguMkwTr1i1C8qxqrbYT --- .../world/bentobox/tradewinds/PortNames.java | 5 +++ .../tradewinds/travel/BoatService.java | 37 +++++++++++++++---- src/main/resources/locales/en-US.yml | 16 ++++++++ .../bentobox/tradewinds/LocaleKeyTest.java | 9 +++++ .../bentobox/tradewinds/PortNamesTest.java | 10 +++++ 5 files changed, 69 insertions(+), 8 deletions(-) diff --git a/src/main/java/world/bentobox/tradewinds/PortNames.java b/src/main/java/world/bentobox/tradewinds/PortNames.java index f49a023..96ac639 100644 --- a/src/main/java/world/bentobox/tradewinds/PortNames.java +++ b/src/main/java/world/bentobox/tradewinds/PortNames.java @@ -28,6 +28,7 @@ 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 @@ -43,6 +44,10 @@ private PortNames() { * 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(); diff --git a/src/main/java/world/bentobox/tradewinds/travel/BoatService.java b/src/main/java/world/bentobox/tradewinds/travel/BoatService.java index b504c3f..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 ? console.getTranslation("tradewinds.general.unknown") : 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/resources/locales/en-US.yml b/src/main/resources/locales/en-US.yml index c382cb8..3f58596 100644 --- a/src/main/resources/locales/en-US.yml +++ b/src/main/resources/locales/en-US.yml @@ -116,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." @@ -445,6 +447,18 @@ tradewinds: 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." @@ -552,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" diff --git a/src/test/java/world/bentobox/tradewinds/LocaleKeyTest.java b/src/test/java/world/bentobox/tradewinds/LocaleKeyTest.java index af874e8..b5a7514 100644 --- a/src/test/java/world/bentobox/tradewinds/LocaleKeyTest.java +++ b/src/test/java/world/bentobox/tradewinds/LocaleKeyTest.java @@ -17,7 +17,10 @@ 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; @@ -137,6 +140,12 @@ void testEveryTradedMaterialHasALocaleLine() { 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(); diff --git a/src/test/java/world/bentobox/tradewinds/PortNamesTest.java b/src/test/java/world/bentobox/tradewinds/PortNamesTest.java index 1609e11..d721601 100644 --- a/src/test/java/world/bentobox/tradewinds/PortNamesTest.java +++ b/src/test/java/world/bentobox/tradewinds/PortNamesTest.java @@ -83,6 +83,16 @@ void testNoEngineOrForeignSpecFallsBackToCanonical() { 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")); From 8c78111f6300b8ef43691369f5a442eebadb8f61 Mon Sep 17 00:00:00 2001 From: tastybento Date: Fri, 4 Sep 2026 07:10:09 -0700 Subject: [PATCH 7/8] Break hold tooltips into lines - a tooltip never wraps The border pane's six-clause instruction was one clipped line, in every language (playtest 2026-09-04). Hold lore keys now split on newlines into lore lines, the convention BentoBox's own panels use; each line carries its own colour tags because an open tag does not reach the next line. The border and TNT tooltips are rewritten as short lines. Co-Authored-By: Claude Fable 5.1 Claude-Session: https://claude.ai/code/session_011kwguMkwTr1i1C8qxqrbYT --- .../bentobox/tradewinds/travel/HoldGui.java | 23 +++++++++-- src/main/resources/locales/en-US.yml | 5 ++- .../tradewinds/travel/HoldLoreTest.java | 41 +++++++++++++++++++ 3 files changed, 64 insertions(+), 5 deletions(-) create mode 100644 src/test/java/world/bentobox/tradewinds/travel/HoldLoreTest.java diff --git a/src/main/java/world/bentobox/tradewinds/travel/HoldGui.java b/src/main/java/world/bentobox/tradewinds/travel/HoldGui.java index a8043ad..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,9 @@ 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; @@ -196,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; } @@ -208,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; } @@ -254,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; diff --git a/src/main/resources/locales/en-US.yml b/src/main/resources/locales/en-US.yml index 3f58596..30adad6 100644 --- a/src/main/resources/locales/en-US.yml +++ b/src/main/resources/locales/en-US.yml @@ -624,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/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()); + } +} From f57ad04a4bac14b79b6910e5f58a717db9cd66b9 Mon Sep 17 00:00:00 2001 From: tastybento Date: Fri, 4 Sep 2026 07:13:12 -0700 Subject: [PATCH 8/8] Add the Simplified Chinese locale MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Every key of en-US, checked by script for key, placeholder and tag parity. Materials are filled with the official zh-CN item names so chat lines name goods in Chinese too, the port-name syllables are transliterated (the lone A is 亚 rather than 阿 so no two English names can collapse into one Chinese one), and the spawn port is 出生港. Requested in #7. Co-Authored-By: Claude Fable 5.1 Claude-Session: https://claude.ai/code/session_011kwguMkwTr1i1C8qxqrbYT --- src/main/resources/locales/zh-CN.yml | 702 +++++++++++++++++++++++++++ 1 file changed, 702 insertions(+) create mode 100644 src/main/resources/locales/zh-CN.yml 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]敌人在附近时无法跃迁。"