using System.Collections.Generic;
using System.Linq;
using Cthangover.Core.Events;
using Cthangover.Core.Scenes;
using Cthangover.Core.Settings;
using Cthangover.Mods.Dialog.Action;
using Cthangover.Core.UI.Event;
using Cthangover.Mods.Dialog.Executable;
using Cthangover.Core.Utils;
using Cthangover.Core.UI;
using Cthangover.Mods.Interface.Base.Lists;
using Cthangover.Core.UI.Dialog;
using Cthangover.Core.UI.Executable;
using Cthangover.Core.Scenarios;
using Cthangover.Core.Interactive;
namespace Cthangover.Mods.Dialog
{
///
/// Dialog execution engine: manages the action queue with goto-based branching
/// and variable substitution. Run() iterates actions in a tight loop, breaking
/// only on wait points (WaitClick/Select/Time/Event). Text variables use the
/// "${name}" syntax and are resolved via GetVariable/SetVariable — values are
/// stored in a simple Dictionary, not a cascading scope, keeping lookups O(1).
/// GoTo searches the queue linearly by action ID and jumps the index; the
/// source action's destruct fires on jump. The isLastGoTo flag prevents double
/// advancement when a GoTo is the last action in its branch. End() processes
/// an optional cleanup queue (endDialogQueue) before tearing down, and raises
/// the dialog-end event via the SceneEventController for chaining.
/// Infinite loop protection caps at 100k iterations per Run call.
///
public class DialogRuntime
{
private List dialogQueue;
private List endDialogQueue;
private int goToActionIndex;
private int index;
private bool isLastGoTo;
private IDictionary variables = new Dictionary();
/// The dialog box instance this runtime drives. Set when a new queue is loaded.
public DialogBox DialogBox { get; private set; }
/// The currently active dialog queue. Set by .
public DialogQueue Dialog { get; set; }
/// 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);
/// True when the current action is waiting for a player choice ().
public bool IsWaitAnswer => IsWaitType(WaitType.WaitSelect);
/// True when the current action is waiting for a timer ().
public bool IsWaitTime => IsWaitType(WaitType.WaitTime);
/// True when the current action is waiting for an external event ().
public bool IsWaitEvent => IsWaitType(WaitType.WaitEvent);
/// 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)
{
variables.TryGetValue(name, out var val);
return val;
}
/// Stores a variable for later substitution via ${name} syntax. Overwrites existing values.
public void SetVariable(string name, string value)
{
variables[name] = value;
}
/// Scans for ${key} patterns and replaces them with stored variable values. Unresolved keys become "?".
public string ProcessText(string text)
{
if (string.IsNullOrWhiteSpace(text))
return null;
var vars = text.GetIncludesInQuotes("${", "}");
if (Lists.IsNotEmpty(vars))
foreach (var variable in vars)
{
if (!variables.TryGetValue(variable, out var val))
val = "?";
text = text.Replace("${" + variable + "}", val);
}
return text;
}
private bool IsWaitType(WaitType type)
{
if (Lists.IsEmpty(dialogQueue))
return false;
if (index < 0 || index >= dialogQueue.Count)
return false;
var action = dialogQueue[index];
return action != null && action.WaitType == type;
}
private IActionCommand GetByIndex(int idx)
{
if (Lists.IsEmpty(dialogQueue))
return null;
if (idx < 0 || idx >= dialogQueue.Count)
return null;
var action = dialogQueue[idx];
if (action != null && action.ConstructType == ConstructType.OnStartAction && !action.IsConstructed)
action.Construct();
return action;
}
///
/// Loads a new dialog queue and prepares for execution. Resets variables, sets the dialog box reference,
/// ends any previous queue (destructing its actions), copies the new queue, and constructs OnStartQueue actions.
/// Note: does NOT call Run() — the caller must call Run() separately.
///
public void SetDialogQueueAndRun(DialogBox dialogBox,
IEnumerable queue,
IEnumerable endQueue,
int startIndex = 0)
{
variables = new Dictionary();
DialogBox = dialogBox;
IsHeld = false;
this.endDialogQueue = endQueue == null ? null : endQueue.ToList();
if (Lists.IsNotEmpty(this.dialogQueue))
End();
this.dialogQueue = queue.ToList();
index = startIndex;
foreach (var action in this.dialogQueue)
if (action != null && action.ConstructType == ConstructType.OnStartQueue && !action.IsConstructed)
action.Construct();
}
///
/// Searches the queue linearly for an action with ID == and jumps the index there.
/// Destructs the source action before jumping. If not found, calls .
/// prevents the runtime from auto-advancing past the target when the GoTo is the last action in its branch.
///
public void TryGoTo(string actionID, bool lastGoTo = false)
{
goToActionIndex = index;
if (string.IsNullOrEmpty(actionID))
return;
for (var i = 0; i < dialogQueue.Count; i++)
{
var action = dialogQueue[i];
if (action?.ID == actionID)
{
index = i;
var actionGoTo = GetByIndex(goToActionIndex);
if (actionGoTo != null && actionGoTo.IsConstructed && actionGoTo.DestructType == DestructType.OnEndAction)
actionGoTo.Destruct();
this.isLastGoTo = lastGoTo;
return;
}
}
GameLogger.Log("DIALOG", "Dialog goto action not found!", LogLevel.Error);
End();
}
///
/// Main execution loop. Iterates through actions, calling on each and advancing
/// automatically for actions. Stops the loop on any wait-type action (WaitClick, WaitSelect,
/// WaitTime, WaitEvent) so the runtime yields control until the condition is satisfied. Protected against infinite loops
/// with a 100,000-iteration cap.
///
public void Run()
{
var iteration = 0;
for (;;)
{
var action = CurrentAction;
if (action == null)
{
GameLogger.Log("DIALOG", "Run: action is null, ending", LogLevel.Debug);
End();
return;
}
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)
{
GameLogger.Log("DIALOG", "Run: infinite loop protection (100k iterations)", LogLevel.Warning);
break;
}
switch (action.WaitType)
{
case WaitType.WaitClick:
case WaitType.WaitSelect:
case WaitType.WaitTime:
case WaitType.WaitEvent:
break;
case WaitType.NoWait:
Next();
continue;
}
break;
}
}
///
/// Advances the queue index by one. Destructs the current action if its is OnEndAction.
/// Processes any delayed-destruct targets registered by the current action. Calls if the queue is exhausted.
/// Respects — if a GoTo was the final action, the index is not incremented.
///
public void Next()
{
if (isLastGoTo)
{
GameLogger.Log("DIALOG", "Next: last goto, returning");
isLastGoTo = false;
return;
}
var action = CurrentAction;
if (action != null && action.IsConstructed && action.DestructType == DestructType.OnEndAction)
action.Destruct();
if (action != null && Lists.IsNotEmpty(dialogQueue) && Lists.IsNotEmpty(action.DelayedDestruct))
foreach (var item in dialogQueue)
if (action.DelayedDestruct.Contains(item.ID))
item.Destruct();
if (Lists.IsEmpty(dialogQueue) || index < 0 || index >= dialogQueue.Count)
{
GameLogger.Log("DIALOG", "Next: queue exhausted, ending");
End();
}
index++;
}
///
/// Terminates the dialog queue. Destructs all remaining constructed actions, hides the dialog box, processes the
/// end-dialog cleanup queue (if any), and raises the dialog-end event via .
/// Guards against end-queue actions starting a new dialog mid-cleanup by checking if the queue changed.
///
public void End()
{
GameLogger.Log("DIALOG", $"End: {dialogQueue?.Count ?? 0} actions in queue, {(endDialogQueue?.Count ?? 0)} end actions");
if (Lists.IsNotEmpty(dialogQueue))
{
foreach (var action in dialogQueue)
if (action != null && action.IsConstructed)
action.Destruct();
dialogQueue.Clear();
}
ExecutableEvent locker = null;
if (DialogBox != null)
{
locker = DialogBox.Locker;
TrackOneRunCompletion(locker);
DialogBox.Locker = null;
DialogBox.Hide();
}
if (Lists.IsNotEmpty(endDialogQueue))
{
GameLogger.Log("DIALOG", "End: processing end actions...");
var oldQueue = dialogQueue;
foreach (var action in endDialogQueue)
{
if (action == null || action.IsDestructed)
continue;
if (!action.IsConstructed) action.Construct();
action.Run(this);
action.Destruct();
}
endDialogQueue.Clear();
if (dialogQueue != oldQueue)
{
GameLogger.Log("DIALOG", "End: new dialog started during end actions, skipping cleanup", LogLevel.Warning);
return;
}
}
dialogQueue = null;
endDialogQueue = null;
index = 0;
if (DialogBox != null)
{
ModEventBus.Publish(new DialogEndedEvent { LockerId = locker?.ID });
}
}
///
/// 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
/// scenario runner never re-triggers it on later visits to the
/// scene or after save/load. Only with
/// an explicit ScenarioId is tracked: its ID matches the scenario
/// definition name used by the runner's filters, whereas generic
/// lockers (e.g. the ones flagged OneRun by ActionSwitchScene) carry
/// type-name IDs that would poison the history.
///
private static void TrackOneRunCompletion(ExecutableEvent locker)
{
if (!(locker is ScenarioEvent scenario) || !scenario.IsOneRun || string.IsNullOrEmpty(scenario.ScenarioId))
return;
var runtime = GameData.Instance?.Runtime;
if (runtime != null && runtime.CompletedScenarioIds.Add(scenario.ID))
GameLogger.Log("DIALOG", $"One-run scenario '{scenario.ID}' marked as completed");
}
}
}