The static utility class handles game text localization. It resolves message keys, applies argument formatting, and substitutes text tokens using a recursive resolution strategy.
using System;
using System.Globalization;
using System.Linq;
using System.Text;
using System.Text.Json;
using System.Text.RegularExpressions;
namespace CitySim;
/// <summary>Localisation tokens stay intact in rules, checkpoints and network messages.
/// Only presentation resolves them, using the viewing client's selected language.</summary>
public static partial class GameText
{
static readonly Regex Tokens = new(@"#[a-z][a-z0-9_]*(?:\.[a-z0-9_]+)+", RegexOptions.CultureInvariant);
static readonly Regex Messages = new("\u001e([A-Za-z0-9+/=]+)\u001f", RegexOptions.CultureInvariant);
/// <summary>Package a phrase with invariant data without selecting the host's language.</summary>
public static string Message(string key, params object[] arguments) => "\u001e" +
Convert.ToBase64String(Encoding.UTF8.GetBytes(JsonSerializer.Serialize(new MessageData(key, arguments)))) + "\u001f";
sealed record MessageData(string Key, object[] Arguments);
public static string Get(string key, params object[] arguments) => Format(key.TrimStart('#'), arguments, 0);
public static string Resolve(string text) => Resolve(text, 0);
public static string Plain(string text)
{
text = Resolve(text) ?? "";
// Timing headings remain readable in plain labels, tooltips and receipts.
// Captured code spans remain untouched until the inline parser handles them.
var segments = Regex.Split(text, @"(`[^`]*`|<code>.*?</code>)", RegexOptions.Singleline | RegexOptions.IgnoreCase);
for (var i = 0; i < segments.Length; i += 2)
{
segments[i] = Regex.Replace(segments[i], @"<(payout|end|place|play|round_start|each_round|growth)>",
match => Get("timing." + match.Groups[1].Value + ".label") + ": ");
segments[i] = Regex.Replace(segments[i], @"</(payout|end|place|play|round_start|each_round|growth)>", " ");
}
return Cards.CardRuleText.Plain(string.Concat(segments)).TrimEnd();
}
public static string EnumLabel<T>(T value) where T : struct, Enum => Get("enums." + typeof(T).Name.ToLowerInvariant() + "." + value.ToString().ToLowerInvariant());
static string Resolve(string text, int depth)
{
if (string.IsNullOrEmpty(text) || depth >= 12) return text;
text = Messages.Replace(text, match =>
{
using var document = JsonDocument.Parse(Encoding.UTF8.GetString(Convert.FromBase64String(match.Groups[1].Value)));
var root = document.RootElement;
var args = root.GetProperty("Arguments").EnumerateArray().Select(Value).ToArray();
return Format(root.GetProperty("Key").GetString(), args, depth + 1);
});
return Tokens.Replace(text, match => Format(match.Value[1..], Array.Empty<object>(), depth + 1));
}
static object Value(JsonElement value) => value.ValueKind switch
{
JsonValueKind.Number => value.TryGetInt64(out var integer) ? (object)integer : value.GetDouble(),
JsonValueKind.True => true,
JsonValueKind.False => false,
JsonValueKind.Null => "",
_ => value.GetString()
};
static string Format(string key, object[] arguments, int depth)
{
var phrase = key;
Translate(ref phrase);
if (arguments.Length > 0)
{
var values = arguments.Select(value => value switch
{
string text => (object)Resolve(text, depth + 1),
Enum label => Get("enums." + label.GetType().Name.ToLowerInvariant() + "." + label.ToString().ToLowerInvariant()),
_ => value
}).ToArray();
phrase = string.Format(CultureInfo.CurrentCulture, phrase, values);
}
return Resolve(phrase, depth + 1);
}
// Implemented by the engine adapter and by the standalone rule-test runner.
static partial void Translate(ref string phrase);
}