Cards/GameText.cs

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);
}