Support concurrent background scenario execution and debugging
This commit is contained in:
1 parent
1894857eff
commit
4720506047
16 files changed
+232
-8
No files matched your search
@@ -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"
|
||||
}
|
||||
]
|
||||
},
|
||||
|
||||
@@ -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).
|
||||
/// </summary>
|
||||
internal class ScenarioActionContext : IActionContext
|
||||
internal class ScenarioActionContext : IActionContext, IHostedScenarioContext
|
||||
{
|
||||
private readonly DialogRuntime runtime;
|
||||
|
||||
/// <summary>Host services belong to the runtime that invoked this action.</summary>
|
||||
public IScenarioExecutionHost Host => runtime.Host;
|
||||
|
||||
/// <summary>
|
||||
/// Service accessor for quest operations. Resolved from the kernel
|
||||
/// quest registry — the implementation (QuestServiceImpl) lives in
|
||||
|
||||
@@ -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; }
|
||||
}
|
||||
}
|
||||
@@ -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; }
|
||||
}
|
||||
}
|
||||
@@ -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; }
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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; }
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -29,7 +29,7 @@ namespace Cthangover.Mods.Dialog.Scenarios
|
||||
/// <inheritdoc/>
|
||||
public int MaxPositional => 1;
|
||||
/// <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/>
|
||||
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 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;
|
||||
|
||||
@@ -29,7 +29,7 @@ namespace Cthangover.Mods.Dialog.Scenarios
|
||||
/// <inheritdoc/>
|
||||
public int MaxPositional => 1;
|
||||
/// <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/>
|
||||
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 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;
|
||||
|
||||
@@ -25,7 +25,7 @@ namespace Cthangover.Mods.Dialog.Scenarios
|
||||
public int MinPositional => 1;
|
||||
|
||||
/// <inheritdoc />
|
||||
public IReadOnlyList<string> AllowedNamed => new[] { "else" };
|
||||
public IReadOnlyList<string> AllowedNamed => new[] { "else", "wait", "timeout" };
|
||||
|
||||
/// <inheritdoc />
|
||||
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(':');
|
||||
|
||||
@@ -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>
|
||||
public override void DoRun(DialogRuntime runtime)
|
||||
{
|
||||
if (runtime.Host != null)
|
||||
{
|
||||
runtime.Host.Delay(this, runtime);
|
||||
return;
|
||||
}
|
||||
var dialogBox = runtime.DialogBox;
|
||||
if (dialogBox == null)
|
||||
return;
|
||||
|
||||
@@ -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>
|
||||
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>
|
||||
/// 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>
|
||||
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))
|
||||
|
||||
@@ -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>
|
||||
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>
|
||||
public string FirstAvatar { get; set; }
|
||||
/// <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>
|
||||
public override void DoRun(DialogRuntime runtime)
|
||||
{
|
||||
if (runtime.Host != null)
|
||||
{
|
||||
runtime.Host.ShowText(this, runtime);
|
||||
return;
|
||||
}
|
||||
var dialogBox = runtime.DialogBox;
|
||||
if (dialogBox == null)
|
||||
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);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -51,6 +51,13 @@ namespace Cthangover.Mods.Dialog
|
||||
/// <summary>The currently active dialog queue. Set by <see cref="SetDialogQueueAndRun"/>.</summary>
|
||||
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>
|
||||
public IActionCommand CurrentAction => GetByIndex(index);
|
||||
|
||||
@@ -71,6 +78,9 @@ namespace Cthangover.Mods.Dialog
|
||||
/// </summary>
|
||||
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>
|
||||
/// True while the debugger holds the runtime before <see cref="CurrentAction"/>:
|
||||
/// 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");
|
||||
}
|
||||
|
||||
|
||||
Reference in new issue
Block a user