mirror of
https://github.com/MCCTeam/Minecraft-Console-Client
synced 2026-08-15 13:04:36 +00:00
640 lines
27 KiB
C#
640 lines
27 KiB
C#
using System;
|
||
using System.Collections.Generic;
|
||
using System.IO;
|
||
using System.Linq;
|
||
using System.Net.Http;
|
||
using System.Net.Http.Json;
|
||
using System.Text;
|
||
using System.Text.Json;
|
||
using System.Text.RegularExpressions;
|
||
using System.Threading;
|
||
using System.Threading.Tasks;
|
||
using static MinecraftClient.Settings;
|
||
|
||
namespace MinecraftClient.Protocol.Message
|
||
{
|
||
/// <summary>
|
||
/// This class parses JSON chat data from MC 1.6+ and returns the appropriate string to be printed.
|
||
/// </summary>
|
||
static class ChatParser
|
||
{
|
||
public enum MessageType
|
||
{
|
||
CHAT,
|
||
SAY_COMMAND,
|
||
MSG_COMMAND_INCOMING,
|
||
MSG_COMMAND_OUTGOING,
|
||
TEAM_MSG_COMMAND_INCOMING,
|
||
TEAM_MSG_COMMAND_OUTGOING,
|
||
EMOTE_COMMAND,
|
||
RAW_MSG
|
||
};
|
||
|
||
public static Dictionary<int, MessageType>? ChatId2Type;
|
||
|
||
// Used to store Chat Types in 1.20.6+
|
||
public static void ReadChatType(Dictionary<int, string> data)
|
||
{
|
||
var chatTypeDictionary = ChatId2Type ?? new Dictionary<int, MessageType>();
|
||
|
||
foreach (var (chatId, chatName) in data)
|
||
{
|
||
chatTypeDictionary[chatId] = chatName switch
|
||
{
|
||
"minecraft:chat" => MessageType.CHAT,
|
||
"minecraft:emote_command" => MessageType.EMOTE_COMMAND,
|
||
"minecraft:msg_command_incoming" => MessageType.MSG_COMMAND_INCOMING,
|
||
"minecraft:msg_command_outgoing" => MessageType.MSG_COMMAND_OUTGOING,
|
||
"minecraft:say_command" => MessageType.SAY_COMMAND,
|
||
"minecraft:team_msg_command_incoming" => MessageType.TEAM_MSG_COMMAND_INCOMING,
|
||
"minecraft:team_msg_command_outgoing" => MessageType.TEAM_MSG_COMMAND_OUTGOING,
|
||
_ => MessageType.CHAT,
|
||
};
|
||
}
|
||
|
||
ChatId2Type = chatTypeDictionary;
|
||
}
|
||
|
||
public static void ReadChatType(Dictionary<string, object> registryCodec)
|
||
{
|
||
Dictionary<int, MessageType> chatTypeDictionary = ChatId2Type ?? new();
|
||
|
||
// Check if the chat type registry is in the correct format
|
||
if (!registryCodec.ContainsKey("minecraft:chat_type")) {
|
||
|
||
// If not, then we force the registry to be in the correct format
|
||
if (registryCodec.ContainsKey("chat_type")) {
|
||
|
||
foreach (var key in registryCodec.Keys.ToArray()) {
|
||
// Skip entries with a namespace already
|
||
if (key.Contains(':', StringComparison.OrdinalIgnoreCase)) continue;
|
||
|
||
// Assume all other entries are in the minecraft namespace
|
||
registryCodec["minecraft:" + key] = registryCodec[key];
|
||
registryCodec.Remove(key);
|
||
}
|
||
}
|
||
}
|
||
|
||
var chatTypeListNbt = (object[])(((Dictionary<string, object>)registryCodec["minecraft:chat_type"])["value"]);
|
||
foreach (var (chatName, chatId) in from Dictionary<string, object> chatTypeNbt in chatTypeListNbt
|
||
let chatName = (string)chatTypeNbt["name"]
|
||
let chatId = (int)chatTypeNbt["id"]
|
||
select (chatName, chatId))
|
||
{
|
||
chatTypeDictionary[chatId] = chatName switch
|
||
{
|
||
"minecraft:chat" => MessageType.CHAT,
|
||
"minecraft:emote_command" => MessageType.EMOTE_COMMAND,
|
||
"minecraft:msg_command_incoming" => MessageType.MSG_COMMAND_INCOMING,
|
||
"minecraft:msg_command_outgoing" => MessageType.MSG_COMMAND_OUTGOING,
|
||
"minecraft:say_command" => MessageType.SAY_COMMAND,
|
||
"minecraft:team_msg_command_incoming" => MessageType.TEAM_MSG_COMMAND_INCOMING,
|
||
"minecraft:team_msg_command_outgoing" => MessageType.TEAM_MSG_COMMAND_OUTGOING,
|
||
_ => MessageType.CHAT,
|
||
};
|
||
}
|
||
|
||
ChatId2Type = chatTypeDictionary;
|
||
}
|
||
|
||
/// <summary>
|
||
/// The main function to convert text from MC 1.6+ JSON to MC 1.5.2 formatted text
|
||
/// </summary>
|
||
/// <param name="json">JSON serialized text</param>
|
||
/// <param name="links">Optional container for links from JSON serialized text</param>
|
||
/// <returns>Returns the translated text</returns>
|
||
public static string ParseText(string json, List<string>? links = null)
|
||
{
|
||
return JSONData2String(Json.ParseJson(json), "", links);
|
||
}
|
||
|
||
public static string ParseText(Dictionary<string, object> nbt)
|
||
{
|
||
return NbtToString(nbt);
|
||
}
|
||
|
||
/// <summary>
|
||
/// The main function to convert text from MC 1.9+ JSON to MC 1.5.2 formatted text
|
||
/// </summary>
|
||
/// <param name="message">Message received</param>
|
||
/// <param name="links">Optional container for links from JSON serialized text</param>
|
||
/// <returns>Returns the translated text</returns>
|
||
public static string ParseSignedChat(ChatMessage message, List<string>? links = null)
|
||
{
|
||
string sender = message.isSenderJson ? ParseText(message.displayName!) : message.displayName!;
|
||
string content;
|
||
if (Config.Signature.ShowModifiedChat && message.unsignedContent is not null)
|
||
{
|
||
content = ParseText(message.unsignedContent!);
|
||
if (string.IsNullOrEmpty(content))
|
||
content = message.unsignedContent!;
|
||
}
|
||
else
|
||
{
|
||
content = message.isJson ? ParseText(message.content) : message.content;
|
||
if (string.IsNullOrEmpty(content))
|
||
content = message.content!;
|
||
}
|
||
|
||
string text;
|
||
List<string> usingData = new();
|
||
|
||
MessageType chatType;
|
||
if (message.chatTypeId == -1)
|
||
chatType = MessageType.RAW_MSG;
|
||
else if (!ChatId2Type!.TryGetValue(message.chatTypeId, out chatType))
|
||
chatType = MessageType.CHAT;
|
||
switch (chatType)
|
||
{
|
||
case MessageType.CHAT:
|
||
usingData.Add(sender);
|
||
usingData.Add(content);
|
||
text = TranslateString("chat.type.text", usingData);
|
||
break;
|
||
case MessageType.SAY_COMMAND:
|
||
usingData.Add(sender);
|
||
usingData.Add(content);
|
||
text = TranslateString("chat.type.announcement", usingData);
|
||
break;
|
||
case MessageType.MSG_COMMAND_INCOMING:
|
||
usingData.Add(sender);
|
||
usingData.Add(content);
|
||
text = TranslateString("commands.message.display.incoming", usingData);
|
||
break;
|
||
case MessageType.MSG_COMMAND_OUTGOING:
|
||
usingData.Add(sender);
|
||
usingData.Add(content);
|
||
text = TranslateString("commands.message.display.outgoing", usingData);
|
||
break;
|
||
case MessageType.TEAM_MSG_COMMAND_INCOMING:
|
||
usingData.Add(message.teamName!);
|
||
usingData.Add(sender);
|
||
usingData.Add(content);
|
||
text = TranslateString("chat.type.team.text", usingData);
|
||
break;
|
||
case MessageType.TEAM_MSG_COMMAND_OUTGOING:
|
||
usingData.Add(message.teamName!);
|
||
usingData.Add(sender);
|
||
usingData.Add(content);
|
||
text = TranslateString("chat.type.team.sent", usingData);
|
||
break;
|
||
case MessageType.EMOTE_COMMAND:
|
||
usingData.Add(sender);
|
||
usingData.Add(content);
|
||
text = TranslateString("chat.type.emote", usingData);
|
||
break;
|
||
case MessageType.RAW_MSG:
|
||
text = content;
|
||
break;
|
||
default:
|
||
goto case MessageType.CHAT;
|
||
}
|
||
|
||
return text;
|
||
}
|
||
|
||
/// <summary>
|
||
/// Get the classic color tag corresponding to a color name
|
||
/// </summary>
|
||
/// <param name="colorname">Color Name</param>
|
||
/// <returns>Color code</returns>
|
||
private static string Color2tag(string colorname)
|
||
{
|
||
return colorname.ToLower() switch
|
||
{
|
||
#pragma warning disable format // @formatter:off
|
||
|
||
/* MC 1.7+ Name || MC 1.6 Name || Classic tag */
|
||
"black" => "§0",
|
||
"dark_blue" => "§1",
|
||
"dark_green" => "§2",
|
||
"dark_aqua" or "dark_cyan" => "§3",
|
||
"dark_red" => "§4",
|
||
"dark_purple" or "dark_magenta" => "§5",
|
||
"gold" or "dark_yellow" => "§6",
|
||
"gray" => "§7",
|
||
"dark_gray" => "§8",
|
||
"blue" => "§9",
|
||
"green" => "§a",
|
||
"aqua" or "cyan" => "§b",
|
||
"red" => "§c",
|
||
"light_purple" or "magenta" => "§d",
|
||
"yellow" => "§e",
|
||
"white" => "§f",
|
||
_ => "" ,
|
||
|
||
#pragma warning restore format // @formatter:on
|
||
};
|
||
}
|
||
|
||
/// <summary>
|
||
/// Specify whether translation rules have been loaded
|
||
/// </summary>
|
||
private static bool RulesInitialized = false;
|
||
private static readonly Lock RulesInitializationLock = new();
|
||
private static Task? RulesRefreshTask = null;
|
||
|
||
/// <summary>
|
||
/// Set of translation rules for formatting text
|
||
/// </summary>
|
||
private static Dictionary<string, string> TranslationRules = new();
|
||
|
||
/// <summary>
|
||
/// Initialize translation rules.
|
||
/// Necessary for properly printing some chat messages.
|
||
/// </summary>
|
||
public static void InitTranslations()
|
||
{
|
||
lock (RulesInitializationLock)
|
||
{
|
||
if (RulesInitialized)
|
||
return;
|
||
|
||
RulesInitialized = true;
|
||
RulesRefreshTask = InitRulesAsync();
|
||
_ = ObserveInitRulesAsync(RulesRefreshTask);
|
||
}
|
||
}
|
||
|
||
/// <summary>
|
||
/// Internal rule initialization method. Looks for local rule file and refreshes it from Mojang asset servers if needed.
|
||
/// </summary>
|
||
private static async Task InitRulesAsync()
|
||
{
|
||
if (Config.Main.Advanced.Language == "en_us")
|
||
{
|
||
TranslationRules = LoadEmbeddedTranslationRules();
|
||
return;
|
||
}
|
||
|
||
//Language file in a subfolder, depending on the language setting
|
||
if (!Directory.Exists("lang"))
|
||
Directory.CreateDirectory("lang");
|
||
|
||
string languageFilePath = "lang" + Path.DirectorySeparatorChar + Config.Main.Advanced.Language + ".json";
|
||
|
||
if (TryLoadTranslationRulesFromFile(languageFilePath, out Dictionary<string, string>? translationRules))
|
||
TranslationRules = translationRules;
|
||
else TranslationRules = LoadEmbeddedTranslationRules();
|
||
|
||
if (TranslationRules.TryGetValue("Version", out string? version) &&
|
||
version == Settings.TranslationsFile_Version)
|
||
{
|
||
if (Config.Logging.DebugMessages)
|
||
ConsoleIO.WriteLineFormatted(Translations.chat_loaded, acceptnewlines: true);
|
||
return;
|
||
}
|
||
|
||
// Try downloading language file from Mojang's servers?
|
||
ConsoleIO.WriteLineFormatted(
|
||
"§8" + string.Format(Translations.chat_download, Config.Main.Advanced.Language));
|
||
using HttpClient httpClient = new();
|
||
try
|
||
{
|
||
string fetchIndex = await httpClient.GetStringAsync(TranslationsFile_Website_Index);
|
||
Match match = Regex.Match(fetchIndex,
|
||
$"minecraft/lang/{Config.Main.Advanced.Language}.json" + @""":\s\{""hash"":\s""([\d\w]{40})""");
|
||
if (match.Success && match.Groups.Count == 2)
|
||
{
|
||
string hash = match.Groups[1].Value;
|
||
string translation_file_location = TranslationsFile_Website_Download + '/' + hash[..2] + '/' + hash;
|
||
if (Config.Logging.DebugMessages)
|
||
ConsoleIO.WriteLineFormatted(
|
||
string.Format(Translations.chat_request, translation_file_location));
|
||
|
||
Dictionary<string, string>? fetchedFile =
|
||
await httpClient.GetFromJsonAsync<Dictionary<string, string>>(translation_file_location);
|
||
if (fetchedFile is not null && fetchedFile.Count > 0)
|
||
{
|
||
TranslationRules = fetchedFile;
|
||
TranslationRules["Version"] = TranslationsFile_Version;
|
||
await File.WriteAllTextAsync(languageFilePath,
|
||
JsonSerializer.Serialize(TranslationRules, typeof(Dictionary<string, string>)),
|
||
Encoding.UTF8);
|
||
|
||
ConsoleIO.WriteLineFormatted("§8" + string.Format(Translations.chat_done, languageFilePath));
|
||
return;
|
||
}
|
||
}
|
||
else
|
||
{
|
||
ConsoleIO.WriteLineFormatted("§8" + Translations.chat_fail, acceptnewlines: true);
|
||
}
|
||
}
|
||
catch (HttpRequestException)
|
||
{
|
||
ConsoleIO.WriteLineFormatted("§8" + Translations.chat_fail, acceptnewlines: true);
|
||
}
|
||
catch (IOException)
|
||
{
|
||
ConsoleIO.WriteLineFormatted("§8" + string.Format(Translations.chat_save_fail, languageFilePath),
|
||
acceptnewlines: true);
|
||
}
|
||
catch (Exception e)
|
||
{
|
||
ConsoleIO.WriteLineFormatted("§8" + Translations.chat_fail, acceptnewlines: true);
|
||
ConsoleIO.WriteLine(e.Message);
|
||
if (Config.Logging.DebugMessages && !string.IsNullOrEmpty(e.StackTrace))
|
||
ConsoleIO.WriteLine(e.StackTrace);
|
||
}
|
||
TranslationRules = LoadEmbeddedTranslationRules();
|
||
ConsoleIO.WriteLine(Translations.chat_use_default);
|
||
}
|
||
|
||
private static async Task ObserveInitRulesAsync(Task initRulesTask)
|
||
{
|
||
try
|
||
{
|
||
await initRulesTask;
|
||
}
|
||
catch (Exception e)
|
||
{
|
||
TranslationRules = LoadEmbeddedTranslationRules();
|
||
if (Config.Logging.DebugMessages)
|
||
ConsoleIO.WriteLine(e.ToString());
|
||
}
|
||
}
|
||
|
||
private static Dictionary<string, string> LoadEmbeddedTranslationRules()
|
||
{
|
||
return JsonSerializer.Deserialize<Dictionary<string, string>>(
|
||
(byte[])MinecraftAssets.ResourceManager.GetObject("en_us.json")!)!;
|
||
}
|
||
|
||
private static bool TryLoadTranslationRulesFromFile(string languageFilePath, out Dictionary<string, string>? translationRules)
|
||
{
|
||
translationRules = null;
|
||
if (!File.Exists(languageFilePath))
|
||
return false;
|
||
|
||
try
|
||
{
|
||
translationRules =
|
||
JsonSerializer.Deserialize<Dictionary<string, string>>(File.OpenRead(languageFilePath))!;
|
||
return translationRules is not null;
|
||
}
|
||
catch (IOException)
|
||
{
|
||
return false;
|
||
}
|
||
catch (JsonException)
|
||
{
|
||
return false;
|
||
}
|
||
}
|
||
|
||
public static string? TranslateString(string rulename)
|
||
{
|
||
if (TranslationRules.TryGetValue(rulename, out string? result))
|
||
return result;
|
||
else
|
||
return null;
|
||
}
|
||
|
||
/// <summary>
|
||
/// Format text using a specific formatting rule.
|
||
/// Example : * %s %s + ["ORelio", "is doing something"] = * ORelio is doing something
|
||
/// </summary>
|
||
/// <param name="rulename">Name of the rule, chosen by the server</param>
|
||
/// <param name="using_data">Data to be used in the rule</param>
|
||
/// <returns>Returns the formatted text according to the given data</returns>
|
||
private static string TranslateString(string rulename, List<string> using_data)
|
||
{
|
||
if (!RulesInitialized)
|
||
InitTranslations();
|
||
|
||
if (TranslationRules.ContainsKey(rulename))
|
||
{
|
||
int using_idx = 0;
|
||
string rule = TranslationRules[rulename];
|
||
StringBuilder result = new();
|
||
for (int i = 0; i < rule.Length; i++)
|
||
{
|
||
if (rule[i] == '%' && i + 1 < rule.Length)
|
||
{
|
||
//Using string or int with %s or %d
|
||
if (rule[i + 1] == 's' || rule[i + 1] == 'd')
|
||
{
|
||
if (using_data.Count > using_idx)
|
||
{
|
||
result.Append(using_data[using_idx]);
|
||
using_idx++;
|
||
i += 1;
|
||
continue;
|
||
}
|
||
}
|
||
|
||
//Using specified string or int with %1$s, %2$s...
|
||
else if (char.IsDigit(rule[i + 1])
|
||
&& i + 3 < rule.Length && rule[i + 2] == '$'
|
||
&& (rule[i + 3] == 's' || rule[i + 3] == 'd'))
|
||
{
|
||
int specified_idx = rule[i + 1] - '1';
|
||
if (using_data.Count > specified_idx)
|
||
{
|
||
result.Append(using_data[specified_idx]);
|
||
using_idx++;
|
||
i += 3;
|
||
continue;
|
||
}
|
||
}
|
||
}
|
||
|
||
result.Append(rule[i]);
|
||
}
|
||
|
||
return result.ToString();
|
||
}
|
||
else return "[" + rulename + "] " + string.Join(" ", using_data);
|
||
}
|
||
|
||
/// <summary>
|
||
/// Mapping from JSON/NBT property names to Minecraft formatting codes (without §).
|
||
/// Both "underlined" (canonical Minecraft name) and "underline" (alias) are supported.
|
||
/// </summary>
|
||
private static readonly Dictionary<string, string> FormattingCodes = new()
|
||
{
|
||
{ "obfuscated", "k" },
|
||
{ "bold", "l" },
|
||
{ "strikethrough", "m" },
|
||
{ "underlined", "n" },
|
||
{ "underline", "n" },
|
||
{ "italic", "o" },
|
||
};
|
||
|
||
/// <summary>Matches a single color code (§0–§9, §a–§f). Used to strip color when replacing.</summary>
|
||
private static readonly Regex ColorCodeRegex = new(@"§[0-9a-f]", RegexOptions.Compiled);
|
||
|
||
/// <summary>
|
||
/// Use a JSON Object to build the corresponding string
|
||
/// </summary>
|
||
/// <param name="data">JSON object to convert</param>
|
||
/// <param name="formatting">Inherited formatting codes from parent elements (set to "" for function init)</param>
|
||
/// <param name="links">Container for links from JSON serialized text</param>
|
||
/// <returns>returns the Minecraft-formatted string</returns>
|
||
private static string JSONData2String(System.Text.Json.Nodes.JsonNode? data, string formatting, List<string>? links)
|
||
{
|
||
string extra_result = "";
|
||
switch (data)
|
||
{
|
||
case System.Text.Json.Nodes.JsonObject obj:
|
||
if (obj.ContainsKey("color"))
|
||
{
|
||
formatting = ColorCodeRegex.Replace(formatting, "");
|
||
formatting += Color2tag(JSONData2String(obj["color"], "", links));
|
||
}
|
||
|
||
foreach (var (key, code) in FormattingCodes)
|
||
{
|
||
if (obj.ContainsKey(key))
|
||
{
|
||
string val = obj[key]!.GetStringValue();
|
||
if (val == "true")
|
||
formatting += "§" + code;
|
||
else if (val == "false")
|
||
formatting = formatting.Replace("§" + code, "");
|
||
}
|
||
}
|
||
|
||
if (obj.ContainsKey("clickEvent") && links is not null)
|
||
{
|
||
var clickEvent = obj["clickEvent"]!.AsObject();
|
||
if (clickEvent.ContainsKey("action")
|
||
&& clickEvent.ContainsKey("value")
|
||
&& clickEvent["action"]!.GetStringValue() == "open_url"
|
||
&& !string.IsNullOrEmpty(clickEvent["value"]!.GetStringValue()))
|
||
{
|
||
links.Add(clickEvent["value"]!.GetStringValue());
|
||
}
|
||
}
|
||
|
||
if (obj.ContainsKey("extra"))
|
||
{
|
||
foreach (var item in obj["extra"]!.AsArray())
|
||
extra_result += JSONData2String(item, "§r" + formatting, links);
|
||
}
|
||
|
||
// Strip any formatting codes that appear before the last §r, since §r resets all
|
||
// prior formatting. The greedy .* matches up to the last §r in the string.
|
||
formatting = Regex.Replace(formatting, ".*(§r.*)", "$1");
|
||
|
||
if (obj.ContainsKey("text"))
|
||
{
|
||
// Pass "" to the leaf text node: formatting is already prepended here,
|
||
// and the default: case would add it a second time if we passed formatting.
|
||
return formatting + JSONData2String(obj["text"], "", links) + extra_result;
|
||
}
|
||
else if (obj.ContainsKey("translate"))
|
||
{
|
||
List<string> using_data = new();
|
||
if (obj.ContainsKey("using") && !obj.ContainsKey("with"))
|
||
obj["with"] = obj["using"]!.DeepClone();
|
||
if (obj.ContainsKey("with"))
|
||
{
|
||
foreach (var item in obj["with"]!.AsArray())
|
||
{
|
||
using_data.Add(JSONData2String(item, formatting, links));
|
||
}
|
||
}
|
||
|
||
return formatting +
|
||
TranslateString(JSONData2String(obj["translate"], "", links), using_data) +
|
||
extra_result;
|
||
}
|
||
else return extra_result;
|
||
|
||
case System.Text.Json.Nodes.JsonArray arr:
|
||
string result = "";
|
||
foreach (var item in arr)
|
||
{
|
||
result += JSONData2String(item, formatting, links);
|
||
}
|
||
|
||
return result;
|
||
|
||
default:
|
||
return formatting + data.GetStringValue();
|
||
}
|
||
}
|
||
|
||
private static string NbtToString(Dictionary<string, object> nbt, string formatting = "")
|
||
{
|
||
if (nbt.Count == 1 && nbt.TryGetValue("", out object? rootMessage))
|
||
{
|
||
return formatting + (rootMessage?.ToString() ?? string.Empty);
|
||
}
|
||
|
||
string message = string.Empty;
|
||
StringBuilder extraBuilder = new();
|
||
|
||
// Build formatting from color and formatting flags first
|
||
if (nbt.TryGetValue("color", out object? color))
|
||
{
|
||
formatting = ColorCodeRegex.Replace(formatting, "");
|
||
formatting += Color2tag((string)color);
|
||
}
|
||
|
||
foreach (var (key, code) in FormattingCodes)
|
||
{
|
||
if (nbt.TryGetValue(key, out object? flagValue))
|
||
{
|
||
bool isActive = flagValue switch
|
||
{
|
||
byte b => b > 0,
|
||
bool b => b,
|
||
_ => flagValue?.ToString()?.ToLower() == "true"
|
||
};
|
||
if (isActive)
|
||
formatting += "§" + code;
|
||
else
|
||
formatting = formatting.Replace("§" + code, "");
|
||
}
|
||
}
|
||
|
||
// Process text
|
||
if (nbt.TryGetValue("text", out object? textValue))
|
||
message = textValue?.ToString() ?? string.Empty;
|
||
|
||
// Process translate
|
||
if (nbt.TryGetValue("translate", out object? translate))
|
||
{
|
||
var translateKey = (string)translate;
|
||
List<string> translateString = new();
|
||
if (nbt.TryGetValue("with", out object? withComponent))
|
||
{
|
||
var withs = (object[])withComponent;
|
||
for (var i = 0; i < withs.Length; i++)
|
||
{
|
||
var withDict = withs[i] switch
|
||
{
|
||
int => new Dictionary<string, object> { { "text", $"{withs[i]}" } },
|
||
string => new Dictionary<string, object> { { "text", (string)withs[i] } },
|
||
_ => (Dictionary<string, object>)withs[i]
|
||
};
|
||
translateString.Add(NbtToString(withDict, formatting));
|
||
}
|
||
}
|
||
message = TranslateString(translateKey, translateString);
|
||
}
|
||
|
||
// Process extras, each starting with a reset then inheriting the current formatting
|
||
if (nbt.TryGetValue("extra", out object? extraValue))
|
||
{
|
||
object[] extras = (object[])extraValue;
|
||
for (var i = 0; i < extras.Length; i++)
|
||
{
|
||
var extraDict = extras[i] switch
|
||
{
|
||
int => new Dictionary<string, object> { { "text", $"{extras[i]}" } },
|
||
string => new Dictionary<string, object> { { "text", (string)extras[i] } },
|
||
_ => (Dictionary<string, object>)extras[i]
|
||
};
|
||
extraBuilder.Append(NbtToString(extraDict, "§r" + formatting));
|
||
}
|
||
}
|
||
|
||
return formatting + message + extraBuilder.ToString();
|
||
}
|
||
}
|
||
}
|