根据最新设想逻辑重写

This commit is contained in:
Xujiayao 2025-12-20 17:53:31 +08:00
parent 11e7b32581
commit 6a873eea2b
14 changed files with 288 additions and 404 deletions

View file

@ -18,6 +18,7 @@ public class Constants {
public static final Logger LOGGER = new Logger();
// YAML_MAPPER has to be initialized before VERSION because getDmccVersion() uses it.
public static final ObjectMapper YAML_MAPPER = new ObjectMapper(new YAMLFactory()
.enable(YAMLGenerator.Feature.MINIMIZE_QUOTES)
.disable(YAMLGenerator.Feature.WRITE_DOC_START_MARKER));

View file

@ -3,6 +3,7 @@ package com.xujiayao.discord_mc_chat;
import com.xujiayao.discord_mc_chat.client.ClientDMCC;
import com.xujiayao.discord_mc_chat.commands.CommandEventHandler;
import com.xujiayao.discord_mc_chat.server.ServerDMCC;
import com.xujiayao.discord_mc_chat.standalone.TerminalManager;
import com.xujiayao.discord_mc_chat.utils.config.ConfigManager;
import com.xujiayao.discord_mc_chat.utils.config.ModeManager;
import com.xujiayao.discord_mc_chat.utils.events.EventManager;
@ -46,7 +47,7 @@ public class DMCC {
}
// Pad the version string to ensure consistent formatting in the banner
String versionString = VERSION + " ".repeat(Math.max(0, 31 - VERSION.length()));
String versionString = VERSION + " ".repeat(Math.max(0, 34 - VERSION.length()));
// Print the DMCC banner
LOGGER.info("┌─────────────────────────────────────────────────────────────────────────────────┐");
@ -56,36 +57,41 @@ public class DMCC {
LOGGER.info("│ | |_| | \\__ \\ (_| (_) | | | (_| |_____| | | | |__|_____| |___| | | | (_| | |_ │");
LOGGER.info("│ |____/|_|___/\\___\\___/|_| \\__,_| |_| |_|\\____| \\____|_| |_|\\__,_|\\__| │");
LOGGER.info("│ │");
LOGGER.info("│ Discord-MC-Chat (DMCC) {} More Information + Docs: │", versionString);
LOGGER.info("│ Discord-MC-Chat (DMCC) {} Discord-MC-Chat Docs: │", versionString);
LOGGER.info("│ By Xujiayao https://dmcc.xujiayao.com/ │");
LOGGER.info("└─────────────────────────────────────────────────────────────────────────────────┘");
LOGGER.info("Initializing DMCC {} with IS_MINECRAFT_ENV: {}", VERSION, IS_MINECRAFT_ENV);
// Initialize Command event handlers
CommandEventHandler.init();
// Initialize terminal manager for standalone mode
// Minecraft commands are initialized using ServiceLoader (MinecraftServiceImpl)
if (!IS_MINECRAFT_ENV) {
TerminalManager.init();
}
// If configuration fails to load, exit the DMCC-Main thread gracefully
// In a Minecraft environment, we just return and let the server continue running
// In standalone mode, the process would terminate after returning
// In standalone mode, the process will also remain alive awaiting user to reload
// User can run the reload command after fixing the issues
// Determine operating mode
if (!ModeManager.load()) {
LOGGER.warn("DMCC initialization halted because an operating mode needs to be selected");
return;
}
String reloadCommand = IS_MINECRAFT_ENV ? "/dmcc reload" : "/reload";
// Load configuration
if (!ConfigManager.load(ModeManager.getMode())) {
LOGGER.warn("DMCC will not continue initialization due to configuration issues");
return;
}
// Load language files
if (!I18nManager.load()) {
// Load DMCC internal translation
if (!I18nManager.loadInternalTranslationsOnly()) {
// Should not happen!
LOGGER.warn("DMCC will not continue initialization due to language file issues");
return;
}
// Initialize Command event handlers
CommandEventHandler.init();
if (!ModeManager.load() // Determine operating mode
|| !ConfigManager.load() // Load configuration
|| !I18nManager.load(ConfigManager.getString("language"))) { // Load all translations
LOGGER.warn("Please correct the errors mentioned above, then run \"{}\".", reloadCommand);
return;
}
// From now on should separate ServerDMCC and ClientDMCC initialization based on mode
switch (ModeManager.getMode()) {
@ -138,6 +144,10 @@ public class DMCC {
serverInstance.shutdown();
}
if (!IS_MINECRAFT_ENV) {
TerminalManager.shutdown();
}
// Shutdown Command event handler
CommandEventHandler.shutdown();
@ -148,14 +158,18 @@ public class DMCC {
try (ExecutorService executorService = OK_HTTP_CLIENT.dispatcher().executorService();
Cache ignored1 = OK_HTTP_CLIENT.cache()) {
executorService.shutdown();
if (ConfigManager.getBoolean("shutdown.graceful_shutdown")) {
// Allow up to 30 minutes for ongoing requests to complete
boolean ignored2 = executorService.awaitTermination(30, TimeUnit.MINUTES);
} else {
try {
if (ConfigManager.getBoolean("shutdown.graceful_shutdown")) {
// Allow up to 30 minutes for ongoing requests to complete
boolean ignored2 = executorService.awaitTermination(30, TimeUnit.MINUTES);
}
} catch (NullPointerException ignored) {
} finally {
// Allow up to 5 seconds for ongoing requests to complete
boolean ignored2 = executorService.awaitTermination(5, TimeUnit.SECONDS);
}
executorService.shutdownNow();
OK_HTTP_CLIENT.connectionPool().evictAll();
} catch (Exception e) {
LOGGER.error("An error occurred during OkHttpClient shutdown", e);

View file

@ -22,10 +22,10 @@ public class ClientBusinessHandler extends SimpleChannelInboundHandler<Packet> {
if (packet instanceof Packets.HandshakeSuccess(String messageKey, String language)) {
LOGGER.info(I18nManager.getDmccTranslation(messageKey));
// Synchronize language with server
if (!I18nManager.load(language)) {
LOGGER.error("Failed to load language \"{}\" from server. DMCC may not function correctly.", language);
}
// // Synchronize language with server
// if (!I18nManager.load(language)) {
// LOGGER.error("Failed to load language \"{}\" from server. DMCC may not function correctly.", language);
// }
} else if (packet instanceof Packets.HandshakeFailure(String messageKey)) {
LOGGER.error("Handshake with server failed: {}", I18nManager.getDmccTranslation(messageKey));

View file

@ -31,19 +31,19 @@ public class CommandEventHandler {
DMCC.reload();
}));
EventManager.register(CommandEvents.ShutdownEvent.class, event -> commandExecutor.submit(() -> {
LOGGER.info(I18nManager.getDmccTranslation("commands.shutdown.shutting_down"));
new Thread(() -> {
if (IS_MINECRAFT_ENV) {
LOGGER.info("Run \"/dmcc start\" to start DMCC again");
DMCC.shutdown();
} else {
// This will trigger the shutdown hook in StandaloneDMCC
System.exit(0);
}
}, "DMCC-Shutdown").start();
}));
// EventManager.register(CommandEvents.ShutdownEvent.class, event -> commandExecutor.submit(() -> {
// LOGGER.info(I18nManager.getDmccTranslation("commands.shutdown.shutting_down"));
//
// new Thread(() -> {
// if (IS_MINECRAFT_ENV) {
// LOGGER.info("Run \"/dmcc enable\" to start DMCC again");
// DMCC.shutdown();
// } else {
// // This will trigger the shutdown hook in StandaloneDMCC
// System.exit(0);
// }
// }, "DMCC-Shutdown").start();
// }));
LOGGER.info("Initialized all Command event handlers");
}

View file

@ -32,9 +32,9 @@ public class CommandManager {
switch (command) {
case "help" -> {
response.add("==================== " + I18nManager.getDmccTranslation("commands.help.help") + " ====================");
response.add("- help | " + I18nManager.getDmccTranslation("commands.help.description"));
response.add("- reload | " + I18nManager.getDmccTranslation("commands.reload.description"));
response.add("- shutdown | " + I18nManager.getDmccTranslation("commands.shutdown.description"));
response.add("/help | " + I18nManager.getDmccTranslation("commands.help.description"));
response.add("/reload | " + I18nManager.getDmccTranslation("commands.reload.description"));
response.add("/shutdown | " + I18nManager.getDmccTranslation("commands.shutdown.description"));
}
case "reload" -> EventManager.post(new CommandEvents.ReloadEvent());
case "shutdown" -> EventManager.post(new CommandEvents.ShutdownEvent());

View file

@ -43,10 +43,6 @@ public class ServerDMCC {
return -1;
}
if (!IS_MINECRAFT_ENV) {
TerminalManager.init();
}
nettyServer = new NettyServer();
this.port = nettyServer.start(host, port);
return this.port;
@ -67,10 +63,6 @@ public class ServerDMCC {
Thread.currentThread().interrupt();
}
if (!IS_MINECRAFT_ENV) {
TerminalManager.shutdown();
}
executor.shutdown();
try {
if (!executor.awaitTermination(5, TimeUnit.SECONDS)) {

View file

@ -30,10 +30,10 @@ public class TerminalManager {
// The Scanner is created here and never closed, to keep System.in open.
// Only process commands if the TerminalManager is in a "running" state.
// This can happen if System.in is closed externally, which signals the end.
Thread consoleThread = new Thread(() -> {
Thread terminalThread = new Thread(() -> {
// The Scanner is created here and never closed, to keep System.in open.
Scanner scanner = new Scanner(System.in);
LOGGER.info("Interactive terminal started. Type \"help\" for a list of available commands.");
LOGGER.info("Interactive terminal started. Type \"/help\" for a list of available commands.");
while (!Thread.currentThread().isInterrupted()) {
try {
@ -55,8 +55,7 @@ public class TerminalManager {
}
}, "DMCC-Terminal");
consoleThread.setDaemon(true); // Set as daemon so it doesn't prevent JVM exit
consoleThread.start();
terminalThread.start();
}
// Set the running state to true, allowing the loop to process commands.

View file

@ -47,7 +47,7 @@ public class EnvironmentUtils {
JsonNode templateConfig = YAML_MAPPER.readTree(templateStream);
// Extract version field
String version = templateConfig.path("version").asText("");
String version = templateConfig.path("version").asText();
if (version.isBlank()) {
throw new RuntimeException("Version field not found in template configuration");
@ -58,25 +58,4 @@ public class EnvironmentUtils {
throw new RuntimeException("Failed to identify DMCC version", e);
}
}
/**
* Determines the current Minecraft version based on the environment.
*
* @return The Minecraft version string.
*/
public static String getMinecraftVersion() {
if (IS_MINECRAFT_ENV) {
// Minecraft Environment
try {
Class<?> sharedConstantsClass = Class.forName("net.minecraft.SharedConstants");
Object worldVersionObject = sharedConstantsClass.getMethod("getCurrentVersion").invoke(null);
return (String) worldVersionObject.getClass().getMethod("name").invoke(worldVersionObject);
} catch (Exception e) {
throw new RuntimeException("Failed to get Minecraft version in Minecraft environment", e);
}
}
// Standalone
return ConfigManager.getString("minecraft_version");
}
}

View file

@ -9,6 +9,7 @@ import java.util.Iterator;
import java.util.List;
import java.util.Set;
import static com.xujiayao.discord_mc_chat.Constants.IS_MINECRAFT_ENV;
import static com.xujiayao.discord_mc_chat.Constants.LOGGER;
/**
@ -18,93 +19,62 @@ import static com.xujiayao.discord_mc_chat.Constants.LOGGER;
*/
public class YamlUtils {
private static final List<String> AUTO_GENERATED_KEYS = List.of(
"multi_server.security.shared_secret"
);
private static final List<String> REQUIRED_MODIFIED_KEYS = List.of(
"bot.token",
"multi_server.server_name",
"multi_server.security.shared_secret"
"discord.bot.token",
"multi_server.server_name"
);
/**
* Validates the loaded config against the template.
* Checks if config is identical to template or if versions do not match.
* Also verifies that the structure of the config matches the template.
*
* @param config The user-loaded config to validate
* @param templateConfig The template config to validate against
* @param configPath The path to the config file for logging purposes
* @return true if the config is valid, false otherwise
*/
public static boolean validate(JsonNode config, JsonNode templateConfig, Path configPath) {
return validate(config, templateConfig, configPath, true);
}
/**
* Validates the loaded config against the template with optional check for modification.
*
* @param config The user-loaded config to validate
* @param userConfig The user-loaded config to validate
* @param templateConfig The template config to validate against
* @param configPath The path to the config file for logging purposes
* @param errorOnUnmodified If true, an error is logged if the file is identical to the template
* @return true if the config is valid, false otherwise
*/
public static boolean validate(JsonNode config, JsonNode templateConfig, Path configPath, boolean errorOnUnmodified) {
public static boolean validate(JsonNode userConfig, JsonNode templateConfig, Path configPath, boolean errorOnUnmodified) {
LOGGER.info("Validating configuration file at \"{}\"...", configPath);
// Check if config is identical to template (user made no changes)
if (errorOnUnmodified && isEffectivelyIdentical(config, templateConfig)) {
if (errorOnUnmodified && userConfig.equals(templateConfig)) {
LOGGER.error("Configuration file has not been modified from default template");
LOGGER.info("Please edit the file at \"{}\"", configPath);
return false;
}
// Check config version
String configVersion = config.path("version").asText(null);
String templateVersion = templateConfig.path("version").asText(null);
String configVersion = userConfig.path("version").asText();
String templateVersion = templateConfig.path("version").asText();
if (configVersion == null && templateVersion == null) {
LOGGER.error("Failed to find valid \"version\" in both user config and template config");
LOGGER.error("This is a bug in DMCC. Please report this issue!");
return false;
} else if (configVersion == null) {
LOGGER.error("User configuration file is missing the required \"version\" field");
return false;
} else if (templateVersion == null) {
LOGGER.error("Template configuration file is missing the required \"version\" field");
LOGGER.error("This is a bug in DMCC. Please report this issue!");
return false;
} else if (!templateVersion.equals(configVersion)) {
if (!templateVersion.equals(configVersion)) {
LOGGER.error("Configuration version mismatch. Expected version: {}, Found version: {}", templateVersion, configVersion);
LOGGER.info("Please upgrade your configuration file");
LOGGER.error("Please upgrade your configuration file on Discord-MC-Chat Docs");
return false;
}
// Check for missing and extra keys in the user's config
Set<String> missingKeys = new HashSet<>();
Set<String> extraKeys = new HashSet<>();
findKeyDiffs(templateConfig, config, "", missingKeys, extraKeys);
findKeyDiffs(templateConfig, userConfig, "", missingKeys, extraKeys);
if (!extraKeys.isEmpty()) {
LOGGER.warn("Your configuration file contains the following unrecognized keys:");
for (String key : extraKeys) {
LOGGER.warn(" - {}", key);
if (!extraKeys.isEmpty() || !missingKeys.isEmpty()) {
if (!extraKeys.isEmpty()) {
LOGGER.warn("Your configuration file contains the following unrecognized keys:");
for (String key : extraKeys) {
LOGGER.warn(" - {}", key);
}
}
LOGGER.warn("These keys will be ignored. However, you are recommended to remove them to avoid confusion!");
}
if (!missingKeys.isEmpty()) {
LOGGER.error("Your configuration file is missing the following required keys:");
for (String key : missingKeys) {
LOGGER.error(" - {}", key);
if (!missingKeys.isEmpty()) {
LOGGER.error("Your configuration file is missing the following required keys:");
for (String key : missingKeys) {
LOGGER.error(" - {}", key);
}
}
return false;
}
// Check all node types for all items recursively
Set<String> typeIssues = validateNodeTypes(templateConfig, config, "");
Set<String> typeIssues = validateNodeTypes(templateConfig, userConfig, "");
if (!typeIssues.isEmpty()) {
LOGGER.error("Your configuration file has type mismatch issues:");
for (String issue : typeIssues) {
@ -115,64 +85,18 @@ public class YamlUtils {
// A hard-coded list of keys that should be modified by the user
// This is to catch cases where the user leaves a key unchanged from the template
Set<String> unmodifiedKeys = findUnmodifiedKeys(config, templateConfig);
Set<String> unmodifiedKeys = findUnmodifiedKeys(userConfig, templateConfig);
if (!unmodifiedKeys.isEmpty()) {
LOGGER.error("The following configuration keys are still unchanged from the template:");
for (String key : unmodifiedKeys) {
LOGGER.error(" - {}", key);
}
LOGGER.info("Please modify them in the configuration file at \"{}\"", configPath);
return false;
}
return true;
}
/**
* Checks if two JsonNodes are effectively identical, ignoring auto-generated keys.
*
* @param config The user config node.
* @param templateConfig The template config node.
* @return true if they are identical after ignoring specified keys.
*/
private static boolean isEffectivelyIdentical(JsonNode config, JsonNode templateConfig) {
if (config.equals(templateConfig)) {
return true;
}
// Create copies to avoid modifying original nodes
ObjectNode configCopy = config.deepCopy();
ObjectNode templateCopy = templateConfig.deepCopy();
// Remove auto-generated keys from both copies before comparison
for (String key : AUTO_GENERATED_KEYS) {
removeNestedKey(configCopy, key);
removeNestedKey(templateCopy, key);
}
return configCopy.equals(templateCopy);
}
/**
* Recursively removes a nested key (e.g., "a.b.c") from an ObjectNode.
*
* @param node The node to modify.
* @param key The dot-separated key to remove.
*/
private static void removeNestedKey(ObjectNode node, String key) {
String[] parts = key.split("\\.");
JsonNode currentNode = node;
for (int i = 0; i < parts.length - 1; i++) {
currentNode = currentNode.path(parts[i]);
if (currentNode.isMissingNode() || !currentNode.isObject()) {
return; // Parent path doesn't exist
}
}
if (currentNode instanceof ObjectNode parentNode) {
parentNode.remove(parts[parts.length - 1]);
}
}
/**
* Recursively finds missing and extra keys between the template and the user config.
*
@ -283,9 +207,7 @@ public class YamlUtils {
templateNode = templateNode.path(part);
}
// If either node is missing, it's not an "unmodified" key in the sense we're checking.
// The missing key itself is handled by findKeyDiffs.
// Or it is the case that config files of different modes have different keys.
// If either node is missing, it is the case that config files of different modes have different keys.
// Or it is the case that language files and config files have different keys.
if (configNode.isMissingNode() || templateNode.isMissingNode()) {
continue;

View file

@ -12,8 +12,7 @@ import java.nio.file.Paths;
import java.nio.file.StandardCopyOption;
import java.util.function.Function;
import static com.xujiayao.discord_mc_chat.Constants.LOGGER;
import static com.xujiayao.discord_mc_chat.Constants.YAML_MAPPER;
import static com.xujiayao.discord_mc_chat.Constants.*;
/**
* Configuration manager for DMCC.
@ -23,84 +22,75 @@ import static com.xujiayao.discord_mc_chat.Constants.YAML_MAPPER;
*/
public class ConfigManager {
private static final Path CONFIG_PATH = Paths.get("./config/discord_mc_chat/config.yml");
private static final Path CONFIG_FILE_PATH = Paths.get("./config/discord_mc_chat/config.yml");
private static JsonNode config;
/**
* Loads the configuration file based on the determined operating mode.
*
* @param expectedMode The mode DMCC is expected to run in.
* @return true if the config was loaded and validated successfully, false otherwise.
*/
public static boolean load(String expectedMode) {
public static boolean load() {
String expectedMode = ModeManager.getMode();
String configTemplatePath = "/config/config_" + expectedMode + ".yml";
try {
// Create directories if they do not exist
Files.createDirectories(CONFIG_PATH.getParent());
Files.createDirectories(CONFIG_FILE_PATH.getParent());
// If config.yml does not exist or is empty, create it from the appropriate template.
if (!Files.exists(CONFIG_PATH) || Files.size(CONFIG_PATH) == 0) {
createDefaultConfig(expectedMode);
if (!Files.exists(CONFIG_FILE_PATH) || Files.size(CONFIG_FILE_PATH) == 0) {
LOGGER.warn("Configuration file not found or is empty");
LOGGER.warn("Creating a default one at \"{}\"", CONFIG_FILE_PATH);
LOGGER.warn("Please edit \"{}\" before reloading DMCC", CONFIG_FILE_PATH);
try (InputStream inputStream = ConfigManager.class.getResourceAsStream(configTemplatePath)) {
if (inputStream == null) {
throw new IOException("Default config template not found: " + configTemplatePath);
}
// Copy the template config file as is
Files.copy(inputStream, CONFIG_FILE_PATH, StandardCopyOption.REPLACE_EXISTING);
}
return false;
}
// Load the user's config.yml
JsonNode userConfig = YAML_MAPPER.readTree(Files.newBufferedReader(CONFIG_PATH, StandardCharsets.UTF_8));
JsonNode userConfig = YAML_MAPPER.readTree(Files.newBufferedReader(CONFIG_FILE_PATH, StandardCharsets.UTF_8));
// Check for mode consistency
String configMode = userConfig.path("mode").asText();
if (!expectedMode.equals(configMode)) {
LOGGER.error("Mode mismatch detected!");
LOGGER.error("The expected mode is \"{}\" (from mode.yml or environment), but config.yml is for \"{}\".", expectedMode, configMode);
LOGGER.error("Please backup and delete your existing config.yml to allow DMCC to generate a new and correct one, then run \"/dmcc reload\".");
LOGGER.error("Please backup and delete your existing config.yml to allow DMCC to generate a new and correct one.");
return false;
}
// Load the corresponding template for validation
String templatePath = "/config/config_" + expectedMode + ".yml";
JsonNode templateConfig;
try (InputStream templateStream = ConfigManager.class.getResourceAsStream(templatePath)) {
try (InputStream templateStream = ConfigManager.class.getResourceAsStream(configTemplatePath)) {
if (templateStream == null) {
LOGGER.error("Could not find configuration template in resources: {}", templatePath);
return false;
throw new IOException("Default config template not found: " + configTemplatePath);
}
templateConfig = YAML_MAPPER.readTree(templateStream);
}
// Validate config
if (YamlUtils.validate(userConfig, templateConfig, CONFIG_PATH)) {
ConfigManager.config = userConfig;
LOGGER.info("Configuration loaded successfully!");
return true;
if (!YamlUtils.validate(userConfig, templateConfig, CONFIG_FILE_PATH, true)) {
LOGGER.error("Validation of config.yml failed");
return false;
}
ConfigManager.config = userConfig;
LOGGER.info("Configuration loaded successfully!");
return true;
} catch (IOException e) {
LOGGER.error("Failed to load or validate configuration", e);
return false;
}
return false;
}
/**
* Creates a default config.yml from a template based on the mode.
* For standalone mode, it also generates and injects a random shared_secret.
*
* @param mode The operating mode which determines the template to use.
*/
private static void createDefaultConfig(String mode) throws IOException {
String templateName = "/config/config_" + mode + ".yml";
LOGGER.warn("Configuration file not found or is empty. Creating a new one for \"{}\" mode.", mode);
try (InputStream inputStream = ConfigManager.class.getResourceAsStream(templateName)) {
if (inputStream == null) {
throw new IOException("Default config template not found: " + templateName);
}
// Copy the template config file as is
Files.copy(inputStream, CONFIG_PATH, StandardCopyOption.REPLACE_EXISTING);
}
LOGGER.info("Created default configuration file at \"{}\"", CONFIG_PATH);
LOGGER.info("Please edit the configuration file before reloading or restarting DMCC");
}
/**
@ -120,7 +110,7 @@ public class ConfigManager {
JsonNode node = config;
for (String part : parts) {
if (node == null || node.isMissingNode()) {
if (node == null || node.isMissingNode() || node.isNull()) {
LOGGER.warn("Configuration path not found: {}", path);
return node;
}

View file

@ -5,6 +5,7 @@ import com.xujiayao.discord_mc_chat.utils.YamlUtils;
import java.io.IOException;
import java.io.InputStream;
import java.nio.charset.StandardCharsets;
import java.nio.file.Files;
import java.nio.file.Path;
import java.nio.file.Paths;
@ -33,55 +34,52 @@ public class ModeManager {
* @return true if the mode was loaded and validated successfully, false otherwise.
*/
public static boolean load() {
try {
if (!IS_MINECRAFT_ENV) {
mode = "standalone";
LOGGER.info("Operating mode set to \"{}\"", mode);
return true;
}
if (!IS_MINECRAFT_ENV) {
mode = "standalone";
LOGGER.info("Operating mode set to \"{}\"", mode);
return true;
}
try {
// Create directories if they do not exist
Files.createDirectories(MODE_FILE_PATH.getParent());
// If mode.yml does not exist, create it from the template
if (!Files.exists(MODE_FILE_PATH) || Files.size(MODE_FILE_PATH) == 0) {
LOGGER.warn("Mode configuration file not found or is empty. Creating a default one.");
LOGGER.info("Please edit \"{}\" to select an operating mode, then run \"/dmcc reload\"", MODE_FILE_PATH);
LOGGER.warn("Mode configuration file not found or is empty");
LOGGER.warn("Creating a default one at \"{}\"", MODE_FILE_PATH);
LOGGER.warn("Please edit \"{}\" before reloading DMCC", MODE_FILE_PATH);
try (InputStream inputStream = ModeManager.class.getResourceAsStream(MODE_TEMPLATE_PATH)) {
if (inputStream == null) {
throw new IOException("Default mode template not found: " + MODE_TEMPLATE_PATH);
}
Files.copy(inputStream, MODE_FILE_PATH, StandardCopyOption.REPLACE_EXISTING);
}
return false; // Halt initialization, requires user action
}
// Load the user's mode.yml
JsonNode modeConfig = YAML_MAPPER.readTree(Files.newBufferedReader(MODE_FILE_PATH));
JsonNode userModeConfig = YAML_MAPPER.readTree(Files.newBufferedReader(MODE_FILE_PATH, StandardCharsets.UTF_8));
// Load the template for validation
JsonNode templateConfig;
JsonNode templateModeConfig;
try (InputStream templateStream = ModeManager.class.getResourceAsStream(MODE_TEMPLATE_PATH)) {
templateConfig = YAML_MAPPER.readTree(templateStream);
templateModeConfig = YAML_MAPPER.readTree(templateStream);
}
// Validate the mode file
if (!YamlUtils.validate(modeConfig, templateConfig, MODE_FILE_PATH, true)) {
LOGGER.error("Validation of mode.yml failed. Please correct the errors mentioned above.");
if (!YamlUtils.validate(userModeConfig, templateModeConfig, MODE_FILE_PATH, true)) {
LOGGER.error("Validation of mode.yml failed");
return false;
}
String loadedMode = modeConfig.path("mode").asText(null);
if (loadedMode == null || loadedMode.trim().isEmpty() || "your_option_here".equals(loadedMode)) {
LOGGER.error("No mode selected in \"{}\"", MODE_FILE_PATH);
LOGGER.error("Please edit the file to select an operating mode, then run \"/dmcc reload\"");
return false;
}
String loadedMode = userModeConfig.path("mode").asText();
if (!"single_server".equals(loadedMode) && !"multi_server_client".equals(loadedMode)) {
LOGGER.error("Invalid mode \"{}\" selected in \"{}\".", loadedMode, MODE_FILE_PATH);
LOGGER.error("Available modes are: single_server, multi_server_client");
LOGGER.error("Available modes are: \"single_server\", \"multi_server_client\"");
return false;
}

View file

@ -1,14 +1,10 @@
package com.xujiayao.discord_mc_chat.utils.i18n;
import com.fasterxml.jackson.core.type.TypeReference;
import com.fasterxml.jackson.databind.JsonNode;
import com.xujiayao.discord_mc_chat.utils.EnvironmentUtils;
import com.xujiayao.discord_mc_chat.DMCC;
import com.xujiayao.discord_mc_chat.utils.StringUtils;
import com.xujiayao.discord_mc_chat.utils.YamlUtils;
import com.xujiayao.discord_mc_chat.utils.config.ConfigManager;
import com.xujiayao.discord_mc_chat.utils.config.ModeManager;
import okhttp3.Request;
import okhttp3.Response;
import java.io.IOException;
import java.io.InputStream;
@ -17,14 +13,10 @@ import java.nio.file.Files;
import java.nio.file.Path;
import java.nio.file.Paths;
import java.nio.file.StandardCopyOption;
import java.text.MessageFormat;
import java.util.HashMap;
import java.util.Map;
import java.util.Objects;
import static com.xujiayao.discord_mc_chat.Constants.JSON_MAPPER;
import static com.xujiayao.discord_mc_chat.Constants.LOGGER;
import static com.xujiayao.discord_mc_chat.Constants.OK_HTTP_CLIENT;
import static com.xujiayao.discord_mc_chat.Constants.YAML_MAPPER;
/**
@ -35,39 +27,37 @@ import static com.xujiayao.discord_mc_chat.Constants.YAML_MAPPER;
*/
public class I18nManager {
private static String language = "en_us";
private static final Map<String, String> DMCC_TRANSLATIONS = new HashMap<>();
private static final Path CUSTOM_MESSAGES_DIR = Paths.get("./config/discord_mc_chat/custom_messages");
private static final Path CACHE_DIR = Paths.get("./config/discord_mc_chat/cache/lang");
private static final Map<String, String> dmccTranslations = new HashMap<>();
private static final Map<String, String> minecraftTranslations = new HashMap<>();
private static String language;
private static JsonNode customMessages;
private static final Path CACHE_DIR = Paths.get("./config/discord_mc_chat/cache/lang");
private static final Map<String, String> minecraftTranslations = new HashMap<>();
/**
* Loads and validates all language files based on the configuration.
* This method is typically called during DMCC initialization and reloads.
* Loads only DMCC's internal translations from resources.
*
* @return true if all files were loaded successfully, false otherwise.
* @return true if DMCC translations were loaded successfully, false otherwise.
*/
public static boolean load() {
return load(ConfigManager.getString("language"));
public static boolean loadInternalTranslationsOnly() {
if (DMCC_TRANSLATIONS.isEmpty()) {
// Check if required resource files exist for the selected language
if (!checkLanguageResources()) {
return false;
}
// Load DMCC internal translations
return loadDmccTranslations();
}
return true;
}
/**
* Loads and validates all language files for a specific language.
* This method can be called to reload translations for a different language.
* <p>
* This method is only called on the client side when the server instructs a language change.
*
* @param newLanguage The language to load (e.g., "en_us", "zh_cn").
* @return true if all files were loaded successfully, false otherwise.
*/
public static boolean load(String newLanguage) {
// If the language is the same as the currently loaded one, no need to reload.
if (Objects.equals(language, newLanguage)) {
return true;
}
language = newLanguage;
public static boolean load(String lang) {
language = lang;
// Check if required resource files exist for the selected language
if (!checkLanguageResources()) {
@ -79,24 +69,22 @@ public class I18nManager {
return false;
}
// For client-only mode, we only need DMCC translations for logs and basic messages.
// The server will provide the language, and this method will be called again if needed.
if ("multi_server_client".equals(ModeManager.getMode()) && customMessages == null) {
LOGGER.info("DMCC internal translations for \"{}\" loaded successfully! Full I18n suite will be loaded after handshake.", language);
return true;
}
if ("multi_server_client".equals(ModeManager.getMode())) {
// For client-only mode, we only need DMCC translations for logs and basic messages.
LOGGER.info("DMCC internal translations for \"{}\" loaded successfully!", language);
} else {
// For server-enabled modes, load the full I18n suite.
if (!loadCustomMessages()) {
return false;
}
// For server-enabled modes, or after a client has been instructed to change lang, load the full I18n suite.
if (!loadCustomMessages()) {
return false;
}
// Load official Minecraft translations, from cache or by downloading
// if (!loadMinecraftTranslations()) {
// return false;
// }
// Load official Minecraft translations, from cache or by downloading
if (!loadMinecraftTranslations()) {
return false;
LOGGER.info("All language files for \"{}\" loaded successfully!", language);
}
LOGGER.info("All language files for \"{}\" loaded successfully!", language);
return true;
}
@ -122,12 +110,32 @@ public class I18nManager {
return true;
}
/**
* Loads DMCC's internal translation file from resources.
*
* @return true if the translations were loaded successfully.
*/
private static boolean loadDmccTranslations() {
DMCC_TRANSLATIONS.clear();
String resourcePath = "/lang/" + language + ".yml";
try (InputStream inputStream = I18nManager.class.getResourceAsStream(resourcePath)) {
JsonNode rootNode = YAML_MAPPER.readTree(inputStream);
flattenJsonToMap(rootNode, "", DMCC_TRANSLATIONS);
} catch (IOException e) {
LOGGER.error("Failed to load DMCC translations from " + resourcePath, e);
return false;
}
return true;
}
/**
* Loads the custom messages configuration file.
*
* @return true if the custom messages were loaded and validated successfully.
*/
public static boolean loadCustomMessages() {
private static boolean loadCustomMessages() {
try {
Files.createDirectories(CUSTOM_MESSAGES_DIR);
Path customMessagesPath = CUSTOM_MESSAGES_DIR.resolve(language + ".yml");
@ -140,7 +148,11 @@ public class I18nManager {
throw new IOException("Default custom messages template not found: " + templatePath);
}
Files.copy(inputStream, customMessagesPath, StandardCopyOption.REPLACE_EXISTING);
LOGGER.info("Created default custom messages file at \"{}\"", customMessagesPath);
LOGGER.warn("Custom messages file for \"{}\" not found or is empty", language);
LOGGER.warn("Creating a default one at \"{}\"", customMessagesPath);
LOGGER.warn("DMCC will continue using default values");
LOGGER.warn("If you wish to customize messages, please edit \"{}\" and reload DMCC.", customMessagesPath);
}
}
@ -155,11 +167,14 @@ public class I18nManager {
// Validate the user's file against the template.
// The `errorOnUnmodified` flag is set to false because users might not need to customize messages.
if (YamlUtils.validate(userMessages, templateMessages, customMessagesPath, false)) {
customMessages = userMessages;
LOGGER.info("Custom messages for \"{}\" loaded successfully!", language);
return true;
if (!YamlUtils.validate(userMessages, templateMessages, customMessagesPath, false)) {
LOGGER.error("Validation of custom message config failed");
return false;
}
customMessages = userMessages;
LOGGER.info("Custom messages for \"{}\" loaded successfully!", language);
return true;
} catch (IOException e) {
LOGGER.error("Failed to load custom messages", e);
}
@ -167,89 +182,63 @@ public class I18nManager {
return false;
}
/**
* Loads DMCC's internal translation file from resources.
*
* @return true if the translations were loaded successfully.
*/
private static boolean loadDmccTranslations() {
dmccTranslations.clear();
String resourcePath = "/lang/" + language + ".yml";
try (InputStream inputStream = I18nManager.class.getResourceAsStream(resourcePath)) {
if (inputStream == null) {
// This check is technically redundant due to checkLanguageResources, but good for safety.
LOGGER.error("DMCC internal translation file not found in resources: {}", resourcePath);
return false;
}
JsonNode rootNode = YAML_MAPPER.readTree(inputStream);
flattenJsonToMap(rootNode, "", dmccTranslations);
} catch (IOException e) {
LOGGER.error("Failed to load DMCC translations from " + resourcePath, e);
return false;
}
return true;
}
/**
* Loads the official Minecraft translation file for the current language.
* It attempts to use a cached version before downloading a new one.
*
* @return true if the translations were loaded successfully.
*/
private static boolean loadMinecraftTranslations() {
minecraftTranslations.clear();
try {
String mcVersion = EnvironmentUtils.getMinecraftVersion();
String fileName = StringUtils.format("{}-{}.json", language, mcVersion);
Files.createDirectories(CACHE_DIR);
Path langCachePath = CACHE_DIR.resolve(fileName);
// If a valid cached file exists, use it.
if (Files.exists(langCachePath)) {
try {
JsonNode root = JSON_MAPPER.readTree(Files.newBufferedReader(langCachePath, StandardCharsets.UTF_8));
minecraftTranslations.putAll(JSON_MAPPER.convertValue(root, new TypeReference<Map<String, String>>() {
}));
LOGGER.info("Loaded Minecraft translations from cache for version {}", mcVersion);
return true;
} catch (Exception e) {
LOGGER.error("Failed to read cached Minecraft translations, will attempt to re-download", e);
}
}
// Otherwise, download the file.
LOGGER.info("Downloading Minecraft translations for version {}...", mcVersion);
String url = "https://cdn.jsdelivr.net/gh/InventivetalentDev/minecraft-assets@" + mcVersion + "/assets/minecraft/lang/" + language + ".json";
Request request = new Request.Builder().url(url).build();
try (Response response = OK_HTTP_CLIENT.newCall(request).execute()) {
if (!response.isSuccessful()) {
LOGGER.error("Failed to download Minecraft translations. HTTP Status: {}", response.code());
return false;
}
String jsonContent = response.body().string();
Files.writeString(langCachePath, jsonContent);
JsonNode root = JSON_MAPPER.readTree(jsonContent);
minecraftTranslations.putAll(JSON_MAPPER.convertValue(root, new TypeReference<Map<String, String>>() {
}));
LOGGER.info("Downloaded and cached Minecraft translations, file size: {} bytes", jsonContent.length());
return true;
}
} catch (IOException e) {
LOGGER.error("Failed to load or download Minecraft translations", e);
}
return false;
}
// /**
// * Loads the official Minecraft translation file for the current language.
// * It attempts to use a cached version before downloading a new one.
// *
// * @return true if the translations were loaded successfully.
// */
// private static boolean loadMinecraftTranslations() {
// minecraftTranslations.clear();
//
// try {
// String mcVersion = "EnvironmentUtils.getMinecraftVersion()";
// String fileName = StringUtils.format("{}-{}.json", language, mcVersion);
//
// Files.createDirectories(CACHE_DIR);
// Path langCachePath = CACHE_DIR.resolve(fileName);
//
// // If a valid cached file exists, use it.
// if (Files.exists(langCachePath)) {
// try {
// JsonNode root = JSON_MAPPER.readTree(Files.newBufferedReader(langCachePath, StandardCharsets.UTF_8));
// minecraftTranslations.putAll(JSON_MAPPER.convertValue(root, new TypeReference<Map<String, String>>() {
// }));
//
// LOGGER.info("Loaded Minecraft translations from cache for version {}", mcVersion);
// return true;
// } catch (Exception e) {
// LOGGER.error("Failed to read cached Minecraft translations, will attempt to re-download", e);
// }
// }
//
// // Otherwise, download the file.
// LOGGER.info("Downloading Minecraft translations for version {}...", mcVersion);
// String url = "https://cdn.jsdelivr.net/gh/InventivetalentDev/minecraft-assets@" + mcVersion + "/assets/minecraft/lang/" + language + ".json";
// Request request = new Request.Builder().url(url).build();
//
// try (Response response = OK_HTTP_CLIENT.newCall(request).execute()) {
// if (!response.isSuccessful()) {
// LOGGER.error("Failed to download Minecraft translations. HTTP Status: {}", response.code());
// return false;
// }
//
// String jsonContent = response.body().string();
// Files.writeString(langCachePath, jsonContent);
//
// JsonNode root = JSON_MAPPER.readTree(jsonContent);
// minecraftTranslations.putAll(JSON_MAPPER.convertValue(root, new TypeReference<Map<String, String>>() {
// }));
//
// LOGGER.info("Downloaded and cached Minecraft translations, file size: {} bytes", jsonContent.length());
// return true;
// }
// } catch (IOException e) {
// LOGGER.error("Failed to load or download Minecraft translations", e);
// }
//
// return false;
// }
/**
* Gets a translation from DMCC's internal translation files (lang/*.yml).
@ -260,29 +249,10 @@ public class I18nManager {
* @return The formatted translation string, or the key if not found.
*/
public static String getDmccTranslation(String key, Object... args) {
String translation = dmccTranslations.getOrDefault(key, key);
String translation = DMCC_TRANSLATIONS.getOrDefault(key, key);
return StringUtils.format(translation, args);
}
/**
* Gets a translation from the official Minecraft translation files.
* Placeholders are formatted using %s or %1$s.
*
* @param key The translation key (e.g., "death.attack.drown").
* @param args The arguments to format into the string.
* @return The formatted translation string, or the key if not found.
*/
public static String getMinecraftTranslation(String key, Object... args) {
String translation = minecraftTranslations.getOrDefault(key, key);
try {
// MessageFormat handles both %s and %1$s style placeholders
return MessageFormat.format(translation.replace("'", "''"), args);
} catch (IllegalArgumentException e) {
LOGGER.warn("Failed to format Minecraft translation for key \"{}\": {}", key, e.getMessage());
return translation; // Return unformatted string on error
}
}
/**
* Gets the custom messages JsonNode.
*
@ -292,6 +262,25 @@ public class I18nManager {
return customMessages;
}
// /**
// * Gets a translation from the official Minecraft translation files.
// * Placeholders are formatted using %s or %1$s.
// *
// * @param key The translation key (e.g., "death.attack.drown").
// * @param args The arguments to format into the string.
// * @return The formatted translation string, or the key if not found.
// */
// public static String getMinecraftTranslation(String key, Object... args) {
// String translation = minecraftTranslations.getOrDefault(key, key);
// try {
// // MessageFormat handles both %s and %1$s style placeholders
// return MessageFormat.format(translation.replace("'", "''"), args);
// } catch (IllegalArgumentException e) {
// LOGGER.warn("Failed to format Minecraft translation for key \"{}\": {}", key, e.getMessage());
// return translation; // Return unformatted string on error
// }
// }
/**
* Recursively flattens a JsonNode object into a Map with dot-separated keys.
*

View file

@ -17,7 +17,7 @@ handshake:
authentication_failed: "Authentication failed. The shared_secret does not match."
commands:
unknown_command: "Unknown command: \"{}\". Type \"help\" for a list of available commands."
unknown_command: "Unknown command: \"{}\". Type \"/help\" for a list of available commands."
no_permission: "You do not have permission to execute this command!"
help:
description: "Show a list of available commands"

View file

@ -17,7 +17,7 @@ handshake:
authentication_failed: "身份验证失败。共享密钥 (shared_secret) 不匹配。"
commands:
unknown_command: "未知命令:\"{}\"。输入 \"help\" 查看可用命令列表。"
unknown_command: "未知命令:\"{}\"。输入 \"/help\" 查看可用命令列表。"
no_permission: "你没有权限执行此命令!"
help:
description: "显示可用命令列表"