Cards/CardRuleText.cs

The utility class processes card rule text, allowing authors to define formatting, nested styles, and interactive references using a custom Markdown/HTML dialect.

using System;
using System.Collections.Generic;
using System.Linq;
using System.Text;
using System.Text.RegularExpressions;
using CitySim.Match;

namespace CitySim.Cards;

/// <summary>Small, safe inline Markdown/HTML dialect. References remain structured hover targets.</summary>
public static class CardRuleText
{
    [Flags]
    public enum TextStyle { None = 0, Bold = 1, Italic = 2, Underline = 4, Strike = 8, Code = 16 }

    public sealed record Section(string Label, string Text, TextStyle LabelStyle = TextStyle.Bold)
    {
        public string Help => Label == null ? null : Label.Replace(".label", ".help");
        public string LabelCssClass => new Word("", Style: LabelStyle).CssClass;
    }
    public sealed record Word(string Text, Tag Tag = Tag.None, bool Shop = false, bool Park = false,
        InteractionKind Kind = InteractionKind.None, string CardId = null, TextStyle Style = TextStyle.None,
        bool SpaceBefore = false, bool LineBreak = false)
    {
        public bool Interactive => Tag != Tag.None || Shop || Park || Kind != InteractionKind.None;
        public string CssClass => "rule-text" + (SpaceBefore ? " rule-space" : "") +
            (Style.HasFlag(TextStyle.Bold) ? " rule-bold" : "") + (Style.HasFlag(TextStyle.Italic) ? " rule-italic" : "") +
            (Style.HasFlag(TextStyle.Underline) ? " rule-underline" : "") + (Style.HasFlag(TextStyle.Strike) ? " rule-strike" : "") +
            (Style.HasFlag(TextStyle.Code) ? " rule-code" : "");
    }

    sealed record Frame(string Token, TextStyle Style, Word Reference = null);
    static readonly string[] Timings = { "place", "payout", "end", "play", "round_start", "each_round", "growth" };
    static readonly Regex HtmlTag = new(@"\G<(?<close>/)?(?<name>[a-z][a-z0-9_-]*)\s*(?<self>/)?>", RegexOptions.IgnoreCase | RegexOptions.CultureInvariant);
    static readonly Regex Timing = new(@"<(?<tag>payout|end|place|play|round_start|each_round|growth)>(?<text>.*?)</\k<tag>>", RegexOptions.Singleline | RegexOptions.CultureInvariant);
    static string MaskCode(string text) => Regex.Replace(text, @"`[^`]*`|<code>.*?</code>", match => new string(' ', match.Length), RegexOptions.Singleline | RegexOptions.IgnoreCase);

    static TextStyle HtmlStyle(string tag) => tag switch
    {
        "b" or "strong" => TextStyle.Bold,
        "i" or "em" => TextStyle.Italic,
        "u" => TextStyle.Underline,
        "s" or "strike" or "del" => TextStyle.Strike,
        "code" => TextStyle.Code,
        _ => TextStyle.None
    };
    static Word Target(string tag)
    {
        if (Enum.TryParse<Tag>(tag, true, out var category) && category != Tag.None && Enum.IsDefined(category)) return new("", category);
        if (tag is "shops" or "shop") return new("", Shop: true);
        if (tag is "parks" or "park") return new("", Park: true);
        if (tag is "roads" or "road") return new("", Kind: InteractionKind.Road);
        if (tag is "buildings" or "building") return new("", Kind: InteractionKind.Building);
        if (tag is "tags" or "tagged") return new("", Kind: InteractionKind.Tagged);
        if (CardLibrary.TryGet(tag, out _)) return new("", Kind: InteractionKind.Card, CardId: tag);
        if (tag.EndsWith("s") && CardLibrary.TryGet(tag[..^1], out _)) return new("", Kind: InteractionKind.Card, CardId: tag[..^1]);
        return null;
    }

