diff --git a/schemas/dialog_base.json b/schemas/dialog_base.json
index 3b0a698..5f837d7 100644
--- a/schemas/dialog_base.json
+++ b/schemas/dialog_base.json
@@ -18,6 +18,18 @@
"Key": "key",
"Type": "text",
"Name": "dialog/schema/field/locale_key"
+ },
+ {
+ "Key": "who",
+ "Type": "text"
+ },
+ {
+ "Key": "duration",
+ "Type": "number"
+ },
+ {
+ "Key": "cue",
+ "Type": "text"
}
]
},
@@ -39,6 +51,18 @@
"Key": "key",
"Type": "text",
"Name": "dialog/schema/field/locale_key"
+ },
+ {
+ "Key": "who",
+ "Type": "text"
+ },
+ {
+ "Key": "duration",
+ "Type": "number"
+ },
+ {
+ "Key": "cue",
+ "Type": "text"
}
]
},
@@ -109,6 +133,14 @@
"Key": "else",
"Type": "label",
"Name": "dialog/schema/field/else"
+ },
+ {
+ "Key": "wait",
+ "Type": "bool"
+ },
+ {
+ "Key": "timeout",
+ "Type": "number"
}
]
},
diff --git a/src/Actions/ScenarioActionContext.cs b/src/Actions/ScenarioActionContext.cs
index 36c0ddd..f476052 100644
--- a/src/Actions/ScenarioActionContext.cs
+++ b/src/Actions/ScenarioActionContext.cs
@@ -27,10 +27,13 @@ namespace Cthangover.Mods.Dialog
/// it may not exist at the moment of context creation (e.g. before game data
/// is fully loaded).
///
- internal class ScenarioActionContext : IActionContext
+ internal class ScenarioActionContext : IActionContext, IHostedScenarioContext
{
private readonly DialogRuntime runtime;
+ /// Host services belong to the runtime that invoked this action.
+ public IScenarioExecutionHost Host => runtime.Host;
+
///
/// Service accessor for quest operations. Resolved from the kernel
/// quest registry — the implementation (QuestServiceImpl) lives in
diff --git a/src/Debugging/BackgroundRunInfo.cs b/src/Debugging/BackgroundRunInfo.cs
new file mode 100644
index 0000000..22cd316
--- /dev/null
+++ b/src/Debugging/BackgroundRunInfo.cs
@@ -0,0 +1,21 @@
+namespace Cthangover.Mods.Dialog
+{
+ using System.Collections.Generic;
+ /// A detached UI snapshot; no tool retains mutable actions or a gameplay session.
+ 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 Variables { get; init; }
+ public IReadOnlyDictionary Roles { get; init; }
+ }
+}
diff --git a/src/Debugging/BackgroundScenarioInfo.cs b/src/Debugging/BackgroundScenarioInfo.cs
new file mode 100644
index 0000000..5bb60e0
--- /dev/null
+++ b/src/Debugging/BackgroundScenarioInfo.cs
@@ -0,0 +1,15 @@
+namespace Cthangover.Mods.Dialog
+{
+ /// Catalog source plus a current, human-inspectable admission decision.
+ 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; }
+ }
+}
diff --git a/src/Debugging/BackgroundScenarios.cs b/src/Debugging/BackgroundScenarios.cs
new file mode 100644
index 0000000..431fbe2
--- /dev/null
+++ b/src/Debugging/BackgroundScenarios.cs
@@ -0,0 +1,8 @@
+namespace Cthangover.Mods.Dialog
+{
+ /// Optional background-host capability shared by authoring tools without a dependency on a particular gameplay mod.
+ public static class BackgroundScenarios
+ {
+ public static IBackgroundScenarioTools Provider { get; set; }
+ }
+}
diff --git a/src/Debugging/IBackgroundScenarioTools.cs b/src/Debugging/IBackgroundScenarioTools.cs
new file mode 100644
index 0000000..f674052
--- /dev/null
+++ b/src/Debugging/IBackgroundScenarioTools.cs
@@ -0,0 +1,21 @@
+namespace Cthangover.Mods.Dialog
+{
+ using System.Collections.Generic;
+ using Godot;
+
+ /// Live authoring seam. Runtime IDs select one execution, scenario IDs select source and breakpoints.
+ public interface IBackgroundScenarioTools
+ {
+ IReadOnlyList Catalog();
+ IReadOnlyList Executions();
+ IReadOnlyList 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);
+ }
+}
diff --git a/src/IHostedScenarioContext.cs b/src/IHostedScenarioContext.cs
new file mode 100644
index 0000000..bc8b1ee
--- /dev/null
+++ b/src/IHostedScenarioContext.cs
@@ -0,0 +1,8 @@
+namespace Cthangover.Mods.Dialog
+{
+ /// Optional action-context capability exposing the execution owner without a global current-scenario singleton.
+ public interface IHostedScenarioContext
+ {
+ IScenarioExecutionHost Host { get; }
+ }
+}
diff --git a/src/IScenarioExecutionHost.cs b/src/IScenarioExecutionHost.cs
new file mode 100644
index 0000000..1cddc28
--- /dev/null
+++ b/src/IScenarioExecutionHost.cs
@@ -0,0 +1,19 @@
+namespace Cthangover.Mods.Dialog
+{
+ using Cthangover.Mods.Dialog.Action.Impls;
+
+ /// Per-execution presentation and wait services. A host never owns another runtime's mutable actions.
+ public interface IScenarioExecutionHost
+ {
+ /// Displays timed speech without occupying the modal DialogBox.
+ void ShowText(ActionText action, DialogRuntime runtime);
+ /// Starts a host-clock delay; completion advances only this execution.
+ void Delay(ActionDelay action, DialogRuntime runtime);
+ /// Waits for a live condition or its deadline.
+ void WaitCondition(ActionWaitCondition action, DialogRuntime runtime);
+ /// Evaluates against this execution's roles and scope.
+ bool EvaluateCondition(string condition);
+ /// Releases waits and presentation. Aborts do not count as completed scenarios.
+ void Finished(bool aborted);
+ }
+}
diff --git a/src/Scenarios/Impls/PTextCommandStrategy.cs b/src/Scenarios/Impls/PTextCommandStrategy.cs
index fff8331..8c70be8 100644
--- a/src/Scenarios/Impls/PTextCommandStrategy.cs
+++ b/src/Scenarios/Impls/PTextCommandStrategy.cs
@@ -29,7 +29,7 @@ namespace Cthangover.Mods.Dialog.Scenarios
///
public int MaxPositional => 1;
///
- public IReadOnlyList AllowedNamed => new[] { "key", "first", "second", "hide_color", "wait" };
+ public IReadOnlyList AllowedNamed => new[] { "key", "first", "second", "hide_color", "wait", "who", "duration", "cue" };
///
public void Execute(DialogQueue dlg, List positional, Dictionary 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 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")
action.WaitType = WaitType.NoWait;
diff --git a/src/Scenarios/Impls/TextCommandStrategy.cs b/src/Scenarios/Impls/TextCommandStrategy.cs
index 4cd6375..1a2fd63 100644
--- a/src/Scenarios/Impls/TextCommandStrategy.cs
+++ b/src/Scenarios/Impls/TextCommandStrategy.cs
@@ -29,7 +29,7 @@ namespace Cthangover.Mods.Dialog.Scenarios
///
public int MaxPositional => 1;
///
- public IReadOnlyList AllowedNamed => new[] { "key", "first", "second", "hide_color", "wait" };
+ public IReadOnlyList AllowedNamed => new[] { "key", "first", "second", "hide_color", "wait", "who", "duration", "cue" };
///
public void Execute(DialogQueue dlg, List positional, Dictionary 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 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")
action.WaitType = WaitType.NoWait;
diff --git a/src/Scenarios/Impls/WhenCommandStrategy.cs b/src/Scenarios/Impls/WhenCommandStrategy.cs
index 5938e7b..29d4eb5 100644
--- a/src/Scenarios/Impls/WhenCommandStrategy.cs
+++ b/src/Scenarios/Impls/WhenCommandStrategy.cs
@@ -25,7 +25,7 @@ namespace Cthangover.Mods.Dialog.Scenarios
public int MinPositional => 1;
///
- public IReadOnlyList AllowedNamed => new[] { "else" };
+ public IReadOnlyList AllowedNamed => new[] { "else", "wait", "timeout" };
///
public string JumpTargetNamedKey => "else";
@@ -35,8 +35,21 @@ namespace Cthangover.Mods.Dialog.Scenarios
{
var condition = string.Join(" ", positional);
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));
- branch.ScopedCondition = scope => ScenarioCondition.Evaluate(condition, scope);
+ branch.ConditionText = condition;
}
private static string Label(string target) => string.IsNullOrWhiteSpace(target) ? null : target.Trim().TrimStart(':');
diff --git a/src/UI/Dialog/Action/Impls/ActionDelay.cs b/src/UI/Dialog/Action/Impls/ActionDelay.cs
index 5e4018c..b2375cc 100644
--- a/src/UI/Dialog/Action/Impls/ActionDelay.cs
+++ b/src/UI/Dialog/Action/Impls/ActionDelay.cs
@@ -32,6 +32,11 @@ namespace Cthangover.Mods.Dialog.Action.Impls
/// Shows the optional and, in , hides the dialog body for the pause duration.
public override void DoRun(DialogRuntime runtime)
{
+ if (runtime.Host != null)
+ {
+ runtime.Host.Delay(this, runtime);
+ return;
+ }
var dialogBox = runtime.DialogBox;
if (dialogBox == null)
return;
diff --git a/src/UI/Dialog/Action/Impls/ActionIf.cs b/src/UI/Dialog/Action/Impls/ActionIf.cs
index c3c37fb..675e26d 100644
--- a/src/UI/Dialog/Action/Impls/ActionIf.cs
+++ b/src/UI/Dialog/Action/Impls/ActionIf.cs
@@ -23,6 +23,8 @@ namespace Cthangover.Mods.Dialog.Action.Impls
{
/// Delegate evaluated at runtime. Must return true/false to select the branch target.
public Func Condition { get; set; }
+ /// Source expression evaluated by the runtime's own context, not a captured global role.
+ public string ConditionText { get; set; }
///
/// Used instead of when set: evaluated with the instance scope of the
@@ -41,13 +43,14 @@ namespace Cthangover.Mods.Dialog.Action.Impls
/// Evaluates and jumps to or ; an empty target falls through to the next action.
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);
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;
if (!string.IsNullOrEmpty(goTo))
diff --git a/src/UI/Dialog/Action/Impls/ActionText.cs b/src/UI/Dialog/Action/Impls/ActionText.cs
index 8f52ba6..f08374c 100644
--- a/src/UI/Dialog/Action/Impls/ActionText.cs
+++ b/src/UI/Dialog/Action/Impls/ActionText.cs
@@ -21,6 +21,16 @@ namespace Cthangover.Mods.Dialog.Action.Impls
{
/// The dialog text to display. Supports ${var} substitution when is true.
public string Text { get; set; }
+ /// Untranslated source retained for live locale changes in hosted speech.
+ public string FallbackText { get; set; }
+ /// Stable translation key, independent of speech event cues.
+ public string LocaleKey { get; set; }
+ /// Host role naming the concrete speaker.
+ public string Speaker { get; set; }
+ /// Optional semantic event emitted when this utterance finishes normally.
+ public string Cue { get; set; }
+ /// Explicit host-clock duration; null uses translated text length.
+ public double? Duration { get; set; }
/// Left avatar sprite ID resolved through . Null or empty hides the slot.
public string FirstAvatar { get; set; }
/// Right avatar sprite ID resolved through . Null or empty hides the slot.
@@ -36,6 +46,11 @@ namespace Cthangover.Mods.Dialog.Action.Impls
/// Sets the dialog text (optionally variable-substituted) and both avatar slots, then waits for the player's click.
public override void DoRun(DialogRuntime runtime)
{
+ if (runtime.Host != null)
+ {
+ runtime.Host.ShowText(this, runtime);
+ return;
+ }
var dialogBox = runtime.DialogBox;
if (dialogBox == null)
return;
diff --git a/src/UI/Dialog/Action/Impls/ActionWaitCondition.cs b/src/UI/Dialog/Action/Impls/ActionWaitCondition.cs
new file mode 100644
index 0000000..3468b72
--- /dev/null
+++ b/src/UI/Dialog/Action/Impls/ActionWaitCondition.cs
@@ -0,0 +1,22 @@
+namespace Cthangover.Mods.Dialog.Action.Impls
+{
+ /// An explicit asynchronous condition wait; ordinary when remains a one-shot branch.
+ public class ActionWaitCondition : ActionCommand
+ {
+ /// Evaluated against the execution's live context.
+ public string ConditionText { get; set; }
+ /// Maximum host-clock wait.
+ public double Timeout { get; set; }
+ /// Label selected when the condition becomes true.
+ public string TrueTarget { get; set; }
+ /// Label selected on timeout, or fall-through when absent.
+ 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);
+ }
+ }
+}
diff --git a/src/UI/Dialog/DialogRuntime.cs b/src/UI/Dialog/DialogRuntime.cs
index 42855d0..cff097e 100644
--- a/src/UI/Dialog/DialogRuntime.cs
+++ b/src/UI/Dialog/DialogRuntime.cs
@@ -51,6 +51,13 @@ namespace Cthangover.Mods.Dialog
/// The currently active dialog queue. Set by .
public DialogQueue Dialog { get; set; }
+ /// Optional per-execution host; null preserves modal dialog behavior.
+ public IScenarioExecutionHost Host { get; set; }
+
+ /// Conditions use live host roles when present, otherwise the existing instance scope.
+ public bool EvaluateCondition(string text) => Host != null
+ ? Host.EvaluateCondition(text) : ScenarioCondition.Evaluate(text, Dialog?.Scope);
+
/// The action at the current queue index. Automatically constructs it if is OnStartAction and it hasn't been constructed yet.
public IActionCommand CurrentAction => GetByIndex(index);
@@ -71,6 +78,9 @@ namespace Cthangover.Mods.Dialog
///
public static IDialogDebugger Debugger { get; set; }
+ /// A host may debug its own execution independently of the modal studio hook.
+ public IDialogDebugger ExecutionDebugger { get; set; }
+
///
/// True while the debugger holds the runtime before :
/// 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);
- if (Debugger != null && Debugger.ShouldHold(this, action))
+ if ((ExecutionDebugger ?? Debugger)?.ShouldHold(this, action) == true)
{
IsHeld = true;
return;
@@ -286,6 +296,11 @@ namespace Cthangover.Mods.Dialog
if (runGeneration != generation)
return;
+ if (Host != null && iteration > 256)
+ {
+ FailExpression("Hosted scenario exceeded the immediate-action budget");
+ return;
+ }
if (iteration++ > 100000)
{
GameLogger.Log("DIALOG", "Run: infinite loop protection (100k iterations)", LogLevel.Warning);
@@ -398,6 +413,7 @@ namespace Cthangover.Mods.Dialog
dialogQueue = null;
endDialogQueue = null;
index = 0;
+ Host?.Finished(false);
if (DialogBox != null)
{
@@ -427,6 +443,7 @@ namespace Cthangover.Mods.Dialog
index = 0;
isLastGoTo = false;
IsHeld = false;
+ Host?.Finished(true);
GameLogger.Log("DIALOG", "Abort: queue dropped without ending");
}