Skip to content

Commit d77abce

Browse files
authored
Merge pull request #1 from f0reachARR/feat/localize-enum-labels
enum の表示名を言語ファイルから解決する
2 parents 6482dba + efa452d commit d77abce

18 files changed

Lines changed: 456 additions & 112 deletions

File tree

AGENTS.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,7 @@
66
- Runtime resources are in `src/main/resources`:
77
- `paper-plugin.yml` for plugin metadata
88
- `config.yml` for plugin settings
9-
- `lang/messages_*.yml` for localized text
9+
- `lang/messages_*.yml` for localized text — keep `messages_en.yml` and `messages_ja.yml` in sync, and never print a domain enum with `name()`: route it through `locale/EnumLabels`, which reads `enum.<kebab-class>.<kebab-value>`
1010
- Build output is generated under `build/` (do not commit generated artifacts).
1111

1212
## Build, Test, and Development Commands

CLAUDE.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -56,6 +56,7 @@ CI runs both backends — see [.github/workflows/ci.yml](.github/workflows/ci.ym
5656
- BedrockDialog callbacks may fire off the main thread. Anything that touches Bukkit API must be wrapped in `Bukkit.getScheduler().runTask(plugin, ...)` — see existing flows in `shop/trade/TradeFlow` and `shop/edit/SlotEditFlow` for the pattern.
5757
- BedrockDialog only ships `ConfirmDialog` / `NoticeDialog` / `MultiButtonDialog` / `InputDialog` and has no `onClose` on Bedrock — design flows around explicit cancel buttons, not close detection. Sliders are banned for amount/price (use `InputDialog`).
5858
- Localization: every user-visible string lives in `lang/messages_*.yml` and both `messages_en.yml` and `messages_ja.yml` must stay in sync when keys are added.
59+
- Never render a domain enum with `name()` in player-facing output. `locale/EnumLabels` resolves `TradeSide` / `LimitScope` / `CoOwnerRole` / `ShopType` to `enum.<kebab-class>.<kebab-value>` (e.g. `enum.trade-side.sell`); insert the result with `Placeholder.component` so the surrounding message keeps supplying the color, and keep the label itself plain text. Adding an enum constant means adding the key to *both* locale files — `EnumLabelsTest` fails otherwise. `name()` remains correct for the DB, YAML import/export, command arguments and Dialog dropdown option IDs.
5960

6061
## Testing notes
6162

src/main/java/me/f0reach/vshop/command/CommandSupport.java

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
package me.f0reach.vshop.command;
22

33
import me.f0reach.vshop.ModernVillagerShopPlugin;
4+
import me.f0reach.vshop.locale.EnumLabels;
45
import me.f0reach.vshop.locale.MessageManager;
56
import me.f0reach.vshop.model.Shop;
67
import net.kyori.adventure.text.minimessage.tag.resolver.Placeholder;
@@ -20,16 +21,20 @@ public final class CommandSupport {
2021

2122
private final ModernVillagerShopPlugin plugin;
2223
private final MessageManager messages;
24+
private final EnumLabels enumLabels;
2325

2426
public CommandSupport(ModernVillagerShopPlugin plugin) {
2527
this.plugin = plugin;
2628
this.messages = plugin.messages();
29+
this.enumLabels = new EnumLabels(messages);
2730
}
2831

2932
public ModernVillagerShopPlugin plugin() { return plugin; }
3033

3134
public MessageManager messages() { return messages; }
3235

36+
public EnumLabels enumLabels() { return enumLabels; }
37+
3338
/** Resolves a shop by either an 8-char ID prefix or a full UUID. */
3439
public Shop findShopByPrefix(String prefix) {
3540
for (Shop s : plugin.registry().all()) {

src/main/java/me/f0reach/vshop/command/sub/HelpCommand.java

Lines changed: 6 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -13,19 +13,10 @@
1313
@SuppressWarnings("UnstableApiUsage")
1414
public final class HelpCommand {
1515

16-
private static final List<String> LINES = List.of(
17-
"<yellow>/vshop list [page] <gray>- ショップ一覧",
18-
"<yellow>/vshop open <shopId> <gray>- ショップを開く",
19-
"<yellow>/vshop edit <shopId> <gray>- 編集UIを開く",
20-
"<yellow>/vshop coowner <shopId> <gray>- 共同オーナー管理",
21-
"<yellow>/vshop transfer <shopId> <player> <gray>- PRIMARY 移譲",
22-
"<yellow>/vshop stats <shopId> <gray>- ショップ統計",
23-
"<yellow>/vshop search <item> [page] <gray>- アイテム検索",
24-
"<yellow>/vshop history [page] [--shop <id>] [--side sell|buy] [--from <date>] [--to <date>] [--player <name>] <gray>- 取引履歴",
25-
"<yellow>/vshop egg <player> <lines|inf|admin> <gray>- スポーンエッグ配布",
26-
"<yellow>/vshop migrate <from> <to> <gray>- ストレージ移行",
27-
"<yellow>/vshop reload <gray>- 設定リロード"
28-
);
16+
/** Display order; the text of each line lives in {@code command.help.<key>}. */
17+
private static final List<String> ENTRIES = List.of(
18+
"list", "open", "edit", "coowner", "transfer", "stats",
19+
"search", "history", "egg", "migrate", "reload");
2920

3021
private final CommandSupport support;
3122

@@ -40,8 +31,8 @@ public LiteralArgumentBuilder<CommandSourceStack> node() {
4031
public int execute(CommandContext<CommandSourceStack> ctx) {
4132
CommandSender sender = ctx.getSource().getSender();
4233
sender.sendMessage(support.messages().get("command.help.header"));
43-
for (String line : LINES) {
44-
sender.sendMessage(support.messages().miniMessage().deserialize(line));
34+
for (String entry : ENTRIES) {
35+
sender.sendMessage(support.messages().get("command.help." + entry));
4536
}
4637
return Command.SINGLE_SUCCESS;
4738
}

src/main/java/me/f0reach/vshop/command/sub/HistoryCommand.java

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -132,7 +132,7 @@ private int execute(CommandSender sender, String raw) {
132132
String key = hasFee ? "command.history.line-with-fee" : "command.history.line";
133133
java.util.List<net.kyori.adventure.text.minimessage.tag.resolver.TagResolver> tags = new java.util.ArrayList<>();
134134
tags.add(Placeholder.parsed("time", when));
135-
tags.add(Placeholder.parsed("side", rec.side().name()));
135+
tags.add(Placeholder.component("side", support.enumLabels().label(rec.side())));
136136
tags.add(Placeholder.component("item", Displays.item(rec.itemSnapshot())));
137137
tags.add(Placeholder.parsed("amount", Integer.toString(rec.amount())));
138138
tags.add(Placeholder.parsed("price", econ.format(rec.unitPrice())));

src/main/java/me/f0reach/vshop/command/sub/ListCommand.java

Lines changed: 23 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,11 @@
66
import io.papermc.paper.command.brigadier.CommandSourceStack;
77
import io.papermc.paper.command.brigadier.Commands;
88
import me.f0reach.vshop.command.CommandSupport;
9+
import me.f0reach.vshop.locale.MessageManager;
910
import me.f0reach.vshop.model.Shop;
11+
import me.f0reach.vshop.ui.text.Displays;
12+
import net.kyori.adventure.text.Component;
13+
import net.kyori.adventure.text.minimessage.tag.resolver.Placeholder;
1014
import org.bukkit.command.CommandSender;
1115

1216
import java.util.ArrayList;
@@ -42,14 +46,27 @@ private int execute(CommandSender sender, int page) {
4246
int from = (p - 1) * PER_PAGE;
4347
int to = Math.min(from + PER_PAGE, total);
4448

45-
var mm = support.messages().miniMessage();
46-
sender.sendMessage(mm.deserialize("<gold>=== ショップ一覧 (" + p + "/" + pages + ") ==="));
49+
MessageManager messages = support.messages();
50+
sender.sendMessage(messages.get("command.list.header",
51+
Placeholder.parsed("page", String.valueOf(p)),
52+
Placeholder.parsed("pages", String.valueOf(pages)),
53+
Placeholder.parsed("total", String.valueOf(total))));
54+
if (total == 0) {
55+
sender.sendMessage(messages.get("command.list.empty"));
56+
return Command.SINGLE_SUCCESS;
57+
}
4758
for (int i = from; i < to; i++) {
4859
Shop s = all.get(i);
49-
sender.sendMessage(mm.deserialize(
50-
"<yellow>" + s.id().toString().substring(0, 8)
51-
+ " <gray>- <white>" + s.name()
52-
+ " <dark_gray>[" + s.type() + (s.suspended() ? " SUSPENDED" : "") + "]"));
60+
// Shop names are player-supplied: insert as a component so a name
61+
// containing MiniMessage syntax cannot inject formatting.
62+
sender.sendMessage(messages.get("command.list.line",
63+
Placeholder.component("shop_id", Displays.shortId(s.id())),
64+
Placeholder.component("shop_name",
65+
Displays.nameWithHover(Displays.truncate(s.name(), 24), s.name())),
66+
Placeholder.component("type", support.enumLabels().label(s.type())),
67+
Placeholder.component("suspended", s.suspended()
68+
? messages.get("command.list.suspended-mark")
69+
: Component.empty())));
5370
}
5471
return Command.SINGLE_SUCCESS;
5572
}

src/main/java/me/f0reach/vshop/command/sub/SearchCommand.java

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -83,7 +83,7 @@ private int execute(CommandSender sender, String query, int page) {
8383
Placeholder.component("shop_name",
8484
Displays.nameWithHover(Displays.truncate(h.shop.name(), 24), h.shop.name())),
8585
Placeholder.component("item", Displays.item(h.slot.itemTemplate())),
86-
Placeholder.parsed("side", h.slot.side().name()),
86+
Placeholder.component("side", support.enumLabels().label(h.slot.side())),
8787
Placeholder.parsed("price",
8888
support.plugin().economyService().format(h.slot.unitPrice())));
8989
sender.sendMessage(line);

src/main/java/me/f0reach/vshop/command/sub/StatsCommand.java

Lines changed: 2 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@
88
import me.f0reach.vshop.command.CommandSupport;
99
import me.f0reach.vshop.economy.EconomyService;
1010
import me.f0reach.vshop.model.Shop;
11+
import me.f0reach.vshop.ui.text.StatsView;
1112
import org.bukkit.command.CommandSender;
1213

1314
import java.sql.SQLException;
@@ -39,14 +40,7 @@ private int execute(CommandSender sender, String shopIdPrefix) {
3940
var agg = support.plugin().api().statsFor(shop.id());
4041
int slotCount = support.plugin().storage().slots().findByShop(shop.id()).size();
4142
EconomyService econ = support.plugin().economyService();
42-
var mm = support.messages().miniMessage();
43-
sender.sendMessage(mm.deserialize("<gold>=== " + shop.name() + " 統計 ==="));
44-
sender.sendMessage(mm.deserialize("<gray>出品枠: <white>" + slotCount));
45-
sender.sendMessage(mm.deserialize("<gray>SELL件数: <white>" + agg.sellCount()
46-
+ " <gray>合計: <white>" + econ.format(agg.totalSalesValue())));
47-
sender.sendMessage(mm.deserialize("<gray>BUY件数: <white>" + agg.buyCount()
48-
+ " <gray>合計: <white>" + econ.format(agg.totalBuyValue())));
49-
sender.sendMessage(mm.deserialize("<gray>累計手数料: <white>" + econ.format(agg.totalFees())));
43+
new StatsView(support.messages(), econ).send(sender, shop, agg, slotCount);
5044
} catch (SQLException ex) {
5145
support.sendGenericError(sender, ex);
5246
return 0;
Lines changed: 76 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,76 @@
1+
package me.f0reach.vshop.locale;
2+
3+
import net.kyori.adventure.text.Component;
4+
import net.kyori.adventure.text.minimessage.MiniMessage;
5+
6+
import java.util.Locale;
7+
import java.util.function.Function;
8+
9+
/**
10+
* Resolves player-facing labels for the domain enums ({@code TradeSide},
11+
* {@code LimitScope}, {@code CoOwnerRole}, {@code ShopType}) from the locale
12+
* files, so raw constant names never reach players.
13+
*
14+
* <p>Keys are derived as {@code enum.<kebab-class>.<kebab-value>} — e.g.
15+
* {@code enum.trade-side.sell}, {@code enum.limit-scope.per-player},
16+
* {@code enum.co-owner-role.primary}. A missing key falls back to
17+
* {@link Enum#name()} rather than the message key, so an incomplete
18+
* translation degrades to the previous behavior instead of leaking
19+
* {@code enum.trade-side.sell} into a chest lore line.</p>
20+
*
21+
* <p>Labels are intentionally plain (no color tags): call sites insert them
22+
* with {@code Placeholder.component}, and the surrounding message supplies the
23+
* color — e.g. {@code "<yellow><side></yellow>"}.</p>
24+
*
25+
* <p>This is display only. Enum names remain the wire format for the database,
26+
* YAML import/export, command arguments and Dialog dropdown option IDs.</p>
27+
*/
28+
public final class EnumLabels {
29+
30+
private final Function<String, String> resolver;
31+
private final MiniMessage miniMessage;
32+
33+
public EnumLabels(MessageManager messages) {
34+
this(messages::getRaw, messages.miniMessage());
35+
}
36+
37+
/** For tests: pass a plain key-to-raw resolver instead of a live MessageManager. */
38+
public EnumLabels(Function<String, String> resolver, MiniMessage miniMessage) {
39+
this.resolver = resolver;
40+
this.miniMessage = miniMessage;
41+
}
42+
43+
/** Message key for {@code value} — e.g. {@code enum.limit-scope.per-player}. */
44+
public static String keyFor(Enum<?> value) {
45+
return "enum." + kebab(value.getDeclaringClass().getSimpleName())
46+
+ "." + value.name().toLowerCase(Locale.ROOT).replace('_', '-');
47+
}
48+
49+
/** Localized label as raw MiniMessage, or the constant name if untranslated. */
50+
public String raw(Enum<?> value) {
51+
if (value == null) return "";
52+
String key = keyFor(value);
53+
String found = resolver.apply(key);
54+
return found == null || found.equals(key) ? value.name() : found;
55+
}
56+
57+
/** Localized label, ready for {@code Placeholder.component}. */
58+
public Component label(Enum<?> value) {
59+
return miniMessage.deserialize(raw(value));
60+
}
61+
62+
/** {@code CoOwnerRole} -> {@code co-owner-role}. */
63+
private static String kebab(String simpleName) {
64+
StringBuilder out = new StringBuilder(simpleName.length() + 4);
65+
for (int i = 0; i < simpleName.length(); i++) {
66+
char c = simpleName.charAt(i);
67+
if (Character.isUpperCase(c)) {
68+
if (i > 0) out.append('-');
69+
out.append(Character.toLowerCase(c));
70+
} else {
71+
out.append(c);
72+
}
73+
}
74+
return out.toString();
75+
}
76+
}

0 commit comments

Comments
 (0)