    /// <summary>Parse nested formatting and references without turning translated text into UI markup.</summary>
    public static IReadOnlyList<Word> Words(string text)
    {
        var words = new List<Word>();
        if (string.IsNullOrWhiteSpace(text)) return words;
        text = GameText.Resolve(text);
        var frames = new List<Frame>();
        var buffer = new StringBuilder();
        bool space = false;
        void Flush()
        {
            if (buffer.Length == 0) return;
            var value = buffer.ToString(); buffer.Clear();
            var style = frames.Aggregate(TextStyle.None, (result, frame) => result | frame.Style);
            var reference = frames.LastOrDefault(frame => frame.Reference != null)?.Reference;
            void Add(string part)
            {
                words.Add((reference ?? new Word("")) with { Text = part, Style = style, SpaceBefore = space });
                space = false;
            }
            // A reference without inner formatting remains one focus/hover target, including its spaces.
            if (reference != null)
            {
                if (char.IsWhiteSpace(value[0])) space = true;
                var label = Regex.Replace(value.Trim(), @"\s+", " ");
                if (label.Length > 0) Add(label);
                if (char.IsWhiteSpace(value[^1])) space = true;
            }
            else foreach (System.Text.RegularExpressions.Match part in Regex.Matches(value, @"\s+|\S+"))
            {
                if (char.IsWhiteSpace(part.Value[0])) space = true;
                else Add(part.Value);
            }
        }
        void Break()
        {
            Flush(); words.Add(new("", LineBreak: true)); space = false;
        }
        for (var i = 0; i < text.Length;)
        {
            if (text[i] == '\\' && i + 1 < text.Length && "\\*_~`<>".Contains(text[i + 1]))
            {
                buffer.Append(text[i + 1]); i += 2; continue;
            }
            var code = frames.LastOrDefault(frame => frame.Style == TextStyle.Code);
            // Inline code is literal until its own closing delimiter.
            if (code != null)
            {
                var close = code.Token == "`" ? "`" : "</code>";
                if (text.AsSpan(i).StartsWith(close.AsSpan(), StringComparison.OrdinalIgnoreCase))
                {
                    Flush(); frames.Remove(code); i += close.Length;
                }
                else { buffer.Append(text[i]); i++; }
                continue;
            }
            if (text[i] is '\n' or '\r')
            {
                if (text[i] == '\r' && i + 1 < text.Length && text[i + 1] == '\n') i++;
                Break(); i++; continue;
            }
            var html = text[i] == '<' ? HtmlTag.Match(text, i) : null;
            if (html?.Success == true)
            {
                Flush();
                var tag = html.Groups["name"].Value.ToLowerInvariant();
                if (tag == "br") Break();
                else if (html.Groups["close"].Success)
                {
                    var at = frames.FindLastIndex(frame => frame.Token == "<" + tag + ">");
                    if (at >= 0) frames.RemoveAt(at);
                }
                else if (!html.Groups["self"].Success) frames.Add(new("<" + tag + ">", HtmlStyle(tag), Target(tag)));
                i += html.Length; continue;
            }
            string marker = null;
            if (text[i] == '`') marker = "`";
            else if (text[i] == '~' && i + 1 < text.Length && text[i + 1] == '~') marker = "~~";
            else if (text[i] is '*' or '_')
            {
                // Underscores within identifiers are ordinary characters.
                if (text[i] != '_' || i == 0 || !char.IsLetterOrDigit(text[i - 1]) || i + 1 == text.Length || !char.IsLetterOrDigit(text[i + 1]))
                {
                    var count = 1;
                    while (count < 3 && i + count < text.Length && text[i + count] == text[i]) count++;
                    marker = new string(text[i], count);
                    // A closing *** can close a nested * inside ** (or the reverse).
                    var open = frames.LastOrDefault(frame => frame.Token.Length <= count &&
                        frame.Token.All(character => character == text[i]));
                    if (open != null) marker = open.Token;
                }
            }
            if (marker != null)
            {
                var at = frames.FindLastIndex(frame => frame.Token == marker);
                if (at >= 0 || text.IndexOf(marker, i + marker.Length, StringComparison.Ordinal) >= 0)
                {
                    Flush();
                    if (at >= 0) frames.RemoveAt(at);
                    else frames.Add(new(marker, marker == "`" ? TextStyle.Code : marker == "~~" ? TextStyle.Strike :
                        marker.Length == 3 ? TextStyle.Bold | TextStyle.Italic : marker.Length == 2 ? TextStyle.Bold : TextStyle.Italic));
                    i += marker.Length; continue;
                }
            }
            buffer.Append(text[i]); i++;
        }
        Flush();
        return words;
    }

    /// <summary>Plain labels/tooltips use the same parsing rules, without Markdown delimiters.</summary>
    public static string Plain(string text)
    {
        var result = new StringBuilder();
        foreach (var word in Words(text))
        {
            if (word.LineBreak) { result.Append('\n'); continue; }
            if (word.SpaceBefore && result.Length > 0 && result[^1] != '\n') result.Append(' ');
            result.Append(word.Text);
        }
        return result.ToString();
    }

    public static IReadOnlyList<Section> Split(string text)
    {
        var sections = new List<Section>();
        if (string.IsNullOrWhiteSpace(text)) return sections;
        text = GameText.Resolve(text);
        // Accept the existing localised "Place:" convention, with optional bold/italic markup.
        // This is presentation metadata only, never gameplay logic or reference detection.
        var labels = Timings.Select(id => "(?<" + id + ">" + Regex.Escape(GameText.Get("timing." + id + ".label")) + ")");
        var decoration = @"(?:\*{1,3}|_{1,3}|</?(?:b|strong|i|em|u|s|strike|del)>)";
        var heading = new Regex(@"(?<![\w*])" + decoration + "*(?:" + string.Join("|", labels) + ")" + decoration + @"*\s*:" + decoration + @"*\s*", RegexOptions.IgnoreCase | RegexOptions.CultureInvariant);
        void AddLegacy(string value)
        {
            var cursor = 0;
            string active = null;
            var activeStyle = TextStyle.Bold;
            foreach (System.Text.RegularExpressions.Match match in heading.Matches(MaskCode(value)))
            {
                var prefix = value[cursor..match.Index].Trim();
                if (prefix.Length > 0) sections.Add(new(active, prefix, activeStyle));
                active = "#timing." + Timings.First(id => match.Groups[id].Success) + ".label";
                activeStyle = Words(value.Substring(match.Index, match.Length)).Aggregate(TextStyle.Bold, (style, word) => style | word.Style);
                cursor = match.Index + match.Length;
            }
            var rest = value[cursor..].Trim();
            if (rest.Length > 0) sections.Add(new(active, rest, activeStyle));
        }
        var cursor = 0;
        foreach (System.Text.RegularExpressions.Match match in Timing.Matches(MaskCode(text)))
        {
            AddLegacy(text[cursor..match.Index]);
            var body = match.Groups["text"];
            sections.Add(new("#timing." + match.Groups["tag"].Value + ".label", text.Substring(body.Index, body.Length).Trim()));
            cursor = match.Index + match.Length;
        }
        AddLegacy(text[cursor..]);
        return sections;
    }
}