using System.Collections.Generic; using Cthangover.Core.Events; using Cthangover.Mods.Dialog; using Cthangover.Core.Scenes; using Cthangover.Core.Utils; using Godot; 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.Executable { /// /// The primary event chain for a scene: picks the next ExecutableEvent to run /// in the dialog box. Event-driven — there is no per-frame work. A scan is /// requested when the condition state changes (), /// when a dialog ends (), when an event is added, /// when the chain becomes ready and when it is re-activated. Requests are /// coalesced into one deferred scan at the end of the frame: a burst of flag /// changes costs one pass, the state is read after the change completed (some /// publishers raise their event before storing the value), and a scan never /// runs inside the dialog runtime that ended a queue. /// /// On _Ready the chain collects child ExecutableEvent nodes and replays a /// pending scene switch (pending scene name from SceneManager). Exposes /// LastBackgroundID so action commands can remember which background was /// active for scene restore on revisit. AddEvent/RemoveEvent/ClearEvents allow /// dynamic event management via code. Events are checked in insertion order; /// the first satisfying both IsOneRun and CheckConditions gets executed. /// /// Self-loop guard: the chain remembers, per event, the condition version at /// the moment its dialog ended, and does not run that event again until the /// state changed after it. An event's own state changes (made during its /// dialog) therefore never re-trigger it, A→B chaining still works, and two /// repeatable events cannot ping-pong A→B→A while nothing changes. /// public partial class ExecutableMainEventChain : Control, IExecutableEventChain { private bool isActive = true; private List allEvents = new(); private DialogBox dialogBox; // Condition version at the end of each event's last dialog run by this chain. private readonly Dictionary endVersions = new(); // The event whose dialog the chain started and is waiting to end. private ExecutableEvent running; // A deferred scan is already queued for this frame. private bool scanQueued; /// /// Stores the last background ID set by action commands (e.g. "bg_home"). /// Used on scene revisit to restore the previously active background. /// public string LastBackgroundID { get; set; } /// /// Enables or disables event dispatch. While false, scans do nothing; /// switching back to true requests a scan. /// public bool IsActive { get => isActive; set { isActive = value; if (value) RequestScan(); } } /// /// Removes and frees all currently registered events from the chain via /// QueueFree and empties the internal event list. /// public void ClearEvents() { GameLogger.Log("CHAIN", $"ClearEvents: removing {allEvents.Count} event(s)"); foreach (var evt in allEvents) { var id = evt is ScenarioEvent se ? se.Condition ?? "no_condition" : evt.GetType().Name; GameLogger.Log("CHAIN", $" QueueFree: {evt.GetType().Name} [condition={id}]"); evt.QueueFree(); } allEvents.Clear(); endVersions.Clear(); running = null; } /// /// Adds an to the chain and reparents it via /// AddChild. Events are dispatched in insertion order; adding one /// requests a scan. /// /// The event to register and parent to this chain. public void AddEvent(IExecutableEvent evt) { var typed = (ExecutableEvent)evt; var id = typed is ScenarioEvent se ? se.Condition ?? "no_condition" : typed.GetType().Name; GameLogger.Log("CHAIN", $"AddEvent: {typed.GetType().Name} [condition={id}] — calling AddChild"); AddChild(typed); allEvents.Add(typed); RequestScan(); GameLogger.Log("CHAIN", $" AddEvent: done, allEvents.Count={allEvents.Count}"); } /// /// Removes an event from the chain and queues it for deletion. No-op if /// the event is not currently registered. /// /// The event to unregister and free. public void RemoveEvent(ExecutableEvent evt) { if (allEvents.Remove(evt)) { endVersions.Remove(evt); if (running == evt) running = null; evt.QueueFree(); GameLogger.Log("CHAIN", $"removed event '{evt.GetType().Name}'"); } } /// Finds the shared , subscribes to the scan triggers, collects child events, publishes the shell-ready event, replays any pending scene switch and requests the first scan. public override void _Ready() { GameLogger.Log("CHAIN", $"_Ready: starting, tree scene='{GetTree()?.CurrentScene?.Name}'"); dialogBox = SceneContextNode.FindNode("DialogBox"); ModEventBus.Subscribe(OnDialogEnded); ModEventBus.Subscribe(OnConditionStateChanged); foreach (var child in GetChildren()) { if (child is ExecutableEvent evt) { allEvents.Add(evt); GameLogger.Log("CHAIN", $"collected event '{evt.GetType().Name}'"); } } ModEventBus.Publish(new ShellReadyEvent { Root = this }); var sceneManager = GetNodeOrNull("/root/SceneManager"); if (sceneManager != null && !string.IsNullOrEmpty(sceneManager.PendingSceneName)) { var pending = sceneManager.PendingSceneName; GameLogger.Log("CHAIN", $"_Ready: deferring SwitchScene('{pending}')"); sceneManager.PendingSceneName = null; Callable.From(() => sceneManager.SwitchScene(pending)).CallDeferred(); } RequestScan(); GameLogger.Log("CHAIN", $"_Ready: done, allEvents={allEvents.Count}"); } /// Detaches the scan triggers when the chain leaves the tree. public override void _ExitTree() { ModEventBus.Unsubscribe(OnDialogEnded); ModEventBus.Unsubscribe(OnConditionStateChanged); } /// /// Called when any dialog queue finishes. For the chain's own running event /// records the version at its dialog end, so the state changes it produced /// are accounted for (self-loop guard); then requests a scan so the next /// event starts right away. /// private void OnDialogEnded(DialogEndedEvent evt) { if (running != null && evt.LockerId == running.ID) { endVersions[running] = ConditionStateMonitor.Version; running = null; } RequestScan(); } private void OnConditionStateChanged(ConditionStateChangedEvent evt) { RequestScan(); } /// /// Asks for a scan at the end of the frame. Any number of requests within /// one frame result in a single scan. /// public void RequestScan() { if (scanQueued) return; scanQueued = true; Callable.From(Scan).CallDeferred(); } /// Starts the first runnable event, unless the chain is inactive or the dialog box is busy or frozen (a dialog end or a state change asks again). private void Scan() { scanQueued = false; if (!IsInstanceValid(this) || !IsInsideTree() || !isActive) return; // Shell rebuilds after battles may make the early _Ready lookup miss. // Resolve again on the deferred scan, after SceneManager clears scene caches. if (dialogBox == null || !IsInstanceValid(dialogBox)) dialogBox = SceneContextNode.FindNode("DialogBox"); if (dialogBox == null || !IsInstanceValid(dialogBox) || !dialogBox.IsActive || dialogBox.IsRunning) return; var dialog = FindEvent(); if (dialog == null) return; if (dialog.RunDialog()) running = dialog; } /// /// true if the chain has any registered events remaining. Used to /// check whether a scene still has pending dialog content. /// public bool HasEvents => allEvents.Count > 0; /// /// Deactivates the chain, preventing any further event dispatch until /// reactivation. /// public void Stop() { isActive = false; } private ExecutableEvent FindEvent() { if (Lists.IsEmpty(allEvents)) return null; var version = ConditionStateMonitor.Version; foreach (var nextEvent in allEvents) { if (endVersions.TryGetValue(nextEvent, out var endVersion) && version <= endVersion) continue; if (nextEvent.IsOneRun && !nextEvent.IsFirstRun) continue; if (nextEvent.CheckConditions) return nextEvent; } return null; } } }