using System.Collections.Generic; using Cthangover.Core.Events; using Cthangover.Core.UI.Dialog; using Cthangover.Core.Utils; using Godot; namespace Mods.TestRunner { /// /// Мод-порт интеграционного DialogAutoDriver: автопроигрывает /// активный диалог, кликая IDialogBox.NextAction() каждые 0.1с и /// отвечая на выборы по индексам из --choices= (по порядку, после /// исчерпания — первый вариант). Тик реализован дочерним Godot /// , а не override _Process: сборка мода /// компилируется Roslyn вне source-генератора Godot, поэтому виртуальные /// методы Node не диспетчеризуются, тогда как сигнал Timer.Timeout /// — это Godot-объект и работает из мод-сборок. DialogBox ищется лениво /// через корень дерева по имени «DialogBox» (контракт ядра /// , типы dialog_base не нужны). Завершение /// диалога и общий таймаут печатают строку /// [TEST_RESULT] name=test_runner passed=N failed=M — формат парсит /// tests/Cthangover.TestRunner (ResultParser), после чего процесс /// завершается с соответствующим кодом выхода. Как и в оригинале, /// IsDialogEnded истинно до загрузки первой очереди, поэтому /// end-обработка ждёт, пока диалог реально наблюдался запущенным, плюс /// стартовая пауза 0.5с на построение сцены. /// With --dialogs=N the run passes after N s /// instead: hub games never go idle, because the next scene's dialog starts /// in the same frame the previous one ends. /// public partial class DialogAutoDriverNode : Node { /// Имя, под которым раннер атрибутирует результат прогона. private const string ResultName = "test_runner"; /// Пауза после старта, чтобы сцена успела построиться (как в SceneSwitcher). private const double StartDelaySeconds = 0.5; /// Сколько бокс может быть найден, но не запущен, прежде чем считаем сцену без диалога. private const double FirstDialogGraceSeconds = 3.0; /// Интервал автопрокликивания, сек (как в оригинальном драйвере). private const double AdvanceIntervalSeconds = 0.1; private readonly List choices; private readonly float? timeoutSeconds; private readonly int? dialogsTarget; private IDialogBox dialogBox; private int choiceIndex; private double elapsedSeconds; private double dialogFoundAtSeconds; private bool hasSeenRunningDialog; private bool finished; private int endedDialogs; /// /// Создаёт драйвер; — индексы вариантов по /// порядку появления, — общий лимит /// прогона (null — без таймаута). Тикающий таймер-ребёнок ставится /// прямо в конструкторе (без override _EnterTree/_Ready — мод-сборка /// вне source-генератора Godot): WaitTime 0.1с с автостартом /// воспроизводит ритм оригинального драйвера, а сигнал /// Timer.Timeout — Godot-сигнал и работает из мод-сборок. /// — number of finished dialogs that passes /// the run (null — pass when the box goes idle). /// public DialogAutoDriverNode(List choices, float? timeoutSeconds, int? dialogsTarget) { this.choices = choices ?? new List(); this.timeoutSeconds = timeoutSeconds; this.dialogsTarget = dialogsTarget; if (dialogsTarget.HasValue) ModEventBus.Subscribe(OnDialogEnded); var timer = new Timer { Name = "TickTimer", WaitTime = AdvanceIntervalSeconds, Autostart = true, OneShot = false }; AddChild(timer); timer.Timeout += Tick; } /// Один тик автопроигрывания: ленивый поиск бокса, прокликивание, выборы, end/timeout-обработка. private void Tick() { if (finished) return; elapsedSeconds += AdvanceIntervalSeconds; if (timeoutSeconds.HasValue && elapsedSeconds >= timeoutSeconds.Value) { Finish(0, 1, $"timeout ({timeoutSeconds.Value}s) reached"); return; } if (elapsedSeconds < StartDelaySeconds) return; if (dialogBox is not GodotObject liveBox || !GodotObject.IsInstanceValid(liveBox) || liveBox is not Node liveNode || !liveNode.IsInsideTree()) { dialogBox = FindDialogBox(); if (dialogBox == null) return; dialogFoundAtSeconds = elapsedSeconds; hasSeenRunningDialog = false; var choicesInfo = choices.Count > 0 ? "choices=[" + string.Join(",", choices) + "]" : "no choices, will pick first variant each time"; GameLogger.Log("TEST", $"DialogAutoDriverNode started, interval={AdvanceIntervalSeconds}, {choicesInfo}"); } if (dialogBox.IsRunning) hasSeenRunningDialog = true; // IsDialogEnded истинно и до первой очереди — ждём наблюдения // запущенного диалога плюс льготный период на пустых сценах. var waitingForFirstDialog = !hasSeenRunningDialog && elapsedSeconds - dialogFoundAtSeconds < FirstDialogGraceSeconds; if (!dialogsTarget.HasValue && dialogBox.IsDialogEnded && !waitingForFirstDialog) { GameLogger.Log("TEST", "DialogAutoDriverNode: dialog ended, quitting"); Finish(1, 0, null); return; } if (dialogBox.IsWaitingAnswer) { HandleSelection(); return; } dialogBox.NextAction(); } /// Counts finished dialogs and passes the run once the --dialogs= target is reached. private void OnDialogEnded(DialogEndedEvent evt) { endedDialogs++; GameLogger.Log("TEST", $"DialogAutoDriverNode: dialog #{endedDialogs} ended"); if (endedDialogs >= dialogsTarget.Value) Finish(1, 0, $"{endedDialogs} dialog(s) ended, quitting"); } /// Выбирает показанный вариант по индексу из --choices= (вне диапазона — первый). private void HandleSelection() { var count = dialogBox.AnswerVariantCount; if (count <= 0) return; var pick = 0; if (choiceIndex < choices.Count) { pick = choices[choiceIndex]; if (pick >= count) pick = 0; choiceIndex++; } GameLogger.Log("TEST", $"DialogAutoDriverNode: choice #{choiceIndex} picking index={pick} out of {count}"); dialogBox.TrySelectVariant(pick); } /// /// Ищет DialogBox через корень дерева: драйвер часто добавляется до /// появления диалоговой сцены, а каст к контракту ядра избавляет мод /// от ссылки на конкретную реализацию. /// private IDialogBox FindDialogBox() { var tree = GetTree(); if (tree?.Root == null) return null; return tree.Root.FindChild("DialogBox", true, false) as IDialogBox; } /// Печатает итоговую строку для раннера и завершает процесс (0 — успех, 1 — провал). private void Finish(int passed, int failed, string reason) { if (finished) return; finished = true; if (dialogsTarget.HasValue) ModEventBus.Unsubscribe(OnDialogEnded); if (reason != null) GameLogger.Log("TEST", $"DialogAutoDriverNode: {reason}"); GameLogger.Log("TEST", $"[TEST_RESULT] name={ResultName} passed={passed} failed={failed}"); Cthangover.Core.Bootstrap.ApplicationExit.Quit(GetTree(), failed == 0 ? 0 : 1); } } }