using System; using System.Collections.Generic; using Cthangover.Core.Factories.Impls; using Cthangover.Core.Localization; using Cthangover.Mods.Dialog; using Cthangover.Mods.Dialog.Action.Impls; using Cthangover.Core.UI; using Cthangover.Mods.Interface.Base.Lists; using Cthangover.Core.UI.Dialog; using Cthangover.Core.UI.Event; using Cthangover.Core.UI.Executable; using Cthangover.Core.Scenarios; using Cthangover.Core.Scenes; using Cthangover.Core.Interactive; namespace Cthangover.Mods.Dialog.Scenarios { /// /// Parses scenario DSL script text into a of dialog actions. /// The pipeline works in two phases: first the text is split into lines, then each /// non-empty, non-comment line is tokenized and dispatched to the matching /// via . /// Special handling exists for :label anchor points, select/option /// blocks (accumulated into ), and -> arrow targets. /// public static class ScenarioParser { /// /// Parses a complete scenario script text and returns the resulting dialog queue. /// /// Raw scenario DSL content. /// Optional localization provider for key= text lookups. public static DialogQueue Parse(string text, ILocalizationProvider locale = null) { var dlg = new DialogQueue(); var lines = text.Split('\n'); ActionSelect pendingSelect = null; for (var lineIndex = 0; lineIndex < lines.Length; lineIndex++) { var lineNumber = lineIndex + 1; var line = lines[lineIndex].Trim(); if (line.Length == 0 || line.StartsWith('#')) { FinalizePendingSelect(dlg, ref pendingSelect); continue; } if (line.StartsWith(':')) { FinalizePendingSelect(dlg, ref pendingSelect); dlg.Point(line.Substring(1)).SourceLine = lineNumber; continue; } var positional = new List(); var named = new Dictionary(); var cmd = SplitLine(line, positional, named, out var arrowTarget); if (cmd == null) continue; if (cmd == "select") { FinalizePendingSelect(dlg, ref pendingSelect); pendingSelect = new ActionSelect { SourceLine = lineNumber }; var promptText = positional.Count > 0 ? positional[0] : null; if (named.TryGetValue("key", out var key) && locale != null) { var loc = locale.Get(key); if (loc != null) promptText = loc; } if (promptText != null) pendingSelect.Text = promptText; if (named.TryGetValue("first", out var first)) pendingSelect.FirstAvatar = first; if (named.TryGetValue("second", out var second)) pendingSelect.SecondAvatar = second; continue; } if (cmd == "option") { if (pendingSelect == null) continue; var optText = positional.Count > 0 ? positional[0] : ""; if (named.TryGetValue("key", out var key) && locale != null) { var loc = locale.Get(key); if (loc != null) optText = loc; } var goTo = arrowTarget; if (string.IsNullOrEmpty(goTo)) { if (named.TryGetValue("goto", out var gt)) goTo = gt; else if (positional.Count > 1 && positional[1].StartsWith(':')) goTo = positional[1].TrimStart(':'); } if (pendingSelect.Variants == null) pendingSelect.Variants = new List(); var variant = SelectVariant.New(optText, goTo); if (named.TryGetValue("if", out var condition)) variant.Condition = condition; pendingSelect.Variants.Add(variant); continue; } FinalizePendingSelect(dlg, ref pendingSelect); var strategy = ScenarioCommandStrategyFactory.Get(cmd); if (strategy == null) continue; var firstNew = dlg.Queue.Count; strategy.Execute(dlg, positional, named, arrowTarget, locale); for (var i = firstNew; i < dlg.Queue.Count; i++) { if (dlg.Queue[i] != null && dlg.Queue[i].SourceLine == 0) dlg.Queue[i].SourceLine = lineNumber; } } FinalizePendingSelect(dlg, ref pendingSelect); return dlg; } /// /// Splits one trimmed, non-empty DSL line the way does: /// returns the lowercase command word (null for an empty line) and fills /// the positional tokens, the name=value pairs (names lowercased) /// and the -> :label target. Tools that read scenarios without /// running them (the scenario studio) use it to agree with the parser. /// public static string SplitLine(string line, List positional, Dictionary named, out string arrowTarget) { arrowTarget = ""; var tokens = Tokenize(line); if (tokens.Count == 0) return null; var cmd = tokens[0].ToLowerInvariant(); var spec = ScenarioCommandStrategyFactory.Get(cmd) as IScenarioCommandSpec; for (var i = 1; i < tokens.Count; i++) { var t = tokens[i]; if (t == "->") { if (i + 1 < tokens.Count) { arrowTarget = tokens[i + 1].TrimStart(':'); i++; } continue; } var eqIdx = t.IndexOf('='); if (eqIdx > 0) { var name = t.Substring(0, eqIdx); if (IsValidIdent(name)) { named[spec?.PreserveNamedArgumentCase == true ? name : name.ToLowerInvariant()] = t.Substring(eqIdx + 1); continue; } } positional.Add(t); } return cmd; } /// /// Extracts YAML-like front-matter metadata from script text. Lines before /// a --- separator or the first non-key:value line are parsed as /// key: value pairs. Returns both the metadata dictionary and the /// remaining script content after the metadata block. /// public static (Dictionary metadata, string remainingText) ParseMetadata(string text) { var metadata = new Dictionary(StringComparer.OrdinalIgnoreCase); var lines = text.Split('\n'); var contentStart = 0; var inMetadata = true; for (var i = 0; i < lines.Length; i++) { var line = lines[i].Trim(); if (!inMetadata) { continue; } if (line == "---") { contentStart = i + 1; break; } if (string.IsNullOrEmpty(line)) { contentStart = i; break; } var colonIdx = line.IndexOf(':'); if (colonIdx > 0) { var key = line.Substring(0, colonIdx).Trim(); var val = line.Substring(colonIdx + 1).Trim(); metadata[key] = val; } else { contentStart = i; break; } } var remaining = string.Join("\n", lines, contentStart, lines.Length - contentStart); return (metadata, remaining); } private static void FinalizePendingSelect(DialogQueue dlg, ref ActionSelect pending) { if (pending == null) return; if (pending.Variants == null || pending.Variants.Count == 0) { pending = null; return; } dlg.Queue.Add(pending); pending = null; } private static List Tokenize(string line) { var tokens=new List(); var i=0; while(i