init mod
This commit is contained in:
commit
5de3fd7ad8
121 files changed
+6632
No files matched your search
@@ -0,0 +1,10 @@
|
||||
{
|
||||
"name": "Test Runner",
|
||||
"description": "Test harness mod: ports the integration DialogAutoDriver into an engine mod. Activated by --choices=/--timeout= user args; auto-plays the active dialog, prints [TEST_RESULT] and quits. Depends on visual_novel_core and dialog_base.",
|
||||
"sources": [
|
||||
"src/*.cs"
|
||||
],
|
||||
"depends": [
|
||||
"visual_novel_core>=1"
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,177 @@
|
||||
using System.Collections.Generic;
|
||||
using Cthangover.Core.UI.Dialog;
|
||||
using Cthangover.Core.Utils;
|
||||
using Godot;
|
||||
|
||||
namespace Mods.TestRunner
|
||||
{
|
||||
/// <summary>
|
||||
/// Мод-порт интеграционного <c>DialogAutoDriver</c>: автопроигрывает
|
||||
/// активный диалог, кликая <c>IDialogBox.NextAction()</c> каждые 0.1с и
|
||||
/// отвечая на выборы по индексам из <c>--choices=</c> (по порядку, после
|
||||
/// исчерпания — первый вариант). Тик реализован дочерним Godot
|
||||
/// <see cref="Timer"/>, а не override <c>_Process</c>: сборка мода
|
||||
/// компилируется Roslyn вне source-генератора Godot, поэтому виртуальные
|
||||
/// методы Node не диспетчеризуются, тогда как сигнал <c>Timer.Timeout</c>
|
||||
/// — это Godot-объект и работает из мод-сборок. DialogBox ищется лениво
|
||||
/// через корень дерева по имени «DialogBox» (контракт ядра
|
||||
/// <see cref="IDialogBox"/>, типы dialog_base не нужны). Завершение
|
||||
/// диалога и общий таймаут печатают строку
|
||||
/// <c>[TEST_RESULT] name=test_runner passed=N failed=M</c> — формат парсит
|
||||
/// tests/Cthangover.TestRunner (ResultParser), после чего процесс
|
||||
/// завершается с соответствующим кодом выхода. Как и в оригинале,
|
||||
/// <c>IsDialogEnded</c> истинно до загрузки первой очереди, поэтому
|
||||
/// end-обработка ждёт, пока диалог реально наблюдался запущенным, плюс
|
||||
/// стартовая пауза 0.5с на построение сцены.
|
||||
/// </summary>
|
||||
public partial class DialogAutoDriverNode : Node
|
||||
{
|
||||
/// <summary>Имя, под которым раннер атрибутирует результат прогона.</summary>
|
||||
private const string ResultName = "test_runner";
|
||||
|
||||
/// <summary>Пауза после старта, чтобы сцена успела построиться (как в SceneSwitcher).</summary>
|
||||
private const double StartDelaySeconds = 0.5;
|
||||
|
||||
/// <summary>Сколько бокс может быть найден, но не запущен, прежде чем считаем сцену без диалога.</summary>
|
||||
private const double FirstDialogGraceSeconds = 3.0;
|
||||
|
||||
/// <summary>Интервал автопрокликивания, сек (как в оригинальном драйвере).</summary>
|
||||
private const double AdvanceIntervalSeconds = 0.1;
|
||||
|
||||
private readonly List<int> choices;
|
||||
private readonly float? timeoutSeconds;
|
||||
|
||||
private IDialogBox dialogBox;
|
||||
private int choiceIndex;
|
||||
private double elapsedSeconds;
|
||||
private double dialogFoundAtSeconds;
|
||||
private bool hasSeenRunningDialog;
|
||||
private bool finished;
|
||||
|
||||
/// <summary>
|
||||
/// Создаёт драйвер; <paramref name="choices"/> — индексы вариантов по
|
||||
/// порядку появления, <paramref name="timeoutSeconds"/> — общий лимит
|
||||
/// прогона (null — без таймаута). Тикающий таймер-ребёнок ставится
|
||||
/// прямо в конструкторе (без override _EnterTree/_Ready — мод-сборка
|
||||
/// вне source-генератора Godot): WaitTime 0.1с с автостартом
|
||||
/// воспроизводит ритм оригинального драйвера, а сигнал
|
||||
/// <c>Timer.Timeout</c> — Godot-сигнал и работает из мод-сборок.
|
||||
/// </summary>
|
||||
public DialogAutoDriverNode(List<int> choices, float? timeoutSeconds)
|
||||
{
|
||||
this.choices = choices ?? new List<int>();
|
||||
this.timeoutSeconds = timeoutSeconds;
|
||||
|
||||
var timer = new Timer
|
||||
{
|
||||
Name = "TickTimer",
|
||||
WaitTime = AdvanceIntervalSeconds,
|
||||
Autostart = true,
|
||||
OneShot = false
|
||||
};
|
||||
AddChild(timer);
|
||||
timer.Timeout += Tick;
|
||||
}
|
||||
|
||||
/// <summary>Один тик автопроигрывания: ленивый поиск бокса, прокликивание, выборы, end/timeout-обработка.</summary>
|
||||
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 (dialogBox.IsDialogEnded && !waitingForFirstDialog)
|
||||
{
|
||||
GameLogger.Log("TEST", "DialogAutoDriverNode: dialog ended, quitting");
|
||||
Finish(1, 0, null);
|
||||
return;
|
||||
}
|
||||
|
||||
if (dialogBox.IsWaitingAnswer)
|
||||
{
|
||||
HandleSelection();
|
||||
return;
|
||||
}
|
||||
|
||||
dialogBox.NextAction();
|
||||
}
|
||||
|
||||
/// <summary>Выбирает показанный вариант по индексу из --choices= (вне диапазона — первый).</summary>
|
||||
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);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Ищет DialogBox через корень дерева: драйвер часто добавляется до
|
||||
/// появления диалоговой сцены, а каст к контракту ядра избавляет мод
|
||||
/// от ссылки на конкретную реализацию.
|
||||
/// </summary>
|
||||
private IDialogBox FindDialogBox()
|
||||
{
|
||||
var tree = GetTree();
|
||||
if (tree?.Root == null)
|
||||
return null;
|
||||
return tree.Root.FindChild("DialogBox", true, false) as IDialogBox;
|
||||
}
|
||||
|
||||
/// <summary>Печатает итоговую строку для раннера и завершает процесс (0 — успех, 1 — провал).</summary>
|
||||
private void Finish(int passed, int failed, string reason)
|
||||
{
|
||||
if (finished)
|
||||
return;
|
||||
finished = true;
|
||||
|
||||
if (reason != null)
|
||||
GameLogger.Log("TEST", $"DialogAutoDriverNode: {reason}");
|
||||
GameLogger.Log("TEST", $"[TEST_RESULT] name={ResultName} passed={passed} failed={failed}");
|
||||
GetTree().Quit(failed == 0 ? 0 : 1);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,94 @@
|
||||
using System.Collections.Generic;
|
||||
using System.Linq;
|
||||
using Cthangover.Core.Mods;
|
||||
using Cthangover.Core.Utils;
|
||||
using Godot;
|
||||
|
||||
namespace Mods.TestRunner
|
||||
{
|
||||
/// <summary>
|
||||
/// Точка входа тестового мода: переносит интеграционную обвязку диалогов
|
||||
/// (tests DialogAutoDriver + SceneSwitcher) в engine-мод. Активируется
|
||||
/// только при наличии пользовательских флагов <c>--choices=</c> или
|
||||
/// <c>--timeout=</c> (как <c>OS.GetCmdlineUserArgs()</c>; флаг <c>--scene=</c>
|
||||
/// мод не читает — навигацией по сценам управляет ядро). При активации
|
||||
/// создаёт <see cref="DialogAutoDriverNode"/> и добавляет его в корень
|
||||
/// дерева через deferred-вызов: <c>OnModResourcesReady</c> срабатывает до
|
||||
/// окончания построения сцены, прямой AddChild в этот момент отклоняется
|
||||
/// движком, а драйверу всё равно нужно дождаться диалога (та же задержка
|
||||
/// 0.5с, что у SceneSwitcher.ScheduleDriverAttach). Повторный
|
||||
/// <c>ModManager.Reload()</c> не должен плодить драйверы — защищено
|
||||
/// статическим флагом.
|
||||
/// </summary>
|
||||
public class TestRunnerInitializer : IModInitializer
|
||||
{
|
||||
private static bool driverQueued;
|
||||
|
||||
/// <summary>Инициализация per-mod не требуется — драйвер ставится на resources-ready.</summary>
|
||||
public void OnModLoaded(string modId) { }
|
||||
|
||||
/// <summary>
|
||||
/// Разбирает пользовательские аргументы командной строки и при
|
||||
/// активационном флаге ставит драйвер автопроигрывания в корень дерева.
|
||||
/// </summary>
|
||||
public void OnModResourcesReady()
|
||||
{
|
||||
if (driverQueued)
|
||||
return;
|
||||
|
||||
var args = OS.GetCmdlineUserArgs();
|
||||
var hasChoices = args.Any(a => a.StartsWith("--choices=", System.StringComparison.Ordinal));
|
||||
var hasTimeout = args.Any(a => a.StartsWith("--timeout=", System.StringComparison.Ordinal));
|
||||
if (!hasChoices && !hasTimeout)
|
||||
return;
|
||||
|
||||
var choices = ParseChoices(args);
|
||||
var timeout = ParseTimeout(args);
|
||||
|
||||
// Godot. квалифицирован: в dev-компиляции модов (ToolsModCompileTests)
|
||||
// в глобальном пространстве имён существует namespace Engine из тестовой сборки,
|
||||
// и неквалифицированное имя резолвится в него вместо Godot.Engine.
|
||||
var root = (Godot.Engine.GetMainLoop() as SceneTree)?.Root;
|
||||
if (root == null)
|
||||
{
|
||||
GameLogger.Log("TEST", "TestRunnerInitializer: tree root not available, driver not attached", LogLevel.Error);
|
||||
return;
|
||||
}
|
||||
|
||||
driverQueued = true;
|
||||
GameLogger.Log("TEST",
|
||||
$"TestRunnerInitializer: activating dialog auto-driver, choices=[{string.Join(",", choices)}], timeout={timeout?.ToString() ?? "none"}s");
|
||||
|
||||
// Deferred: сцена ещё строится; драйвер внутри сам выдерживает
|
||||
// стартовую паузу и лениво ищет DialogBox, поэтому порядок с
|
||||
// загрузкой сцены не важен.
|
||||
root.CallDeferred("add_child", new DialogAutoDriverNode(choices, timeout));
|
||||
}
|
||||
|
||||
private static List<int> ParseChoices(string[] args)
|
||||
{
|
||||
var raw = args
|
||||
.Where(a => a.StartsWith("--choices=", System.StringComparison.Ordinal))
|
||||
.Select(a => a.Substring("--choices=".Length))
|
||||
.FirstOrDefault();
|
||||
if (string.IsNullOrEmpty(raw))
|
||||
return new List<int>();
|
||||
|
||||
return raw.Split(',')
|
||||
.Select(s => int.TryParse(s.Trim(), out var n) ? n : -1)
|
||||
.Where(n => n >= 0)
|
||||
.ToList();
|
||||
}
|
||||
|
||||
private static float? ParseTimeout(string[] args)
|
||||
{
|
||||
var raw = args
|
||||
.Where(a => a.StartsWith("--timeout=", System.StringComparison.Ordinal))
|
||||
.Select(a => a.Substring("--timeout=".Length))
|
||||
.FirstOrDefault();
|
||||
return float.TryParse(raw, System.Globalization.CultureInfo.InvariantCulture, out var t) && t > 0
|
||||
? t
|
||||
: null;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,2 @@
|
||||
mod_dialog_base__gdscript_name = GDScript Integration Test Mod
|
||||
mod_dialog_base__gdscript_desc = Integration tests for GDScript mod actions: .gd load, compile, action registration and scenario execution.
|
||||
@@ -0,0 +1,2 @@
|
||||
mod_dialog_base__gdscript_name = GDScript-мод интеграционных тестов
|
||||
mod_dialog_base__gdscript_desc = Интеграционные тесты GDScript-действий модов: загрузка .gd, компиляция, регистрация действий и выполнение сценария.
|
||||
@@ -0,0 +1,13 @@
|
||||
{
|
||||
"name": "GDScript test",
|
||||
"description": "Integration test of GDScript mods: the suite scripts (actions, conditions, an item action, an initializer) go through .gd load, compile and registration; the driver checks every action is registered and evaluates the conditions. Mounted by --itest=gdscript.",
|
||||
"sources": [
|
||||
"src/*.cs"
|
||||
],
|
||||
"gd_sources": [
|
||||
"scripts/*.gd"
|
||||
],
|
||||
"depends": [
|
||||
"visual_novel_core>=1"
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
scene: home_kitchen
|
||||
priority: 8
|
||||
condition: true
|
||||
is_one_run: true
|
||||
---
|
||||
action gds.greet name=Always
|
||||
text "Fallback scene always runs."
|
||||
end
|
||||
@@ -0,0 +1,9 @@
|
||||
scene: home_kitchen
|
||||
priority: 7
|
||||
condition: gds.flag_equals("victory")
|
||||
is_one_run: true
|
||||
---
|
||||
set custom_flag victory
|
||||
action gds.inventory_demo item_id=health_potion
|
||||
text "GDScript condition with args and inventory action executed."
|
||||
end
|
||||
@@ -0,0 +1,9 @@
|
||||
scene: home_kitchen
|
||||
priority: 6
|
||||
condition: gds.is_rain
|
||||
is_one_run: true
|
||||
---
|
||||
set weather rain
|
||||
action gds.greet name=RainVisitor
|
||||
text "It's raining! GDScript condition gds.is_rain passed."
|
||||
end
|
||||
@@ -0,0 +1,10 @@
|
||||
scene: home_kitchen
|
||||
priority: 5
|
||||
condition: true
|
||||
is_one_run: true
|
||||
---
|
||||
set my_name Marao
|
||||
action gds.greet name=my_name
|
||||
action gds.quest_log quest_id=test_quest message=Scenario started from GDScript
|
||||
text "GDScript test actions executed. Check logs."
|
||||
end
|
||||
@@ -0,0 +1,21 @@
|
||||
extends RefCounted
|
||||
|
||||
# Custom battle action executor: deals triple damage based on the user's Attack.
|
||||
# ActionId must match the battle action ID in the character's JSON data.
|
||||
# The execute method receives plain dictionaries (action/user/target) and must
|
||||
# return a dictionary with any of: source_damage, source_defence, target_damage,
|
||||
# target_defence, result.
|
||||
|
||||
func get_action_id():
|
||||
return "gds.triple_attack"
|
||||
|
||||
func execute(action, user, target):
|
||||
var damage = user["attack_value"] * 3 - target["defence_value"]
|
||||
if damage < 1:
|
||||
damage = 1
|
||||
|
||||
return {
|
||||
"target_damage": damage,
|
||||
"target_defence": target["defence_value"],
|
||||
"result": true,
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
extends RefCounted
|
||||
|
||||
func get_name():
|
||||
return "gds.char_welcome"
|
||||
|
||||
func run(ctx):
|
||||
var char_type = ctx.GetParam("char_type")
|
||||
if char_type == "":
|
||||
ctx.LogWarning("GDS", "gds.char_welcome: missing char_type")
|
||||
return
|
||||
|
||||
ctx.Character.AddToParty(char_type)
|
||||
ctx.Character.SendNotification(char_type)
|
||||
ctx.Log("GDS", "Character '" + char_type + "' added to party via GDScript")
|
||||
@@ -0,0 +1,13 @@
|
||||
extends RefCounted
|
||||
|
||||
# Custom condition with arguments: checks if a flag equals the passed value
|
||||
# Usage in scenario: condition: gds.flag_equals("expected_value")
|
||||
|
||||
func get_condition_name():
|
||||
return "gds.flag_equals"
|
||||
|
||||
func evaluate(flags, args):
|
||||
var expected = args.get("0", "")
|
||||
if expected == "":
|
||||
return false
|
||||
return flags.get("custom_flag", "") == expected
|
||||
@@ -0,0 +1,11 @@
|
||||
extends RefCounted
|
||||
|
||||
# Custom condition: checks if the "weather" flag equals "rain"
|
||||
# Usage in scenario: condition: gds.is_rain
|
||||
# Or: condition: gds.is_rain && Quest.hasTag("active")
|
||||
|
||||
func get_condition_name():
|
||||
return "gds.is_rain"
|
||||
|
||||
func evaluate(flags, args):
|
||||
return flags.get("weather", "") == "rain"
|
||||
@@ -0,0 +1,10 @@
|
||||
extends RefCounted
|
||||
|
||||
func get_name():
|
||||
return "gds.greet"
|
||||
|
||||
func run(ctx):
|
||||
var name = ctx.GetParam("name")
|
||||
if name == "":
|
||||
name = "Stranger"
|
||||
ctx.Log("EVENT", "GDScript greet action: Hello, " + name + "!")
|
||||
@@ -0,0 +1,21 @@
|
||||
extends RefCounted
|
||||
|
||||
# Demonstrates inventory queries and mod presence checks from a scenario action
|
||||
# Usage in scenario DSL: action gds.inventory_demo
|
||||
|
||||
func get_name():
|
||||
return "gds.inventory_demo"
|
||||
|
||||
func run(ctx):
|
||||
var itemId = ctx.GetParam("item_id")
|
||||
|
||||
if ctx.Inventory.HasItem(itemId):
|
||||
var count = ctx.Inventory.CheckCount(itemId)
|
||||
ctx.Log("EVENT", "Inventory has " + itemId + " x" + str(count))
|
||||
else:
|
||||
ctx.Log("EVENT", "Inventory does NOT have " + itemId)
|
||||
|
||||
if ctx.ModRegistry.IsLoaded("core"):
|
||||
ctx.Log("EVENT", "Core mod is loaded")
|
||||
else:
|
||||
ctx.LogWarning("EVENT", "Core mod is NOT loaded")
|
||||
@@ -0,0 +1,16 @@
|
||||
extends RefCounted
|
||||
|
||||
# Custom item action: heals the character by a fixed amount when the item is used
|
||||
# ID must match the ItemAction field in the item's JSON data
|
||||
# The use(item) method receives a GDItem (with fields Id, Name, Description, Cost, ItemType)
|
||||
# Returns true on success
|
||||
|
||||
func get_item_action_id():
|
||||
return "gds.heal_item"
|
||||
|
||||
func use(item):
|
||||
print("[GDS_ITEM] Using item: " + item.Name + " (id=" + item.Id + ")")
|
||||
|
||||
# In a real mod, perform actual healing logic here (e.g. via player stats)
|
||||
# For demonstration, simply return success
|
||||
return true
|
||||
@@ -0,0 +1,9 @@
|
||||
extends RefCounted
|
||||
|
||||
func get_name():
|
||||
return "gds.light_reset"
|
||||
|
||||
func run(ctx):
|
||||
ctx.Lighting.ClearDepthMap()
|
||||
ctx.Lighting.SetUseTime(false)
|
||||
ctx.Log("GDS", "Lighting reset via GDScript")
|
||||
@@ -0,0 +1,12 @@
|
||||
extends RefCounted
|
||||
|
||||
func get_name():
|
||||
return "gds.quest_log"
|
||||
|
||||
func run(ctx):
|
||||
var quest_id = ctx.GetParam("quest_id")
|
||||
var message = ctx.GetParam("message")
|
||||
if quest_id == "":
|
||||
ctx.LogWarning("GDS", "gds.quest_log: missing quest_id param")
|
||||
return
|
||||
ctx.Log("QUEST", "[" + quest_id + "] " + message)
|
||||
@@ -0,0 +1,16 @@
|
||||
extends RefCounted
|
||||
|
||||
func get_name():
|
||||
return "gds.quest_setup"
|
||||
|
||||
func run(ctx):
|
||||
var quest_id = ctx.GetParam("quest_id")
|
||||
if quest_id == "":
|
||||
ctx.LogWarning("GDS", "gds.quest_setup: missing quest_id")
|
||||
return
|
||||
|
||||
if ctx.Quests.Exists(quest_id):
|
||||
ctx.Quests.SetStatus(quest_id, "Active")
|
||||
ctx.Quests.AddTag(quest_id, "gds_started")
|
||||
ctx.Quests.SendNotification(quest_id)
|
||||
ctx.Log("GDS", "Quest '" + quest_id + "' activated by GDScript")
|
||||
@@ -0,0 +1,7 @@
|
||||
extends RefCounted
|
||||
|
||||
func on_mod_loaded(mod_id):
|
||||
print("[GDS_TEST] on_mod_loaded: " + mod_id)
|
||||
|
||||
func on_mod_resources_ready():
|
||||
print("[GDS_TEST] on_mod_resources_ready: all mods loaded")
|
||||
@@ -0,0 +1,129 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Linq;
|
||||
using System.Text.RegularExpressions;
|
||||
using Cthangover.Core.Bootstrap;
|
||||
using Cthangover.Core.Factories.Impls;
|
||||
using Cthangover.Core.Mods;
|
||||
using Cthangover.Core.Scenes;
|
||||
using Cthangover.Core.Utils;
|
||||
using Godot;
|
||||
|
||||
namespace Mods.DialogBase.Tests
|
||||
{
|
||||
/// <summary>Entry point of the GDScript itest; the suite is mounted only with <c>--itest=gdscript</c>.</summary>
|
||||
public class GdscriptTestInitializer : IModInitializer
|
||||
{
|
||||
private static bool queued;
|
||||
|
||||
public void OnModLoaded(string modId) { }
|
||||
|
||||
public void OnModResourcesReady()
|
||||
{
|
||||
if (queued || !OS.GetCmdlineUserArgs().Contains("--itest=gdscript"))
|
||||
return;
|
||||
queued = true;
|
||||
GameLogger.Log("TEST", "GdscriptTest: activating");
|
||||
(Godot.Engine.GetMainLoop() as SceneTree)?.Root?.CallDeferred("add_child", new GdscriptTestDriverNode());
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Checks the GDScript mod pipeline end to end on the suite's own
|
||||
/// <c>scripts/*.gd</c>: every script with <c>get_name()</c> + <c>run()</c>
|
||||
/// is a registered scenario action, and every <c>get_condition_name()</c>
|
||||
/// script is a working condition — <c>gds.is_rain</c> and
|
||||
/// <c>gds.flag_equals("victory")</c> are evaluated through the same
|
||||
/// <see cref="ConditionParser"/> scenarios use, against flags that must
|
||||
/// make them true and false. Prints <c>[TEST_RESULT] name=gdscript</c>.
|
||||
/// </summary>
|
||||
public partial class GdscriptTestDriverNode : Node
|
||||
{
|
||||
private const string ResultName = "gdscript";
|
||||
private const string SuiteModId = "dialog_base__gdscript";
|
||||
private const double TimeoutSeconds = 60;
|
||||
private static readonly Regex ReturnedName = new(@"func\s+(get_name|get_condition_name)\s*\(\s*\)\s*:\s*\n\s*return\s+""([^""]+)""", RegexOptions.Compiled);
|
||||
|
||||
private int passed;
|
||||
private int failed;
|
||||
private double elapsed;
|
||||
private bool done;
|
||||
|
||||
public override void _Process(double delta)
|
||||
{
|
||||
if (done)
|
||||
return;
|
||||
elapsed += delta;
|
||||
if (elapsed > TimeoutSeconds)
|
||||
{
|
||||
Check("boot finished in time", false);
|
||||
Finish();
|
||||
return;
|
||||
}
|
||||
|
||||
if (!Boot.IsReady)
|
||||
return;
|
||||
|
||||
try
|
||||
{
|
||||
Run();
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Check($"no exception ({ex.GetType().Name}: {ex.Message})", false);
|
||||
}
|
||||
|
||||
Finish();
|
||||
}
|
||||
|
||||
private void Run()
|
||||
{
|
||||
var actions = new List<string>();
|
||||
var conditions = new List<string>();
|
||||
foreach (var file in ModManager.Instance.ListFiles(SuiteModId, "scripts").Where(f => f.EndsWith(".gd", StringComparison.Ordinal)))
|
||||
{
|
||||
var text = (ModManager.Instance.ReadFileText(SuiteModId, file) ?? string.Empty).Replace("\r\n", "\n");
|
||||
foreach (Match match in ReturnedName.Matches(text))
|
||||
{
|
||||
if (match.Groups[1].Value == "get_condition_name")
|
||||
conditions.Add(match.Groups[2].Value);
|
||||
else if (text.Contains("func run("))
|
||||
actions.Add(match.Groups[2].Value);
|
||||
}
|
||||
}
|
||||
|
||||
Check($"suite scripts found ({actions.Count} action(s), {conditions.Count} condition(s))", actions.Count > 0 && conditions.Count > 0);
|
||||
foreach (var name in actions)
|
||||
Check($"action '{name}' registered", ScenarioActionFactory.Instance.Get(name) != null);
|
||||
|
||||
Expect("gds.is_rain", new() { ["weather"] = "rain" }, true);
|
||||
Expect("gds.is_rain", new() { ["weather"] = "sun" }, false);
|
||||
Expect("gds.flag_equals(\"victory\")", new() { ["custom_flag"] = "victory" }, true);
|
||||
Expect("gds.flag_equals(\"victory\")", new() { ["custom_flag"] = "defeat" }, false);
|
||||
Expect("gds.is_rain && gds.flag_equals(\"victory\")", new() { ["weather"] = "rain", ["custom_flag"] = "victory" }, true);
|
||||
}
|
||||
|
||||
private void Expect(string condition, Dictionary<string, string> flags, bool expected)
|
||||
{
|
||||
var node = ConditionParser.Parse(condition);
|
||||
var actual = node != null && node.Evaluate(new EvaluationContext { Flags = flags });
|
||||
Check($"'{condition}' with {string.Join(",", flags.Select(f => $"{f.Key}={f.Value}"))} → {expected}" + (actual == expected ? string.Empty : $" (got {actual})"), actual == expected);
|
||||
}
|
||||
|
||||
private void Check(string what, bool ok)
|
||||
{
|
||||
if (ok)
|
||||
passed++;
|
||||
else
|
||||
failed++;
|
||||
GameLogger.Log("TEST", $" [{(ok ? "PASS" : "FAIL")}] {what}");
|
||||
}
|
||||
|
||||
private void Finish()
|
||||
{
|
||||
done = true;
|
||||
GameLogger.Log("TEST", $"[TEST_RESULT] name={ResultName} passed={passed} failed={failed}");
|
||||
GetTree().Quit(failed == 0 ? 0 : 1);
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user