Scene editor for logical scenes; scenario editor Save refreshes the running game; novel_flow itest; mod cover

This commit is contained in:
cthangover committed 2026-10-22 17:09:38 +02:00
1 parent 2b8eaa5452
commit 972df5ccff
17 files changed
+1692 -3

No files matched your search

+10
View File
@@ -0,0 +1,10 @@
{
"name": "Novel Flow Test",
"description": "End-to-end authoring test on the novel template with the editors added, using only the editors' UI: opens the scene editor from the in-game toolbar, creates a new scene with a background, writes its scenario (a way back to the town) in the scenario editor, adds a way to it to the town's scenario, goes to the town from the scene editor and walks town -> new scene -> town through the real dialog choices. Restores the game's files at the end. Activated by --itest=novel_flow; --shots=<dir> saves screenshots (windowed runs only).",
"sources": [
"src/*.cs"
],
"depends": [
"my_novel_game>=1"
]
}
@@ -0,0 +1,359 @@
using System.Collections.Generic;
using System.IO;
using System.Linq;
using Cthangover.Core.Bootstrap;
using Cthangover.Core.Scenes;
using Cthangover.Core.UI.Dialog;
using Cthangover.Core.UI.Tool.ScenarioEditor;
using Cthangover.Core.Utils;
using Cthangover.Mods.Dialog;
using Cthangover.Tools.Common;
using Cthangover.Tools.SceneEditor;
using Cthangover.Tools.Services;
using Godot;
namespace Cthangover.Tools.Tests.NovelFlow
{
/// <summary>
/// An author's session in the running novel template, through the editors' UI only:
/// <list type="number">
/// <item>the toolbar's tools menu opens the scene editor;</item>
/// <item>New scene, a name, the game's mod, a background, Save;</item>
/// <item>New scenario of that scene opens the scenario editor; the scenario (a way back to
/// the town) is typed in and saved;</item>
/// <item>the town is picked in the scene editor, its scenario opened and a way to the new
/// scene typed in and saved;</item>
/// <item>Go to scene puts the game into the town, and the player walks town → new scene →
/// town by clicking the dialog's answers.</item>
/// </list>
/// Buttons, list rows, answers and text edits get the signals a mouse or keyboard would
/// raise. The game's files are restored at the end (town scenario text, new files deleted).
/// Result line: <c>[TEST_RESULT] name=novel_flow passed=N failed=M</c>, 150 s watchdog.
/// </summary>
public partial class NovelFlowTestDriver : Node
{
private const float TickSeconds = 0.3f;
private const float TimeoutSeconds = 150f;
private const string Mod = "my_novel_game";
private const string Town = "my_novel_town";
private const string Park = "my_novel_park";
private const string TownScenario = "scenarios/my_novel_town.scenario";
private const string ParkScenario = "scenarios/my_novel_park.scenario";
private const string ToPark = "Go to the park";
private const string ToTown = "Back to the town";
// The town scenario before the test edits it; survives a run that is killed midway
// (the next run restores it first, so an interrupted run never leaves the park behind).
private const string Backup = "user://novel_flow_town.scenario.bak";
private const string ParkText =
"scene: " + Park + "\npriority: 10\n---\n" +
"text \"A quiet park behind the town. Wet leaves, an empty bench.\"\n" +
"select \"What next?\"\n" +
"option \"" + ToTown + "\" -> :town\n\n" +
":town\nswitch_scene " + Town + "\nend\n";
private readonly string shotsDir;
private readonly List<string> failures = new();
private int passed;
private int step;
private int wait;
private double elapsed;
private string townOriginal;
private SceneEditorWindow sceneEditor;
/// <summary>Creates the driver; <paramref name="shotsDir"/> null disables screenshots.</summary>
public NovelFlowTestDriver(string shotsDir)
{
this.shotsDir = shotsDir;
Name = "NovelFlowTestDriver";
var timer = new Timer { WaitTime = TickSeconds, Autostart = true };
AddChild(timer);
timer.Timeout += Tick;
}
private SceneManager Scenes => GetTree().Root.GetNodeOrNull<SceneManager>("SceneManager");
private void Tick()
{
if (step < 0)
return;
elapsed += TickSeconds;
if (elapsed > TimeoutSeconds)
{
Fail($"timeout at step {step}");
Finish();
return;
}
try
{
RunStep();
}
catch (System.Exception ex)
{
Fail($"step {step}: {ex.GetType().Name}: {ex.Message}");
Finish();
}
}
private void RunStep()
{
switch (step)
{
case 0:
if (!Boot.IsReady || Scenes?.CurrentSceneName != Town || GodotSceneService.IsTransitioning)
return;
RestoreFromBackup();
townOriginal = ModFileService.ReadText(Mod, TownScenario);
File.WriteAllText(ProjectSettings.GlobalizePath(Backup), townOriginal ?? string.Empty);
DeleteParkFiles();
SceneFileService.RefreshGame();
Check(townOriginal != null && !Scenes.HasScene(Park), "the novel runs in the town, no park yet");
OpenToolFromToolbar("tools/scene_editor/title");
Next();
return;
case 1:
sceneEditor = Windows<SceneEditorWindow>().FirstOrDefault();
if (sceneEditor == null)
return;
Check(true, "the toolbar's tools menu opens the scene editor");
Press(sceneEditor, "tools/scene_editor/new_scene");
var name = sceneEditor.FindChildren("*", "", true, false).OfType<LineEdit>().First(e => e.PlaceholderText == "my_scene");
name.Text = Park;
name.EmitSignal(LineEdit.SignalName.TextChanged, Park);
var mods = sceneEditor.FindChildren("*", "", true, false).OfType<OptionButton>()
.First(o => Enumerable.Range(0, o.ItemCount).Any(i => o.GetItemText(i) == Mod));
var modIndex = Enumerable.Range(0, mods.ItemCount).First(i => mods.GetItemText(i) == Mod);
mods.Select(modIndex);
mods.EmitSignal(OptionButton.SignalName.ItemSelected, modIndex);
var picker = Lists(sceneEditor)[1];
var background = Row(picker, "home/vestibule") >= 0 ? Row(picker, "home/vestibule") : 0;
picker.EmitSignal(ItemList.SignalName.ItemActivated, background);
Next();
return;
case 2:
Shot("1_new_scene");
Press(sceneEditor, "tools/common/save");
Check(Scenes.HasScene(Park), "Save in the scene editor: the game knows the new scene at once");
Check(File.Exists(ModFileService.GetWritablePath(Mod, "scenes/" + Park + ".json")), "the scene file is in the game's mod");
Press(sceneEditor, "tools/scene_editor/new_scenario");
Next();
return;
case 3:
var parkEditor = EditorWith("scene: " + Park);
if (parkEditor == null)
return;
Check(true, "New scenario opens the scenario editor on the scene's new scenario");
Type(parkEditor, ParkText);
Press(parkEditor, "tools/common/save");
Check(ModFileService.ReadText(Mod, ParkScenario)?.Contains(ToTown) == true, "the park scenario is saved with the way back");
Next();
return;
case 4:
// A tick after typing, so the frame shows the new text.
Shot("2_park_scenario");
PickScene(Town);
Next();
return;
case 5:
var rows = sceneEditor.FindChildren("*", "", true, false).OfType<ItemList>().Last();
var townRow = Row(rows, Mod + ": " + TownScenario);
Check(townRow >= 0, "the town's scenario is listed under the town in the scene editor");
rows.Select(townRow);
rows.EmitSignal(ItemList.SignalName.ItemActivated, townRow);
Next();
return;
case 6:
var townEditor = EditorWith("scene: " + Town);
if (townEditor == null)
return;
var text = Code(townEditor).Text;
text = text.Replace("option \"Go back home\"", "option \"" + ToPark + "\" -> :park\noption \"Go back home\"")
+ "\n:park\nswitch_scene " + Park + "\nend\n";
Type(townEditor, text);
Press(townEditor, "tools/common/save");
Check(ModFileService.ReadText(Mod, TownScenario)?.Contains("switch_scene " + Park) == true, "the town scenario is saved with the way to the park");
Next();
return;
case 7:
Shot("3_town_scenario");
Press(sceneEditor, "tools/scene_editor/go");
Next();
return;
case 8:
if (++wait < 3 || GodotSceneService.IsTransitioning)
return;
foreach (var window in GetTree().Root.GetChildren().OfType<Window>().ToList())
window.EmitSignal(Window.SignalName.CloseRequested);
Next();
return;
case 9:
if (!Answer(ToPark))
return;
Shot("4_town_choice");
Click(ToPark);
Next();
return;
case 10:
if (Scenes.CurrentSceneName != Park || GodotSceneService.IsTransitioning || !Answer(ToTown))
return;
Check(true, "choosing the new answer in the town leads into the park");
Shot("5_park");
Click(ToTown);
Next();
return;
case 11:
if (Scenes.CurrentSceneName != Town || GodotSceneService.IsTransitioning || !Answer(ToPark))
return;
Check(true, "the park's answer leads back to the town, which offers the park again");
Shot("6_back_in_town");
Finish();
return;
}
}
// ── the author's hands ─────────────────────────────────────
/// <summary>The toolbar's tools button opens its menu; the tool is picked by its title.</summary>
private void OpenToolFromToolbar(string titleKey)
{
var menuTitle = TranslationServer.Translate("tools/menu_title").ToString();
var button = GetTree().Root.FindChildren("*", "", true, false).OfType<Button>().First(b => b.TooltipText == menuTitle);
button.EmitSignal(BaseButton.SignalName.Pressed);
var menu = button.GetChildren().OfType<PopupMenu>().First();
var title = TranslationServer.Translate(titleKey).ToString();
var index = Enumerable.Range(0, menu.ItemCount).First(i => menu.GetItemText(i) == title);
menu.EmitSignal(PopupMenu.SignalName.IndexPressed, index);
}
private void PickScene(string name)
{
var list = Lists(sceneEditor)[0];
var row = Enumerable.Range(0, list.ItemCount).First(i => list.GetItemTooltip(i) == name);
list.Select(row);
list.EmitSignal(ItemList.SignalName.ItemSelected, row);
}
private static void Type(ScenarioEditorWindow editor, string text)
{
var code = Code(editor);
code.Text = text;
code.EmitSignal(TextEdit.SignalName.TextChanged);
}
private void Press(Node root, string key)
{
var text = TranslationServer.Translate(key).ToString();
var button = root.FindChildren("*", "", true, false).OfType<Button>().FirstOrDefault(b => b.Text == text && b.IsVisibleInTree());
Check(button != null && !button.Disabled, $"button '{text}' is there and enabled");
button?.EmitSignal(BaseButton.SignalName.Pressed);
}
/// <summary>True when the dialog shows an answer with this text (advances the text before it).</summary>
private bool Answer(string text)
{
if (AnswerItems().Any(a => a.Model?.Text == text))
return true;
if (++wait % 10 == 0)
GameLogger.Log("TEST", $"waiting for answer '{text}' in '{Scenes?.CurrentSceneName}', shown: [{string.Join(", ", AnswerItems().Select(a => a.Model?.Text))}]");
var box = SceneContextNode.FindNode<Node>("DialogBox") as IDialogBox;
if (box != null && box.IsRunning && !box.IsWaitingAnswer)
box.NextAction();
return false;
}
private void Click(string text)
{
var item = AnswerItems().First(a => a.Model?.Text == text);
item.GetNode<Button>("Button").EmitSignal(BaseButton.SignalName.Pressed);
}
private IEnumerable<AnswerItem> AnswerItems() =>
GetTree().Root.FindChildren("*", "", true, false).OfType<AnswerItem>().Where(a => a.IsVisibleInTree());
// ── finding things ─────────────────────────────────────────
private IEnumerable<T> Windows<T>() where T : Window =>
GetTree().Root.GetChildren().OfType<T>().Where(w => !w.IsQueuedForDeletion());
private ScenarioEditorWindow EditorWith(string header) =>
Windows<ScenarioEditorWindow>().FirstOrDefault(w => Code(w)?.Text.StartsWith(header) == true);
private static CodeEdit Code(Node editor) =>
editor.FindChildren("*", "", true, false).OfType<CodeEdit>().FirstOrDefault(c => c.IsVisibleInTree());
private static List<ItemList> Lists(Node root) =>
root.FindChildren("*", "", true, false).OfType<FilteredList>()
.Select(f => f.FindChildren("*", "", true, false).OfType<ItemList>().First()).ToList();
private static int Row(ItemList list, string text) =>
Enumerable.Range(0, list.ItemCount).FirstOrDefault(i => list.GetItemText(i) == text, -1);
// ── bookkeeping ────────────────────────────────────────────
private static void RestoreFromBackup()
{
var path = ProjectSettings.GlobalizePath(Backup);
if (!File.Exists(path))
return;
ModFileService.WriteText(Mod, TownScenario, File.ReadAllText(path));
File.Delete(path);
GameLogger.Log("TEST", "novel_flow: restored the town scenario left by an interrupted run");
}
private void DeleteParkFiles()
{
foreach (var file in new[] { "scenes/" + Park + ".json", ParkScenario })
{
var path = ModFileService.GetWritablePath(Mod, file);
if (path != null && File.Exists(path))
File.Delete(path);
}
}
private void Next()
{
step++;
wait = 0;
}
private void Check(bool condition, string what)
{
if (condition)
{
passed++;
GameLogger.Log("TEST", $"[PASS] {what}");
}
else
{
Fail(what);
}
}
private void Fail(string what)
{
failures.Add(what);
GameLogger.Log("TEST", $"[FAIL] {what}", LogLevel.Error);
}
private void Shot(string name)
{
if (string.IsNullOrEmpty(shotsDir) || DisplayServer.GetName() == "headless")
return;
DirAccess.MakeDirRecursiveAbsolute(shotsDir);
GetViewport().GetTexture().GetImage()?.SavePng(shotsDir.TrimEnd('/') + "/novel_flow_" + name + ".png");
}
private void Finish()
{
step = -1;
if (townOriginal != null)
ModFileService.WriteText(Mod, TownScenario, townOriginal);
File.Delete(ProjectSettings.GlobalizePath(Backup));
DeleteParkFiles();
SceneFileService.RefreshGame();
GameLogger.Log("TEST", $"[TEST_RESULT] name=novel_flow passed={passed} failed={failures.Count}");
GetTree().Quit(failures.Count == 0 ? 0 : 1);
}
}
}
@@ -0,0 +1,37 @@
using System.Linq;
using Cthangover.Core.Mods;
using Cthangover.Core.Utils;
using Godot;
namespace Cthangover.Tools.Tests.NovelFlow
{
/// <summary>
/// Entry point of the novel_flow test: adds the driver to the tree root when the exact
/// flag <c>--itest=novel_flow</c> is given (the driver quits the process). Optional
/// <c>--shots=&lt;dir&gt;</c> makes the driver save a screenshot per step.
/// </summary>
public class NovelFlowTestInitializer : IModInitializer
{
private static bool driverQueued;
/// <summary>Nothing per mod.</summary>
public void OnModLoaded(string modId)
{
}
/// <summary>Queues the driver once.</summary>
public void OnModResourcesReady()
{
var args = OS.GetCmdlineUserArgs();
if (driverQueued || !args.Any(a => a == "--itest=novel_flow"))
return;
driverQueued = true;
var shots = args.Where(a => a.StartsWith("--shots=", System.StringComparison.Ordinal))
.Select(a => a.Substring("--shots=".Length))
.FirstOrDefault();
GameLogger.Log("TEST", $"NovelFlowTestInitializer: activating novel_flow, shots={shots ?? "off"}");
(Godot.Engine.GetMainLoop() as SceneTree)?.Root?.CallDeferred("add_child", new NovelFlowTestDriver(shots));
}
}
}
@@ -1,9 +1,14 @@
using System;
using System.IO;
using System.Linq;
using Cthangover.Core.Scenes;
using Cthangover.Core.UI.Tool.LightEditor;
using Cthangover.Core.UI.Tool.ScenarioEditor;
using Cthangover.Tools.InteractiveEditor;
using Cthangover.Tools.Common;
using Cthangover.Tools.MapEditor;
using Cthangover.Tools.SceneEditor;
using Cthangover.Tools.Services;
using Godot;
namespace Cthangover.Tools.Tests.Windows
@@ -31,6 +36,9 @@ namespace Cthangover.Tools.Tests.Windows
case "light_editor":
LightAdd(window, check);
break;
case "scene_editor":
SceneCreate(window, check);
break;
case "scenario_editor":
var tree = Find<ScenarioFileTreePanel>(window)?.FindChildren("*", nameof(Tree), true, false).OfType<Tree>().FirstOrDefault();
check(tree?.GetRoot()?.GetChildCount() > 0, "scenario_editor: file tree lists mods with scenarios");
@@ -104,6 +112,78 @@ namespace Cthangover.Tools.Tests.Windows
"light_editor: double-click adds a light at the clicked point");
}
/// <summary>
/// Creates a scene the way an author does — New scene, a name, the test fixture mod,
/// a double-clicked background, Save — and checks that the running game knows it and
/// that no scenario was made for it (a scene may hold only interactives); then removes
/// the files again. The scene goes
/// into editor_tools itself: it is always loaded with the window, while a test fixture
/// mod is not (--all-mods skips fixtures). Skipped when the mod is not a folder mod.
/// </summary>
private static void SceneCreate(Window window, Action<bool, string> check)
{
const string mod = "editor_tools";
const string name = "zz_tools_test_scene";
var lists = window.FindChildren("*", "", true, false).OfType<FilteredList>().ToList();
check(lists.Count == 2 && lists[0].Count > 0, $"scene_editor: scene list shows the game's scenes ({(lists.Count > 0 ? lists[0].Count : 0)})");
var modSelect = window.FindChildren("*", "", true, false).OfType<OptionButton>()
.FirstOrDefault(o => Enumerable.Range(0, o.ItemCount).Any(i => o.GetItemText(i) == mod));
if (modSelect == null || lists.Count < 2 || lists[1].Count == 0)
return;
var scenePath = ModFileService.GetWritablePath(mod, SceneFileEntry.PathFor(name));
var scenarioPath = ModFileService.GetWritablePath(mod, "scenarios/" + name + ".scenario");
try
{
DeleteLeftovers(scenePath, scenarioPath);
PressButton(window, "tools/scene_editor/new_scene");
var nameEdit = window.FindChildren("*", "", true, false).OfType<LineEdit>().First(e => e.PlaceholderText == "my_scene");
nameEdit.Text = name;
nameEdit.EmitSignal(LineEdit.SignalName.TextChanged, name);
var index = Enumerable.Range(0, modSelect.ItemCount).First(i => modSelect.GetItemText(i) == mod);
modSelect.Select(index);
modSelect.EmitSignal(OptionButton.SignalName.ItemSelected, index);
var picker = lists[1].FindChildren("*", "", true, false).OfType<ItemList>().First();
picker.EmitSignal(ItemList.SignalName.ItemActivated, 0);
PressButton(window, "tools/common/save");
var manager = window.GetTree().Root.GetNodeOrNull<SceneManager>("SceneManager");
check(File.Exists(scenePath) && File.ReadAllText(scenePath).Contains("\"defaultBackground\""), "scene_editor: Save writes the scene file with its background");
check(!File.Exists(scenarioPath), "scene_editor: a new scene is saved without a scenario");
check(manager?.HasScene(name) == true, "scene_editor: the running game knows the new scene without a reload");
}
finally
{
DeleteLeftovers(scenePath, scenarioPath);
SceneFileService.RefreshGame();
}
}
/// <summary>Removes the test scene's files and the folders they alone needed (editor_tools has no scenes of its own).</summary>
private static void DeleteLeftovers(string scenePath, string scenarioPath)
{
foreach (var path in new[] { scenePath, scenarioPath })
{
if (path != null && File.Exists(path))
File.Delete(path);
DeleteIfEmpty(Path.GetDirectoryName(path));
}
}
/// <summary>Deletes a folder left empty by the test.</summary>
private static void DeleteIfEmpty(string directory)
{
if (!string.IsNullOrEmpty(directory) && Directory.Exists(directory) && !Directory.EnumerateFileSystemEntries(directory).Any())
Directory.Delete(directory);
}
private static void PressButton(Node root, string key)
{
var text = TranslationServer.Translate(key).ToString();
var button = root.FindChildren("*", "", true, false).OfType<Button>().First(b => b.Text == text);
button.EmitSignal(BaseButton.SignalName.Pressed);
}
private static void Click(Control target, Vector2 local, bool doubleClick)
{
var ev = new InputEventMouseButton