diff --git a/src/ScenarioEventFactory.cs b/src/ScenarioEventFactory.cs index 014840c..5618ca9 100644 --- a/src/ScenarioEventFactory.cs +++ b/src/ScenarioEventFactory.cs @@ -17,8 +17,8 @@ namespace Cthangover.Mods.Dialog return new ScenarioEvent { ScenarioText = scenarioText }; } - /// Builds an event from a scene event definition's scenario fields. - public IExecutableEvent CreateFromDefinition(string scenarioText, string scenarioId, string condition, bool? lightUseTime, bool isOneRun) + /// Builds an event from a scene event definition's scenario fields, remembering the source file. + public IExecutableEvent CreateFromDefinition(string scenarioText, string scenarioId, string condition, bool? lightUseTime, bool isOneRun, string modId, string filePath) { return new ScenarioEvent { @@ -27,6 +27,8 @@ namespace Cthangover.Mods.Dialog Condition = condition, LightUseTime = lightUseTime, IsOneRun = isOneRun, + SourceModId = modId, + SourcePath = filePath, }; } } diff --git a/src/Scenarios/ScenarioParser.cs b/src/Scenarios/ScenarioParser.cs index cca95cd..4044666 100644 --- a/src/Scenarios/ScenarioParser.cs +++ b/src/Scenarios/ScenarioParser.cs @@ -36,9 +36,10 @@ namespace Cthangover.Mods.Dialog.Scenarios var lines = text.Split('\n'); ActionSelect pendingSelect = null; - foreach (var rawLine in lines) + for (var lineIndex = 0; lineIndex < lines.Length; lineIndex++) { - var line = rawLine.Trim(); + var lineNumber = lineIndex + 1; + var line = lines[lineIndex].Trim(); if (line.Length == 0 || line.StartsWith('#')) { FinalizePendingSelect(dlg, ref pendingSelect); @@ -48,51 +49,19 @@ namespace Cthangover.Mods.Dialog.Scenarios if (line.StartsWith(':')) { FinalizePendingSelect(dlg, ref pendingSelect); - dlg.Point(line.Substring(1)); + dlg.Point(line.Substring(1)).SourceLine = lineNumber; continue; } - var tokens = Tokenize(line); - if (tokens.Count == 0) continue; - - var cmd = tokens[0].ToLowerInvariant(); - tokens.RemoveAt(0); - var positional = new List(); var named = new Dictionary(); - var arrowTarget = ""; - - for (var i = 0; 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[name.ToLowerInvariant()] = t.Substring(eqIdx + 1); - continue; - } - } - - positional.Add(t); - } + var cmd = SplitLine(line, positional, named, out var arrowTarget); + if (cmd == null) continue; if (cmd == "select") { FinalizePendingSelect(dlg, ref pendingSelect); - pendingSelect = new ActionSelect(); + pendingSelect = new ActionSelect { SourceLine = lineNumber }; var promptText = positional.Count > 0 ? positional[0] : null; if (named.TryGetValue("key", out var key) && locale != null) @@ -141,14 +110,67 @@ namespace Cthangover.Mods.Dialog.Scenarios FinalizePendingSelect(dlg, ref pendingSelect); var strategy = ScenarioCommandStrategyFactory.Get(cmd); - if (strategy != null) - strategy.Execute(dlg, positional, named, arrowTarget, locale); + 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(); + 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[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 diff --git a/src/UI/Dialog/Action/ActionCommand.cs b/src/UI/Dialog/Action/ActionCommand.cs index 301aad7..15c650d 100644 --- a/src/UI/Dialog/Action/ActionCommand.cs +++ b/src/UI/Dialog/Action/ActionCommand.cs @@ -44,6 +44,8 @@ namespace Cthangover.Mods.Dialog.Action public float StartTime { get; private set; } /// Action IDs whose will be triggered when this action destructs. Used for cross-action cleanup dependencies. public ISet DelayedDestruct { get; } = new HashSet(); + /// 1-based scenario line the action was parsed from; 0 when built in code. + public int SourceLine { get; set; } /// /// Scene node lookup seam for subclasses. Replaces direct diff --git a/src/UI/Dialog/Action/IActionCommand.cs b/src/UI/Dialog/Action/IActionCommand.cs index aae9824..0757387 100644 --- a/src/UI/Dialog/Action/IActionCommand.cs +++ b/src/UI/Dialog/Action/IActionCommand.cs @@ -39,6 +39,12 @@ namespace Cthangover.Mods.Dialog.Action float WaitTime { get; } /// Timestamp of the last call, in seconds since epoch. float StartTime { get; } + /// + /// 1-based line of the scenario text this action was parsed from, 0 for + /// actions built in code. Set by ; + /// debugging tools map the running action back to the script with it. + /// + int SourceLine { get; set; } #endregion diff --git a/src/UI/Dialog/DialogBox.cs b/src/UI/Dialog/DialogBox.cs index 059335d..7a5bf8b 100644 --- a/src/UI/Dialog/DialogBox.cs +++ b/src/UI/Dialog/DialogBox.cs @@ -209,7 +209,7 @@ namespace Cthangover.Mods.Dialog /// public void NextAction() { - if (Runtime.IsWaitAnswer) + if (Runtime.IsHeld || Runtime.IsWaitAnswer) return; if (Runtime.IsEnd) { @@ -242,7 +242,7 @@ namespace Cthangover.Mods.Dialog /// Advances the dialog on ui_accept or on a click outside the body, unless a choice is pending or the queue has ended. protected override void OnInput(InputEvent @event) { - if (Runtime.IsEnd || Runtime.IsWaitAnswer) + if (Runtime.IsHeld || Runtime.IsEnd || Runtime.IsWaitAnswer) return; if (@event.IsActionPressed("ui_accept")) @@ -269,6 +269,40 @@ namespace Cthangover.Mods.Dialog } } + /// + /// Continues a dialog the debugger held (): + /// runs from the held action on, exactly as if it had never stopped. + /// Does nothing when the runtime is not held. + /// + public void Resume() + { + if (!Runtime.IsHeld) + return; + + Runtime.IsHeld = false; + Runtime.Run(); + ScheduleWaitTimer(); + } + + /// + /// Stops the running dialog without finishing it () + /// and frees the box for another queue: closes the answer box, hides + /// and releases the locker. The scenario does not count as completed. + /// + public void Abort() + { + ClearWaitTimer(); + if (answerBox != null) + { + answerBox.Hide(); + answerBox = null; + } + + Runtime.Abort(); + Hide(); + Locker = null; + } + /// Whether a dialog is currently running (Locker is not null). public bool IsRunning => Locker != null; diff --git a/src/UI/Dialog/DialogRuntime.cs b/src/UI/Dialog/DialogRuntime.cs index f9985cf..6f4178f 100644 --- a/src/UI/Dialog/DialogRuntime.cs +++ b/src/UI/Dialog/DialogRuntime.cs @@ -57,6 +57,26 @@ namespace Cthangover.Mods.Dialog /// True when the dialog queue is exhausted (index past end or queue is empty). public bool IsEnd => Lists.IsEmpty(dialogQueue) || index >= dialogQueue.Count; + /// + /// Process-wide debugging hook asked before every action (see + /// ); null in normal play, which costs one + /// null check per action. + /// + public static IDialogDebugger Debugger { get; set; } + + /// + /// True while the debugger holds the runtime before : + /// that action has not run yet, so advancing must wait for + /// instead of skipping it. + /// + public bool IsHeld { get; set; } + + /// Queue index of . + public int Index => index; + + /// Current ${name} variables of the dialog, for inspection. + public IReadOnlyDictionary Variables => (IReadOnlyDictionary)variables; + /// Retrieves a stored variable value by name. Returns null if not set. public string GetVariable(string name) { @@ -122,6 +142,7 @@ namespace Cthangover.Mods.Dialog { variables = new Dictionary(); DialogBox = dialogBox; + IsHeld = false; this.endDialogQueue = endQueue == null ? null : endQueue.ToList(); if (Lists.IsNotEmpty(this.dialogQueue)) @@ -187,7 +208,13 @@ namespace Cthangover.Mods.Dialog } GameLogger.Log("DIALOG", $"Run: idx={index}/{dialogQueue.Count-1} type={action.GetType().Name} wait={action.WaitType}", LogLevel.Debug); - + + if (Debugger != null && Debugger.ShouldHold(this, action)) + { + IsHeld = true; + return; + } + action.Run(this); if (iteration++ > 100000) @@ -301,6 +328,30 @@ namespace Cthangover.Mods.Dialog } } + /// + /// Drops the running queue without finishing it: destructs constructed + /// actions and forgets the queue, but — unlike — runs no + /// end actions, does not mark a one-run scenario completed and publishes + /// no . Used to restart a scenario that is + /// being edited; the caller hides the box (). + /// + public void Abort() + { + if (Lists.IsNotEmpty(dialogQueue)) + { + foreach (var action in dialogQueue) + if (action != null && action.IsConstructed) + action.Destruct(); + } + + dialogQueue = null; + endDialogQueue = null; + index = 0; + isLastGoTo = false; + IsHeld = false; + GameLogger.Log("DIALOG", "Abort: queue dropped without ending"); + } + /// /// Records a finished one-run scenario in the per-game completion /// history () so the diff --git a/src/UI/Dialog/IDialogDebugger.cs b/src/UI/Dialog/IDialogDebugger.cs new file mode 100644 index 0000000..0a798e0 --- /dev/null +++ b/src/UI/Dialog/IDialogDebugger.cs @@ -0,0 +1,23 @@ +using Cthangover.Mods.Dialog.Action; + +namespace Cthangover.Mods.Dialog +{ + /// + /// Hook a debugging tool installs in to + /// stop a dialog before an action runs (pause, step, breakpoints). The + /// runtime asks before every action of every dialog — including standalone + /// runtimes without a (hover commands) — so the + /// implementation must answer quickly and hold only dialogs it can resume + /// (). + /// + public interface IDialogDebugger + { + /// + /// Returns true to hold before + /// : the runtime marks itself + /// and returns without running it; + /// clicks are ignored until . + /// + bool ShouldHold(DialogRuntime runtime, IActionCommand action); + } +} diff --git a/src/UI/Executable/ExecutableEvent.cs b/src/UI/Executable/ExecutableEvent.cs index ba9dce7..2a40754 100644 --- a/src/UI/Executable/ExecutableEvent.cs +++ b/src/UI/Executable/ExecutableEvent.cs @@ -159,7 +159,7 @@ namespace Cthangover.Mods.Dialog.Executable var dlg = new DialogQueue(); CreateDialog(dlg); - dialogBox.SetDialogQueueAndRun(dlg, null, 0, this); + dialogBox.SetDialogQueueAndRun(dlg, null, StartIndex(dlg), this); IsFirstRun = false; return true; } @@ -167,6 +167,9 @@ namespace Cthangover.Mods.Dialog.Executable protected virtual void CreateDialog(DialogQueue dlg) { } + + /// Queue index the dialog starts at; 0 unless a subclass starts mid-script. + protected virtual int StartIndex(DialogQueue dlg) => 0; } } diff --git a/src/UI/Executable/ScenarioEvent.cs b/src/UI/Executable/ScenarioEvent.cs index 78b807d..7f3d3f5 100644 --- a/src/UI/Executable/ScenarioEvent.cs +++ b/src/UI/Executable/ScenarioEvent.cs @@ -81,6 +81,23 @@ namespace Cthangover.Mods.Dialog.Executable /// public override string ID => ScenarioId ?? base.ID; + /// + /// Mod (or content overlay) the scenario text was read from; null for + /// generated text (test play, inline scenarios). With + /// it lets debugging tools open and edit the file behind a running event. + /// + public string SourceModId { get; set; } + + /// Mod-relative path of the scenario file; see . + public string SourcePath { get; set; } + + /// + /// Label (:name in the script, without the colon) to start at + /// instead of the first line. Used to restart a scenario from where an + /// edited part begins; an unknown label falls back to the start. + /// + public string StartLabel { get; set; } + protected override void CreateDialog(DialogQueue dlg) { if (LightUseTime.HasValue) @@ -111,6 +128,22 @@ namespace Cthangover.Mods.Dialog.Executable dlg.RuntimeObjectList.Add(obj); } + /// Index of the point in the built queue, or 0. + protected override int StartIndex(DialogQueue dlg) + { + if (string.IsNullOrEmpty(StartLabel)) + return 0; + + for (var i = 0; i < dlg.Queue.Count; i++) + { + if (dlg.Queue[i]?.ID == StartLabel) + return i; + } + + GameLogger.Log("SCENE", $"ScenarioEvent '{ID}': start label '{StartLabel}' not found, starting from the top", LogLevel.Warning); + return 0; + } + private long _cachedVersion = long.MinValue; private bool _cachedResult;