Support concurrent background scenario execution and debugging

This commit is contained in:
cthangover committed 2026-11-03 13:53:23 +03:00
1 parent 1894857eff
commit 4720506047
16 files changed
+232 -8

No files matched your search

+32
View File
@@ -18,6 +18,18 @@
"Key": "key", "Key": "key",
"Type": "text", "Type": "text",
"Name": "dialog/schema/field/locale_key" "Name": "dialog/schema/field/locale_key"
},
{
"Key": "who",
"Type": "text"
},
{
"Key": "duration",
"Type": "number"
},
{
"Key": "cue",
"Type": "text"
} }
] ]
}, },
@@ -39,6 +51,18 @@
"Key": "key", "Key": "key",
"Type": "text", "Type": "text",
"Name": "dialog/schema/field/locale_key" "Name": "dialog/schema/field/locale_key"
},
{
"Key": "who",
"Type": "text"
},
{
"Key": "duration",
"Type": "number"
},
{
"Key": "cue",
"Type": "text"
} }
] ]
}, },
@@ -109,6 +133,14 @@
"Key": "else", "Key": "else",
"Type": "label", "Type": "label",
"Name": "dialog/schema/field/else" "Name": "dialog/schema/field/else"
},
{
"Key": "wait",
"Type": "bool"
},
{
"Key": "timeout",
"Type": "number"
} }
] ]
}, },
+4 -1
View File
@@ -27,10 +27,13 @@ namespace Cthangover.Mods.Dialog
/// it may not exist at the moment of context creation (e.g. before game data /// it may not exist at the moment of context creation (e.g. before game data
/// is fully loaded). /// is fully loaded).
/// </summary> /// </summary>
internal class ScenarioActionContext : IActionContext internal class ScenarioActionContext : IActionContext, IHostedScenarioContext
{ {
private readonly DialogRuntime runtime; private readonly DialogRuntime runtime;
/// <summary>Host services belong to the runtime that invoked this action.</summary>
public IScenarioExecutionHost Host => runtime.Host;
/// <summary> /// <summary>
/// Service accessor for quest operations. Resolved from the kernel /// Service accessor for quest operations. Resolved from the kernel
/// quest registry — the implementation (QuestServiceImpl) lives in /// quest registry — the implementation (QuestServiceImpl) lives in
+21
View File
@@ -0,0 +1,21 @@
namespace Cthangover.Mods.Dialog
{
using System.Collections.Generic;
/// <summary>A detached UI snapshot; no tool retains mutable actions or a gameplay session.</summary>
public sealed class BackgroundRunInfo
{
public string RunId { get; init; }
public string ScenarioId { get; init; }
public int Line { get; init; }
public string Label { get; init; }
public bool Paused { get; init; }
public string Wait { get; init; }
public double Remaining { get; init; }
public string Text { get; init; }
public string Speaker { get; init; }
public string Body { get; init; }
public string Details { get; init; }
public IReadOnlyDictionary<string, string> Variables { get; init; }
public IReadOnlyDictionary<string, string> Roles { get; init; }
}
}
+15
View File
@@ -0,0 +1,15 @@
namespace Cthangover.Mods.Dialog
{
/// <summary>Catalog source plus a current, human-inspectable admission decision.</summary>
public sealed class BackgroundScenarioInfo
{
public string Id { get; init; }
public string ModId { get; init; }
public string Path { get; init; }
public string Map { get; init; }
public string Source { get; init; }
public string Body { get; init; }
public string Status { get; init; }
public string Details { get; init; }
}
}
+8
View File
@@ -0,0 +1,8 @@
namespace Cthangover.Mods.Dialog
{
/// <summary>Optional background-host capability shared by authoring tools without a dependency on a particular gameplay mod.</summary>
public static class BackgroundScenarios
{
public static IBackgroundScenarioTools Provider { get; set; }
}
}
+21
View File
@@ -0,0 +1,21 @@
namespace Cthangover.Mods.Dialog
{
using System.Collections.Generic;
using Godot;
/// <summary>Live authoring seam. Runtime IDs select one execution, scenario IDs select source and breakpoints.</summary>
public interface IBackgroundScenarioTools
{
IReadOnlyList<BackgroundScenarioInfo> Catalog();
IReadOnlyList<BackgroundRunInfo> Executions();
IReadOnlyList<string> Events { get; }
bool WorldPaused { get; set; }
void Observe(Node owner, bool enabled);
bool Validate(string modId, string path, string text, out string error);
bool Reload(out string error);
bool Play(string modId, string path, string text, bool company, out string error);
bool Command(string runId, string command, out string error);
bool HasBreakpoint(string scenarioId, int line);
void ToggleBreakpoint(string scenarioId, int line);
}
}
+8
View File
@@ -0,0 +1,8 @@
namespace Cthangover.Mods.Dialog
{
/// <summary>Optional action-context capability exposing the execution owner without a global current-scenario singleton.</summary>
public interface IHostedScenarioContext
{
IScenarioExecutionHost Host { get; }
}
}
+19
View File
@@ -0,0 +1,19 @@
namespace Cthangover.Mods.Dialog
{
using Cthangover.Mods.Dialog.Action.Impls;
/// <summary>Per-execution presentation and wait services. A host never owns another runtime's mutable actions.</summary>
public interface IScenarioExecutionHost
{
/// <summary>Displays timed speech without occupying the modal DialogBox.</summary>
void ShowText(ActionText action, DialogRuntime runtime);
/// <summary>Starts a host-clock delay; completion advances only this execution.</summary>
void Delay(ActionDelay action, DialogRuntime runtime);
/// <summary>Waits for a live condition or its deadline.</summary>
void WaitCondition(ActionWaitCondition action, DialogRuntime runtime);
/// <summary>Evaluates against this execution's roles and scope.</summary>
bool EvaluateCondition(string condition);
/// <summary>Releases waits and presentation. Aborts do not count as completed scenarios.</summary>
void Finished(bool aborted);
}
}
+12 -1
View File
@@ -29,7 +29,7 @@ namespace Cthangover.Mods.Dialog.Scenarios
/// <inheritdoc/> /// <inheritdoc/>
public int MaxPositional => 1; public int MaxPositional => 1;
/// <inheritdoc/> /// <inheritdoc/>
public IReadOnlyList<string> AllowedNamed => new[] { "key", "first", "second", "hide_color", "wait" }; public IReadOnlyList<string> AllowedNamed => new[] { "key", "first", "second", "hide_color", "wait", "who", "duration", "cue" };
/// <inheritdoc/> /// <inheritdoc/>
public void Execute(DialogQueue dlg, List<string> positional, Dictionary<string, string> named, string arrowTarget, ILocalizationProvider locale) public void Execute(DialogQueue dlg, List<string> positional, Dictionary<string, string> named, string arrowTarget, ILocalizationProvider locale)
@@ -46,6 +46,17 @@ namespace Cthangover.Mods.Dialog.Scenarios
var hideColor = named.TryGetValue("hide_color", out var hc) && hc == "true"; var hideColor = named.TryGetValue("hide_color", out var hc) && hc == "true";
var action = dlg.PText(textVal, first, second, hideColor); var action = dlg.PText(textVal, first, second, hideColor);
action.FallbackText = positional.Count > 0 ? positional[0] : "";
action.LocaleKey = key;
action.Speaker = named.TryGetValue("who", out var who) ? who : null;
action.Cue = named.TryGetValue("cue", out var cue) ? cue : null;
if (named.TryGetValue("duration", out var duration))
{
if (!double.TryParse(duration, System.Globalization.NumberStyles.Float, System.Globalization.CultureInfo.InvariantCulture, out var seconds)
|| !double.IsFinite(seconds) || seconds <= 0)
throw new System.FormatException("duration must be a positive finite number");
action.Duration = seconds;
}
if (named.TryGetValue("wait", out var wait) && wait == "false") if (named.TryGetValue("wait", out var wait) && wait == "false")
action.WaitType = WaitType.NoWait; action.WaitType = WaitType.NoWait;
+12 -1
View File
@@ -29,7 +29,7 @@ namespace Cthangover.Mods.Dialog.Scenarios
/// <inheritdoc/> /// <inheritdoc/>
public int MaxPositional => 1; public int MaxPositional => 1;
/// <inheritdoc/> /// <inheritdoc/>
public IReadOnlyList<string> AllowedNamed => new[] { "key", "first", "second", "hide_color", "wait" }; public IReadOnlyList<string> AllowedNamed => new[] { "key", "first", "second", "hide_color", "wait", "who", "duration", "cue" };
/// <inheritdoc/> /// <inheritdoc/>
public void Execute(DialogQueue dlg, List<string> positional, Dictionary<string, string> named, string arrowTarget, ILocalizationProvider locale) public void Execute(DialogQueue dlg, List<string> positional, Dictionary<string, string> named, string arrowTarget, ILocalizationProvider locale)
@@ -46,6 +46,17 @@ namespace Cthangover.Mods.Dialog.Scenarios
var hideColor = named.TryGetValue("hide_color", out var hc) && hc == "true"; var hideColor = named.TryGetValue("hide_color", out var hc) && hc == "true";
var action = dlg.Text(textVal, first, second, hideColor); var action = dlg.Text(textVal, first, second, hideColor);
action.FallbackText = positional.Count > 0 ? positional[0] : "";
action.LocaleKey = key;
action.Speaker = named.TryGetValue("who", out var who) ? who : null;
action.Cue = named.TryGetValue("cue", out var cue) ? cue : null;
if (named.TryGetValue("duration", out var duration))
{
if (!double.TryParse(duration, System.Globalization.NumberStyles.Float, System.Globalization.CultureInfo.InvariantCulture, out var seconds)
|| !double.IsFinite(seconds) || seconds <= 0)
throw new System.FormatException("duration must be a positive finite number");
action.Duration = seconds;
}
if (named.TryGetValue("wait", out var wait) && wait == "false") if (named.TryGetValue("wait", out var wait) && wait == "false")
action.WaitType = WaitType.NoWait; action.WaitType = WaitType.NoWait;
+15 -2
View File
@@ -25,7 +25,7 @@ namespace Cthangover.Mods.Dialog.Scenarios
public int MinPositional => 1; public int MinPositional => 1;
/// <inheritdoc /> /// <inheritdoc />
public IReadOnlyList<string> AllowedNamed => new[] { "else" }; public IReadOnlyList<string> AllowedNamed => new[] { "else", "wait", "timeout" };
/// <inheritdoc /> /// <inheritdoc />
public string JumpTargetNamedKey => "else"; public string JumpTargetNamedKey => "else";
@@ -35,8 +35,21 @@ namespace Cthangover.Mods.Dialog.Scenarios
{ {
var condition = string.Join(" ", positional); var condition = string.Join(" ", positional);
named.TryGetValue("else", out var otherwise); named.TryGetValue("else", out var otherwise);
if (named.TryGetValue("wait", out var wait) && wait == "true")
{
if (!named.TryGetValue("timeout", out var timeout) || !double.TryParse(timeout,
System.Globalization.NumberStyles.Float, System.Globalization.CultureInfo.InvariantCulture, out var seconds)
|| !double.IsFinite(seconds) || seconds <= 0)
throw new System.FormatException("when wait=true requires a positive finite timeout");
dlg.Queue.Add(new Cthangover.Mods.Dialog.Action.Impls.ActionWaitCondition
{
ConditionText = condition, Timeout = seconds,
TrueTarget = Label(arrowTarget), TimeoutTarget = Label(otherwise),
});
return;
}
var branch = dlg.IfGoTo(null, Label(arrowTarget), Label(otherwise)); var branch = dlg.IfGoTo(null, Label(arrowTarget), Label(otherwise));
branch.ScopedCondition = scope => ScenarioCondition.Evaluate(condition, scope); branch.ConditionText = condition;
} }
private static string Label(string target) => string.IsNullOrWhiteSpace(target) ? null : target.Trim().TrimStart(':'); private static string Label(string target) => string.IsNullOrWhiteSpace(target) ? null : target.Trim().TrimStart(':');
@@ -32,6 +32,11 @@ namespace Cthangover.Mods.Dialog.Action.Impls
/// <summary>Shows the optional <see cref="ShowText"/> and, in <see cref="HiddenMode"/>, hides the dialog body for the pause duration.</summary> /// <summary>Shows the optional <see cref="ShowText"/> and, in <see cref="HiddenMode"/>, hides the dialog body for the pause duration.</summary>
public override void DoRun(DialogRuntime runtime) public override void DoRun(DialogRuntime runtime)
{ {
if (runtime.Host != null)
{
runtime.Host.Delay(this, runtime);
return;
}
var dialogBox = runtime.DialogBox; var dialogBox = runtime.DialogBox;
if (dialogBox == null) if (dialogBox == null)
return; return;
+5 -2
View File
@@ -23,6 +23,8 @@ namespace Cthangover.Mods.Dialog.Action.Impls
{ {
/// <summary>Delegate evaluated at runtime. Must return true/false to select the branch target.</summary> /// <summary>Delegate evaluated at runtime. Must return true/false to select the branch target.</summary>
public Func<bool> Condition { get; set; } public Func<bool> Condition { get; set; }
/// <summary>Source expression evaluated by the runtime's own context, not a captured global role.</summary>
public string ConditionText { get; set; }
/// <summary> /// <summary>
/// Used instead of <see cref="Condition"/> when set: evaluated with the instance scope of the /// Used instead of <see cref="Condition"/> when set: evaluated with the instance scope of the
@@ -41,13 +43,14 @@ namespace Cthangover.Mods.Dialog.Action.Impls
/// <summary>Evaluates <see cref="Condition"/> and jumps to <see cref="TrueGoTo"/> or <see cref="FalseGoTo"/>; an empty target falls through to the next action.</summary> /// <summary>Evaluates <see cref="Condition"/> and jumps to <see cref="TrueGoTo"/> or <see cref="FalseGoTo"/>; an empty target falls through to the next action.</summary>
public override void DoRun(DialogRuntime runtime) public override void DoRun(DialogRuntime runtime)
{ {
if (Condition == null && ScopedCondition == null) if (ConditionText == null && Condition == null && ScopedCondition == null)
{ {
GameLogger.Log("DIALOG", "ActionIf: Condition delegate is null", LogLevel.Error); GameLogger.Log("DIALOG", "ActionIf: Condition delegate is null", LogLevel.Error);
return; return;
} }
var result = ScopedCondition != null ? ScopedCondition(runtime?.Dialog?.Scope) : Condition(); var result = ConditionText != null ? runtime.EvaluateCondition(ConditionText)
: ScopedCondition != null ? ScopedCondition(runtime?.Dialog?.Scope) : Condition();
var goTo = result ? TrueGoTo : FalseGoTo; var goTo = result ? TrueGoTo : FalseGoTo;
if (!string.IsNullOrEmpty(goTo)) if (!string.IsNullOrEmpty(goTo))
+15
View File
@@ -21,6 +21,16 @@ namespace Cthangover.Mods.Dialog.Action.Impls
{ {
/// <summary>The dialog text to display. Supports ${var} substitution when <see cref="UseProcessText"/> is true.</summary> /// <summary>The dialog text to display. Supports ${var} substitution when <see cref="UseProcessText"/> is true.</summary>
public string Text { get; set; } public string Text { get; set; }
/// <summary>Untranslated source retained for live locale changes in hosted speech.</summary>
public string FallbackText { get; set; }
/// <summary>Stable translation key, independent of speech event cues.</summary>
public string LocaleKey { get; set; }
/// <summary>Host role naming the concrete speaker.</summary>
public string Speaker { get; set; }
/// <summary>Optional semantic event emitted when this utterance finishes normally.</summary>
public string Cue { get; set; }
/// <summary>Explicit host-clock duration; null uses translated text length.</summary>
public double? Duration { get; set; }
/// <summary>Left avatar sprite ID resolved through <see cref="IAvatarProvider"/>. Null or empty hides the slot.</summary> /// <summary>Left avatar sprite ID resolved through <see cref="IAvatarProvider"/>. Null or empty hides the slot.</summary>
public string FirstAvatar { get; set; } public string FirstAvatar { get; set; }
/// <summary>Right avatar sprite ID resolved through <see cref="IAvatarProvider"/>. Null or empty hides the slot.</summary> /// <summary>Right avatar sprite ID resolved through <see cref="IAvatarProvider"/>. Null or empty hides the slot.</summary>
@@ -36,6 +46,11 @@ namespace Cthangover.Mods.Dialog.Action.Impls
/// <summary>Sets the dialog text (optionally variable-substituted) and both avatar slots, then waits for the player's click.</summary> /// <summary>Sets the dialog text (optionally variable-substituted) and both avatar slots, then waits for the player's click.</summary>
public override void DoRun(DialogRuntime runtime) public override void DoRun(DialogRuntime runtime)
{ {
if (runtime.Host != null)
{
runtime.Host.ShowText(this, runtime);
return;
}
var dialogBox = runtime.DialogBox; var dialogBox = runtime.DialogBox;
if (dialogBox == null) if (dialogBox == null)
return; return;
@@ -0,0 +1,22 @@
namespace Cthangover.Mods.Dialog.Action.Impls
{
/// <summary>An explicit asynchronous condition wait; ordinary when remains a one-shot branch.</summary>
public class ActionWaitCondition : ActionCommand
{
/// <summary>Evaluated against the execution's live context.</summary>
public string ConditionText { get; set; }
/// <summary>Maximum host-clock wait.</summary>
public double Timeout { get; set; }
/// <summary>Label selected when the condition becomes true.</summary>
public string TrueTarget { get; set; }
/// <summary>Label selected on timeout, or fall-through when absent.</summary>
public string TimeoutTarget { get; set; }
public override WaitType WaitType { get; set; } = WaitType.WaitEvent;
public override void DoRun(DialogRuntime runtime)
{
if (runtime.Host == null)
runtime.FailExpression("when wait=true requires an execution host", SourceLine);
else runtime.Host.WaitCondition(this, runtime);
}
}
}
+18 -1
View File
@@ -51,6 +51,13 @@ namespace Cthangover.Mods.Dialog
/// <summary>The currently active dialog queue. Set by <see cref="SetDialogQueueAndRun"/>.</summary> /// <summary>The currently active dialog queue. Set by <see cref="SetDialogQueueAndRun"/>.</summary>
public DialogQueue Dialog { get; set; } public DialogQueue Dialog { get; set; }
/// <summary>Optional per-execution host; null preserves modal dialog behavior.</summary>
public IScenarioExecutionHost Host { get; set; }
/// <summary>Conditions use live host roles when present, otherwise the existing instance scope.</summary>
public bool EvaluateCondition(string text) => Host != null
? Host.EvaluateCondition(text) : ScenarioCondition.Evaluate(text, Dialog?.Scope);
/// <summary>The action at the current queue index. Automatically constructs it if <see cref="ConstructType"/> is OnStartAction and it hasn't been constructed yet.</summary> /// <summary>The action at the current queue index. Automatically constructs it if <see cref="ConstructType"/> is OnStartAction and it hasn't been constructed yet.</summary>
public IActionCommand CurrentAction => GetByIndex(index); public IActionCommand CurrentAction => GetByIndex(index);
@@ -71,6 +78,9 @@ namespace Cthangover.Mods.Dialog
/// </summary> /// </summary>
public static IDialogDebugger Debugger { get; set; } public static IDialogDebugger Debugger { get; set; }
/// <summary>A host may debug its own execution independently of the modal studio hook.</summary>
public IDialogDebugger ExecutionDebugger { get; set; }
/// <summary> /// <summary>
/// True while the debugger holds the runtime before <see cref="CurrentAction"/>: /// True while the debugger holds the runtime before <see cref="CurrentAction"/>:
/// that action has not run yet, so advancing must wait for /// that action has not run yet, so advancing must wait for
@@ -276,7 +286,7 @@ namespace Cthangover.Mods.Dialog
GameLogger.Log("DIALOG", $"Run: idx={index}/{dialogQueue.Count-1} type={action.GetType().Name} wait={action.WaitType}", LogLevel.Debug); 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)) if ((ExecutionDebugger ?? Debugger)?.ShouldHold(this, action) == true)
{ {
IsHeld = true; IsHeld = true;
return; return;
@@ -286,6 +296,11 @@ namespace Cthangover.Mods.Dialog
if (runGeneration != generation) if (runGeneration != generation)
return; return;
if (Host != null && iteration > 256)
{
FailExpression("Hosted scenario exceeded the immediate-action budget");
return;
}
if (iteration++ > 100000) if (iteration++ > 100000)
{ {
GameLogger.Log("DIALOG", "Run: infinite loop protection (100k iterations)", LogLevel.Warning); GameLogger.Log("DIALOG", "Run: infinite loop protection (100k iterations)", LogLevel.Warning);
@@ -398,6 +413,7 @@ namespace Cthangover.Mods.Dialog
dialogQueue = null; dialogQueue = null;
endDialogQueue = null; endDialogQueue = null;
index = 0; index = 0;
Host?.Finished(false);
if (DialogBox != null) if (DialogBox != null)
{ {
@@ -427,6 +443,7 @@ namespace Cthangover.Mods.Dialog
index = 0; index = 0;
isLastGoTo = false; isLastGoTo = false;
IsHeld = false; IsHeld = false;
Host?.Finished(true);
GameLogger.Log("DIALOG", "Abort: queue dropped without ending"); GameLogger.Log("DIALOG", "Abort: queue dropped without ending");
} }