Skip to content

Commit da673fc

Browse files
authored
feat: add hybrid localisation support with per-player locale resolution (#1062)
Adds multi-language support using a server-wide default locale with optional per-player override (similar to LuckPerms/EssentialsX). - Introduce MessageRegistry with immutable Snapshot for thread-safe atomic swaps during hot-reloads - Refactor Message class to use deferred resolution, applying token replacements at resolve time with locale-aware lookups - Add locale cascading fallback (e.g. zh_tw -> zh -> default) - Migrate messages from single messages.yml to messages/ directory with per-locale files (messages_en.yml, etc.), retaining messages.yml as a backward-compatible overlay - Add locale config (locale.default, locale.perPlayer) to config.yml - Persist player locale in bm_players table (VARCHAR(16)) via V2 database migration, gated by perPlayerLocale config - Add locale-aware kick(Message) and broadcast(Message, String) APIs to CommonPlayer and CommonServer - Migrate all kick() and broadcast() call sites (17 kick, 26 broadcast) to the new Message-based overloads - Implement platform-specific getLocale() across Bukkit, Bungee, Velocity, Sponge (API7+API8), and Fabric (with pre-1.21 fallback) - Add locale-aware DateUtils overloads for localised time formatting - Log loaded locales and missing key diagnostics on startup - Add comprehensive tests: MessageRegistry, Message deferred resolution, locale normalisation, player storage locale persistence, DB migration - Add E2E locale smoke test with German locale fixture - Update E2E sync-configs.sh to distribute messages/ directories
1 parent 4145882 commit da673fc

File tree

90 files changed

+4401
-181
lines changed

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

90 files changed

+4401
-181
lines changed

bukkit/src/main/java/me/confuser/banmanager/bukkit/BukkitPlayer.java

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@
77
import me.confuser.banmanager.common.kyori.text.TextComponent;
88
import me.confuser.banmanager.common.kyori.text.serializer.gson.GsonComponentSerializer;
99
import me.confuser.banmanager.common.util.Message;
10+
import me.confuser.banmanager.common.util.MessageRegistry;
1011
import me.confuser.banmanager.common.util.UUIDUtils;
1112
import net.md_5.bungee.chat.ComponentSerializer;
1213
import org.bukkit.Bukkit;
@@ -125,6 +126,13 @@ public boolean isOnline() {
125126
return getPlayer() != null;
126127
}
127128

129+
@Override
130+
public String getLocale() {
131+
Player p = getPlayer();
132+
if (p == null) return "en";
133+
return MessageRegistry.normaliseLocale(p.getLocale());
134+
}
135+
128136
private Player getPlayer() {
129137
if (player != null) return player;
130138
if (isOnlineMode()) return Bukkit.getServer().getPlayer(uuid);

bukkit/src/main/java/me/confuser/banmanager/bukkit/listeners/JoinListener.java

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -60,8 +60,9 @@ private class BanJoinHandler implements CommonJoinHandler {
6060
@Override
6161
public void handlePlayerDeny(PlayerData player, Message message) {
6262
plugin.getServer().callEvent("PlayerDeniedEvent", player, message);
63-
64-
handleDeny(message);
63+
String locale = player.getLocale() != null ? player.getLocale() : "en";
64+
event.setLoginResult(AsyncPlayerPreLoginEvent.Result.KICK_BANNED);
65+
event.setKickMessage(BukkitServer.formatMessage(message.resolve(locale)));
6566
}
6667

6768
@Override

bungee/src/main/java/me/confuser/banmanager/bungee/BungeePlayer.java

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@
66
import me.confuser.banmanager.common.data.PlayerData;
77
import me.confuser.banmanager.common.kyori.text.TextComponent;
88
import me.confuser.banmanager.common.util.Message;
9+
import me.confuser.banmanager.common.util.MessageRegistry;
910
import net.md_5.bungee.api.ProxyServer;
1011
import net.md_5.bungee.api.connection.ProxiedPlayer;
1112
import net.md_5.bungee.chat.ComponentSerializer;
@@ -110,6 +111,13 @@ public boolean canSee(CommonPlayer player) {
110111
return true;
111112
}
112113

114+
@Override
115+
public String getLocale() {
116+
java.util.Locale locale = player.getLocale();
117+
if (locale == null) return "en";
118+
return MessageRegistry.normaliseLocale(locale.toString());
119+
}
120+
113121
private ProxiedPlayer getPlayer() {
114122
return ProxyServer.getInstance().getPlayer(uuid);
115123
}

bungee/src/main/java/me/confuser/banmanager/bungee/listeners/JoinListener.java

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -58,8 +58,9 @@ private class BanJoinHandler implements CommonJoinHandler {
5858
@Override
5959
public void handlePlayerDeny(PlayerData player, Message message) {
6060
plugin.getServer().callEvent("PlayerDeniedEvent", player, message);
61-
62-
handleDeny(message);
61+
String locale = player.getLocale() != null ? player.getLocale() : "en";
62+
event.setCancelled(true);
63+
event.setCancelReason(BungeeServer.formatMessage(message.resolve(locale)));
6364
}
6465

6566
@Override

common/src/main/java/me/confuser/banmanager/common/BanManagerPlugin.java

Lines changed: 121 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -14,16 +14,19 @@
1414
import me.confuser.banmanager.common.ormlite.logger.LocalLog;
1515
import me.confuser.banmanager.common.ormlite.support.ConnectionSource;
1616
import me.confuser.banmanager.common.ormlite.support.DatabaseConnection;
17+
import me.confuser.banmanager.common.configuration.file.YamlConfiguration;
1718
import me.confuser.banmanager.common.runnables.Runner;
1819
import me.confuser.banmanager.common.storage.*;
1920
import me.confuser.banmanager.common.storage.global.*;
2021
import me.confuser.banmanager.common.storage.migration.MigrationRunner;
2122
import me.confuser.banmanager.common.storage.mariadb.MariaDBDatabase;
2223
import me.confuser.banmanager.common.storage.mysql.MySQLDatabase;
2324
import me.confuser.banmanager.common.util.DriverManagerUtil;
25+
import me.confuser.banmanager.common.util.Message;
26+
import me.confuser.banmanager.common.util.MessageRegistry;
2427

25-
import java.io.File;
26-
import java.io.IOException;
28+
import java.io.*;
29+
import java.nio.file.Files;
2730
import java.sql.SQLException;
2831

2932
import static java.lang.Long.parseLong;
@@ -153,6 +156,9 @@ public class BanManagerPlugin {
153156
@Getter
154157
private PlaceholderResolver placeholderResolver;
155158

159+
@Getter
160+
private MessageRegistry messageRegistry;
161+
156162
public BanManagerPlugin(PluginInfo pluginInfo, CommonLogger logger, File dataFolder, CommonScheduler scheduler, CommonServer server, CommonMetrics metrics) {
157163
this.pluginInfo = pluginInfo;
158164
this.logger = logger;
@@ -268,18 +274,126 @@ public final void disable() {
268274
}
269275

270276
public void setupConfigs() {
271-
MessagesConfig newMessagesConfig = new MessagesConfig(dataFolder, logger);
272-
if (!newMessagesConfig.load()) {
273-
logger.warning("Failed to reload messages.yml, keeping previous messages");
274-
}
275-
276277
config = reloadConfig(new DefaultConfig(dataFolder, logger), config, "config.yml");
277278
consoleConfig = reloadConfig(new ConsoleConfig(dataFolder, logger), consoleConfig, "console.yml");
278279
schedulesConfig = reloadConfig(new SchedulesConfig(dataFolder, logger), schedulesConfig, "schedules.yml");
279280
exemptionsConfig = reloadConfig(new ExemptionsConfig(dataFolder, logger), exemptionsConfig, "exemptions.yml");
280281
reasonsConfig = reloadConfig(new ReasonsConfig(dataFolder, logger), reasonsConfig, "reasons.yml");
281282
geoIpConfig = reloadConfig(new GeoIpConfig(dataFolder, logger), geoIpConfig, "geoip.yml");
282283
webhookConfig = reloadConfig(new WebhookConfig(dataFolder, logger), webhookConfig, "webhooks.yml");
284+
285+
loadMessages();
286+
}
287+
288+
private void loadMessages() {
289+
String defaultLocale = config != null ? config.getDefaultLocale() : "en";
290+
MessageRegistry newRegistry = new MessageRegistry(defaultLocale);
291+
292+
copyMessagesDirectory();
293+
294+
File messagesDir = new File(dataFolder, "messages");
295+
if (messagesDir.exists() && messagesDir.isDirectory()) {
296+
File[] files = messagesDir.listFiles((dir, name) ->
297+
name.startsWith("messages_") && name.endsWith(".yml"));
298+
299+
if (files != null) {
300+
for (File file : files) {
301+
String fileName = file.getName();
302+
String locale = fileName.substring("messages_".length(), fileName.length() - ".yml".length());
303+
loadLocaleFile(newRegistry, file, locale);
304+
}
305+
}
306+
}
307+
308+
File legacyMessages = new File(dataFolder, "messages.yml");
309+
if (legacyMessages.exists()) {
310+
loadLocaleFile(newRegistry, legacyMessages, defaultLocale);
311+
}
312+
313+
if (!newRegistry.hasAnyMessages()) {
314+
if (messageRegistry != null) {
315+
logger.warning("No messages loaded, keeping previous messages");
316+
return;
317+
}
318+
}
319+
320+
if (messageRegistry != null) {
321+
messageRegistry.atomicSwap(newRegistry);
322+
} else {
323+
messageRegistry = newRegistry;
324+
}
325+
326+
Message.init(messageRegistry, logger);
327+
328+
logLocaleInfo();
329+
}
330+
331+
private void logLocaleInfo() {
332+
if (messageRegistry == null) return;
333+
334+
java.util.Set<String> locales = messageRegistry.getAvailableLocales();
335+
logger.info("Loaded " + locales.size() + " locale(s): " + String.join(", ", locales));
336+
337+
String defaultLocale = messageRegistry.getDefaultLocale();
338+
for (String locale : locales) {
339+
if (locale.equals(defaultLocale)) continue;
340+
int missing = messageRegistry.getMissingKeyCount(locale);
341+
if (missing > 0) {
342+
logger.info("Locale '" + locale + "' is missing " + missing + " key(s) (will fall back to '" + defaultLocale + "')");
343+
}
344+
}
345+
}
346+
347+
private void loadLocaleFile(MessageRegistry registry, File file, String locale) {
348+
try {
349+
YamlConfiguration conf = new YamlConfiguration();
350+
conf.load(file);
351+
352+
if (conf.getConfigurationSection("messages") == null) {
353+
logger.warning("Messages section not found in " + file.getName() + ", skipping");
354+
return;
355+
}
356+
357+
java.util.Map<String, String> messages = new java.util.HashMap<>();
358+
359+
for (String key : conf.getConfigurationSection("messages").getKeys(true)) {
360+
String value = conf.getString("messages." + key);
361+
if (value != null) {
362+
messages.put(key, value.replace("\\n", "\n").replaceAll("(?<=\\n)(?=\\n)", " "));
363+
}
364+
}
365+
366+
if (!messages.isEmpty()) {
367+
java.util.Map<String, String> existing = registry.getMessages(locale);
368+
if (!existing.isEmpty()) {
369+
java.util.Map<String, String> merged = new java.util.HashMap<>(existing);
370+
merged.putAll(messages);
371+
registry.loadLocale(locale, merged);
372+
} else {
373+
registry.loadLocale(locale, messages);
374+
}
375+
}
376+
} catch (Exception e) {
377+
logger.warning("Failed to load " + file.getName(), e);
378+
}
379+
}
380+
381+
private void copyMessagesDirectory() {
382+
File messagesDir = new File(dataFolder, "messages");
383+
if (!messagesDir.exists()) {
384+
messagesDir.mkdirs();
385+
}
386+
387+
File defaultMessages = new File(messagesDir, "messages_en.yml");
388+
if (!defaultMessages.exists()) {
389+
try (InputStream in = getClass().getClassLoader().getResourceAsStream("messages/messages_en.yml")) {
390+
if (in != null) {
391+
Files.copy(in, defaultMessages.toPath());
392+
}
393+
} catch (IOException e) {
394+
logger.warning("Failed to copy default messages_en.yml", e);
395+
}
396+
}
283397
}
284398

285399
/**

common/src/main/java/me/confuser/banmanager/common/CommonPlayer.java

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,10 @@ public interface CommonPlayer extends CommonSender {
1212

1313
void kick(String message);
1414

15+
default void kick(Message message) {
16+
kick(message.resolveFor(this));
17+
}
18+
1519
void sendMessage(String message);
1620

1721
void sendMessage(Message message);
@@ -43,4 +47,6 @@ public interface CommonPlayer extends CommonSender {
4347
boolean teleport(CommonWorld world, double x, double y, double z, float pitch, float yaw);
4448

4549
boolean canSee(CommonPlayer player);
50+
51+
String getLocale();
4652
}

common/src/main/java/me/confuser/banmanager/common/CommonServer.java

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@
33
import me.confuser.banmanager.common.api.events.CommonEvent;
44
import me.confuser.banmanager.common.commands.CommonSender;
55
import me.confuser.banmanager.common.kyori.text.TextComponent;
6+
import me.confuser.banmanager.common.util.Message;
67

78
import java.util.UUID;
89

@@ -17,6 +18,15 @@ public interface CommonServer {
1718

1819
void broadcast(String message, String permission);
1920

21+
default void broadcast(Message message, String permission) {
22+
for (CommonPlayer player : getOnlinePlayers()) {
23+
if (player.hasPermission(permission)) {
24+
player.sendMessage(message.resolveFor(player));
25+
}
26+
}
27+
getConsoleSender().sendMessage(message.toString());
28+
}
29+
2030
void broadcastJSON(TextComponent message, String permission);
2131

2232
void broadcast(String message, String permission, CommonSender sender);

common/src/main/java/me/confuser/banmanager/common/commands/BanCommand.java

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -167,7 +167,7 @@ public boolean onCommand(CommonSender sender, CommandParser parser) {
167167
if (onlinePlayer != null) {
168168
final Message finalKickMessage = kickMessage;
169169
getPlugin().getScheduler().runSync(() -> {
170-
onlinePlayer.kick(finalKickMessage.toString());
170+
onlinePlayer.kick(finalKickMessage);
171171
});
172172
}
173173
});

common/src/main/java/me/confuser/banmanager/common/commands/BanIpCommand.java

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -130,7 +130,7 @@ public boolean onCommand(CommonSender sender, CommandParser parser) {
130130

131131
for (CommonPlayer onlinePlayer : getPlugin().getServer().getOnlinePlayers()) {
132132
if (IPUtils.toIPAddress(onlinePlayer.getAddress()).equals(ip)) {
133-
onlinePlayer.kick(kickMessage.toString());
133+
onlinePlayer.kick(kickMessage);
134134
}
135135
}
136136
});

common/src/main/java/me/confuser/banmanager/common/commands/BanIpRangeCommand.java

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -82,7 +82,7 @@ public boolean onCommand(final CommonSender sender, CommandParser parser) {
8282

8383
for (CommonPlayer onlinePlayer : getPlugin().getServer().getOnlinePlayers()) {
8484
if (ban.inRange(IPUtils.toIPAddress(onlinePlayer.getAddress()))) {
85-
onlinePlayer.kick(kickMessage.toString());
85+
onlinePlayer.kick(kickMessage);
8686
}
8787
}
8888
});

0 commit comments

Comments
 (0)