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"); } } }