Debugger support for the scenario studio
Actions carry the scenario line they were parsed from (SourceLine); ScenarioParser.SplitLine exposes the parser's line split. ScenarioEvent remembers its source mod/file and can start at a label. DialogRuntime asks an optional IDialogDebugger before every action and can be held; DialogBox resumes a held dialog and aborts one without completing it.
This commit is contained in:
1 parent
8982ad7092
commit
aca89c631f
9 files changed
+221
-45
No files matched your search
@@ -17,8 +17,8 @@ namespace Cthangover.Mods.Dialog
|
||||
return new ScenarioEvent { ScenarioText = scenarioText };
|
||||
}
|
||||
|
||||
/// <summary>Builds an event from a scene event definition's scenario fields.</summary>
|
||||
public IExecutableEvent CreateFromDefinition(string scenarioText, string scenarioId, string condition, bool? lightUseTime, bool isOneRun)
|
||||
/// <summary>Builds an event from a scene event definition's scenario fields, remembering the source file.</summary>
|
||||
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,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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<string>();
|
||||
var named = new Dictionary<string, string>();
|
||||
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;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Splits one trimmed, non-empty DSL line the way <see cref="Parse"/> does:
|
||||
/// returns the lowercase command word (null for an empty line) and fills
|
||||
/// the positional tokens, the <c>name=value</c> pairs (names lowercased)
|
||||
/// and the <c>-> :label</c> target. Tools that read scenarios without
|
||||
/// running them (the scenario studio) use it to agree with the parser.
|
||||
/// </summary>
|
||||
public static string SplitLine(string line, List<string> positional, Dictionary<string, string> 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;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Extracts YAML-like front-matter metadata from script text. Lines before
|
||||
/// a <c>---</c> separator or the first non-key:value line are parsed as
|
||||
|
||||
@@ -44,6 +44,8 @@ namespace Cthangover.Mods.Dialog.Action
|
||||
public float StartTime { get; private set; }
|
||||
/// <summary>Action IDs whose <see cref="Destruct"/> will be triggered when this action destructs. Used for cross-action cleanup dependencies.</summary>
|
||||
public ISet<string> DelayedDestruct { get; } = new HashSet<string>();
|
||||
/// <summary>1-based scenario line the action was parsed from; 0 when built in code.</summary>
|
||||
public int SourceLine { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Scene node lookup seam for subclasses. Replaces direct
|
||||
|
||||
@@ -39,6 +39,12 @@ namespace Cthangover.Mods.Dialog.Action
|
||||
float WaitTime { get; }
|
||||
/// <summary>Timestamp of the last <see cref="Run"/> call, in seconds since epoch.</summary>
|
||||
float StartTime { get; }
|
||||
/// <summary>
|
||||
/// 1-based line of the scenario text this action was parsed from, 0 for
|
||||
/// actions built in code. Set by <see cref="Scenarios.ScenarioParser"/>;
|
||||
/// debugging tools map the running action back to the script with it.
|
||||
/// </summary>
|
||||
int SourceLine { get; set; }
|
||||
|
||||
#endregion
|
||||
|
||||
|
||||
@@ -209,7 +209,7 @@ namespace Cthangover.Mods.Dialog
|
||||
/// </summary>
|
||||
public void NextAction()
|
||||
{
|
||||
if (Runtime.IsWaitAnswer)
|
||||
if (Runtime.IsHeld || Runtime.IsWaitAnswer)
|
||||
return;
|
||||
if (Runtime.IsEnd)
|
||||
{
|
||||
@@ -242,7 +242,7 @@ namespace Cthangover.Mods.Dialog
|
||||
/// <summary>Advances the dialog on <c>ui_accept</c> or on a click outside the body, unless a choice is pending or the queue has ended.</summary>
|
||||
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
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Continues a dialog the debugger held (<see cref="DialogRuntime.IsHeld"/>):
|
||||
/// runs from the held action on, exactly as if it had never stopped.
|
||||
/// Does nothing when the runtime is not held.
|
||||
/// </summary>
|
||||
public void Resume()
|
||||
{
|
||||
if (!Runtime.IsHeld)
|
||||
return;
|
||||
|
||||
Runtime.IsHeld = false;
|
||||
Runtime.Run();
|
||||
ScheduleWaitTimer();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Stops the running dialog without finishing it (<see cref="DialogRuntime.Abort"/>)
|
||||
/// and frees the box for another queue: closes the answer box, hides
|
||||
/// and releases the locker. The scenario does not count as completed.
|
||||
/// </summary>
|
||||
public void Abort()
|
||||
{
|
||||
ClearWaitTimer();
|
||||
if (answerBox != null)
|
||||
{
|
||||
answerBox.Hide();
|
||||
answerBox = null;
|
||||
}
|
||||
|
||||
Runtime.Abort();
|
||||
Hide();
|
||||
Locker = null;
|
||||
}
|
||||
|
||||
/// <summary>Whether a dialog is currently running (Locker is not null).</summary>
|
||||
public bool IsRunning => Locker != null;
|
||||
|
||||
|
||||
@@ -57,6 +57,26 @@ namespace Cthangover.Mods.Dialog
|
||||
/// <summary>True when the dialog queue is exhausted (index past end or queue is empty).</summary>
|
||||
public bool IsEnd => Lists.IsEmpty(dialogQueue) || index >= dialogQueue.Count;
|
||||
|
||||
/// <summary>
|
||||
/// Process-wide debugging hook asked before every action (see
|
||||
/// <see cref="IDialogDebugger"/>); null in normal play, which costs one
|
||||
/// null check per action.
|
||||
/// </summary>
|
||||
public static IDialogDebugger Debugger { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// True while the debugger holds the runtime before <see cref="CurrentAction"/>:
|
||||
/// that action has not run yet, so advancing must wait for
|
||||
/// <see cref="DialogBox.Resume"/> instead of skipping it.
|
||||
/// </summary>
|
||||
public bool IsHeld { get; set; }
|
||||
|
||||
/// <summary>Queue index of <see cref="CurrentAction"/>.</summary>
|
||||
public int Index => index;
|
||||
|
||||
/// <summary>Current <c>${name}</c> variables of the dialog, for inspection.</summary>
|
||||
public IReadOnlyDictionary<string, string> Variables => (IReadOnlyDictionary<string, string>)variables;
|
||||
|
||||
/// <summary>Retrieves a stored variable value by name. Returns null if not set.</summary>
|
||||
public string GetVariable(string name)
|
||||
{
|
||||
@@ -122,6 +142,7 @@ namespace Cthangover.Mods.Dialog
|
||||
{
|
||||
variables = new Dictionary<string, string>();
|
||||
DialogBox = dialogBox;
|
||||
IsHeld = false;
|
||||
|
||||
this.endDialogQueue = endQueue == null ? null : endQueue.ToList();
|
||||
if (Lists.IsNotEmpty(this.dialogQueue))
|
||||
@@ -188,6 +209,12 @@ 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
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Drops the running queue without finishing it: destructs constructed
|
||||
/// actions and forgets the queue, but — unlike <see cref="End"/> — runs no
|
||||
/// end actions, does not mark a one-run scenario completed and publishes
|
||||
/// no <see cref="DialogEndedEvent"/>. Used to restart a scenario that is
|
||||
/// being edited; the caller hides the box (<see cref="DialogBox.Abort"/>).
|
||||
/// </summary>
|
||||
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");
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Records a finished one-run scenario in the per-game completion
|
||||
/// history (<see cref="RuntimeData.CompletedScenarioIds"/>) so the
|
||||
|
||||
@@ -0,0 +1,23 @@
|
||||
using Cthangover.Mods.Dialog.Action;
|
||||
|
||||
namespace Cthangover.Mods.Dialog
|
||||
{
|
||||
/// <summary>
|
||||
/// Hook a debugging tool installs in <see cref="DialogRuntime.Debugger"/> 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 <see cref="DialogBox"/> (hover commands) — so the
|
||||
/// implementation must answer quickly and hold only dialogs it can resume
|
||||
/// (<see cref="DialogBox.Resume"/>).
|
||||
/// </summary>
|
||||
public interface IDialogDebugger
|
||||
{
|
||||
/// <summary>
|
||||
/// Returns true to hold <paramref name="runtime"/> before
|
||||
/// <paramref name="action"/>: the runtime marks itself
|
||||
/// <see cref="DialogRuntime.IsHeld"/> and returns without running it;
|
||||
/// clicks are ignored until <see cref="DialogBox.Resume"/>.
|
||||
/// </summary>
|
||||
bool ShouldHold(DialogRuntime runtime, IActionCommand action);
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
{
|
||||
}
|
||||
|
||||
/// <summary>Queue index the dialog starts at; 0 unless a subclass starts mid-script.</summary>
|
||||
protected virtual int StartIndex(DialogQueue dlg) => 0;
|
||||
}
|
||||
|
||||
}
|
||||
@@ -81,6 +81,23 @@ namespace Cthangover.Mods.Dialog.Executable
|
||||
/// </summary>
|
||||
public override string ID => ScenarioId ?? base.ID;
|
||||
|
||||
/// <summary>
|
||||
/// Mod (or content overlay) the scenario text was read from; null for
|
||||
/// generated text (test play, inline scenarios). With <see cref="SourcePath"/>
|
||||
/// it lets debugging tools open and edit the file behind a running event.
|
||||
/// </summary>
|
||||
public string SourceModId { get; set; }
|
||||
|
||||
/// <summary>Mod-relative path of the scenario file; see <see cref="SourceModId"/>.</summary>
|
||||
public string SourcePath { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Label (<c>:name</c> 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.
|
||||
/// </summary>
|
||||
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);
|
||||
}
|
||||
|
||||
/// <summary>Index of the <see cref="StartLabel"/> point in the built queue, or 0.</summary>
|
||||
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;
|
||||
|
||||
|
||||
Reference in new issue
Block a user