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