Compare commits

...
3 Commits
Author SHA1 Message Date
cthangover b8fb306a57 Commit pending working-tree changes (local edits, line endings) 2026-10-21 12:32:24 +02:00
cthangover 04c334c36a hot_reload checks that edited data reaches a kept engine mod
A data-only probe mod depending on characters_base has its character edited;
after the reload the template must carry the new name. It failed while the
character factory cached the previous mod set's data.
2026-10-21 10:09:44 +02:00
cthangover 07d7d10fc5 collection_edit reads the studio button key from its UI entry
The suite does not depend on scenario_studio, so the hard-coded studio/title
key was an undeclared resource use (resource_atlas error).
2026-10-21 07:45:36 +02:00
4 changed files with 250 additions and 210 deletions

No files matched your search

+37 -37
View File
@@ -1,37 +1,37 @@
# Godot 4.x # Godot 4.x
.godot/ .godot/
.idea/ .idea/
sdk/ sdk/
bin/ bin/
obj/ obj/
#ai #ai
.omo/ .omo/
.opencode/ .opencode/
gen/ gen/
obj/ obj/
bin/ bin/
*.tres~ *.tres~
*.tscn~ *.tscn~
# GDScript LSP # GDScript LSP
.godot/ .godot/
# Export # Export
*.exe *.exe
*.dmg *.dmg
*.apk *.apk
*.aab *.aab
*.pck *.pck
*.zip *.zip
*.log *.log
# OS # OS
.DS_Store .DS_Store
Thumbs.db Thumbs.db
# Editor # Editor
*.import *.import
*.godot.uid *.godot.uid
+169 -169
View File
@@ -1,169 +1,169 @@
using System.Collections.Generic; using System.Collections.Generic;
using Cthangover.Core.Scenes; using Cthangover.Core.Scenes;
using Cthangover.Core.Settings; using Cthangover.Core.Settings;
using Cthangover.Core.UI.Menu; using Cthangover.Core.UI.Menu;
using Cthangover.Core.UI.Theming; using Cthangover.Core.UI.Theming;
using Cthangover.Core.UI.Tool; using Cthangover.Core.UI.Tool;
using Cthangover.Core.Utils; using Cthangover.Core.Utils;
using Godot; using Godot;
using Cthangover.Mods.Interface.Menu; using Cthangover.Mods.Interface.Menu;
namespace Cthangover.BaseMenu namespace Cthangover.BaseMenu
{ {
/// <summary> /// <summary>
/// Title screen main menu, fully driven by <see cref="MenuRegistry"/>. The scene /// Title screen main menu, fully driven by <see cref="MenuRegistry"/>. The scene
/// only defines named section containers (game, mods, exit, tools); every button — /// only defines named section containers (game, mods, exit, tools); every button —
/// built-in or mod-provided — is created from registry entries, so mods can add, /// built-in or mod-provided — is created from registry entries, so mods can add,
/// replace or re-order entries without touching this scene. Below the sections, /// replace or re-order entries without touching this scene. Below the sections,
/// every registered tool appends its own launcher button via /// every registered tool appends its own launcher button via
/// <see cref="IToolProvider.AddMenuButton"/>, so no tool list is hardcoded here. /// <see cref="IToolProvider.AddMenuButton"/>, so no tool list is hardcoded here.
/// If GameLogger reports mod compilation errors, a popup lists them. The /// If GameLogger reports mod compilation errors, a popup lists them. The
/// SettingsMenu and SaveLoadMenu panels are instantiated at ready time but stay /// SettingsMenu and SaveLoadMenu panels are instantiated at ready time but stay
/// hidden; they are registered in <see cref="MenuRegistry"/> as panels "settings" /// hidden; they are registered in <see cref="MenuRegistry"/> as panels "settings"
/// and "save_load", so other mods can open them by id. /// and "save_load", so other mods can open them by id.
/// </summary> /// </summary>
public partial class MainMenu : Control public partial class MainMenu : Control
{ {
private SettingsMenu _settingsMenu; private SettingsMenu _settingsMenu;
private SaveLoadMenu _saveLoadMenu; private SaveLoadMenu _saveLoadMenu;
private GamePickerPanel _gamePicker; private GamePickerPanel _gamePicker;
private readonly Dictionary<Button, string> _btnKeys = new(); private readonly Dictionary<Button, string> _btnKeys = new();
public override void _Ready() public override void _Ready()
{ {
try try
{ {
BuildUi(); BuildUi();
} }
catch (System.Exception ex) catch (System.Exception ex)
{ {
GameLogger.Log("MAIN_MENU", GameLogger.Log("MAIN_MENU",
$"_Ready failed: {ex.GetType().Name}: {ex.Message}{System.Environment.NewLine}{ex.StackTrace}", $"_Ready failed: {ex.GetType().Name}: {ex.Message}{System.Environment.NewLine}{ex.StackTrace}",
LogLevel.Error); LogLevel.Error);
throw; throw;
} }
} }
/// <summary> /// <summary>
/// Assembles the runtime UI: theme, hidden settings and save/load panels, /// Assembles the runtime UI: theme, hidden settings and save/load panels,
/// telegram link wiring, scene manager initialization and registry/tool /// telegram link wiring, scene manager initialization and registry/tool
/// driven buttons. /// driven buttons.
/// </summary> /// </summary>
private void BuildUi() private void BuildUi()
{ {
CthangoverTheme.Apply(this); CthangoverTheme.Apply(this);
_settingsMenu = new ModScenePrefab("scenes/menu/settings_menu.tscn").Instantiate<SettingsMenu>(); _settingsMenu = new ModScenePrefab("scenes/menu/settings_menu.tscn").Instantiate<SettingsMenu>();
_settingsMenu.Name = "SettingsPanel"; _settingsMenu.Name = "SettingsPanel";
_settingsMenu.Visible = false; _settingsMenu.Visible = false;
_settingsMenu.LanguageChanged += RefreshButtons; _settingsMenu.LanguageChanged += RefreshButtons;
AddChild(_settingsMenu); AddChild(_settingsMenu);
_saveLoadMenu = new ModScenePrefab("scenes/menu/save_load_menu.tscn").Instantiate<SaveLoadMenu>(); _saveLoadMenu = new ModScenePrefab("scenes/menu/save_load_menu.tscn").Instantiate<SaveLoadMenu>();
_saveLoadMenu.Name = "SaveLoadPanel"; _saveLoadMenu.Name = "SaveLoadPanel";
_saveLoadMenu.Visible = false; _saveLoadMenu.Visible = false;
AddChild(_saveLoadMenu); AddChild(_saveLoadMenu);
_gamePicker = new GamePickerPanel { Name = "GamePickerPanel" }; _gamePicker = new GamePickerPanel { Name = "GamePickerPanel" };
AddChild(_gamePicker); AddChild(_gamePicker);
_gamePicker.SavesRequested += gameId => _gamePicker.SavesRequested += gameId =>
{ {
_gamePicker.Visible = false; _gamePicker.Visible = false;
_saveLoadMenu.OpenForGame(gameId); _saveLoadMenu.OpenForGame(gameId);
}; };
var telegramLink = GetNode<RichTextLabel>("TelegramLink"); var telegramLink = GetNode<RichTextLabel>("TelegramLink");
telegramLink.MetaClicked += OnTelegramLinkClicked; telegramLink.MetaClicked += OnTelegramLinkClicked;
var sceneManager = GetNode<SceneManager>("/root/SceneManager"); var sceneManager = GetNode<SceneManager>("/root/SceneManager");
sceneManager.Initialize(); sceneManager.Initialize();
BuildRegistryButtons(); BuildRegistryButtons();
BuildToolButtons(); BuildToolButtons();
CheckModCompilationErrors(); CheckModCompilationErrors();
RefreshButtons(); RefreshButtons();
} }
/// <summary> /// <summary>
/// Creates one button per <see cref="MenuRegistry"/> entry, routed to the /// Creates one button per <see cref="MenuRegistry"/> entry, routed to the
/// section container matching its <c>Section</c> ("game", "mods", "exit", /// section container matching its <c>Section</c> ("game", "mods", "exit",
/// "tools"). Runs after <see cref="SceneManager.Initialize"/> because the /// "tools"). Runs after <see cref="SceneManager.Initialize"/> because the
/// registry is built during boot and filled by mod initializers; the /// registry is built during boot and filled by mod initializers; the
/// defensive Initialize call keeps direct scene launches working. Each /// defensive Initialize call keeps direct scene launches working. Each
/// button's translation key is registered so <see cref="RefreshButtons"/> /// button's translation key is registered so <see cref="RefreshButtons"/>
/// keeps it localized. /// keeps it localized.
/// </summary> /// </summary>
private void BuildRegistryButtons() private void BuildRegistryButtons()
{ {
var sections = new Dictionary<string, Container> var sections = new Dictionary<string, Container>
{ {
{ "game", GetNode<Container>("MenuContainer/GameButtons") }, { "game", GetNode<Container>("MenuContainer/GameButtons") },
{ MenuButtonEntry.SectionMods, GetNode<Container>("MenuContainer/ModMenuButtons") }, { MenuButtonEntry.SectionMods, GetNode<Container>("MenuContainer/ModMenuButtons") },
{ "exit", GetNode<Container>("MenuContainer/ExitButtons") }, { "exit", GetNode<Container>("MenuContainer/ExitButtons") },
{ "tools", GetNode<Container>("MenuContainer/ToolMenuButtons") }, { "tools", GetNode<Container>("MenuContainer/ToolMenuButtons") },
}; };
MenuBuilder.Build(this, sections); MenuBuilder.Build(this, sections);
foreach (var container in sections.Values) foreach (var container in sections.Values)
MenuBuilder.CollectLocaleKeys(container, _btnKeys); MenuBuilder.CollectLocaleKeys(container, _btnKeys);
} }
/// <summary> /// <summary>
/// Lets every registered tool append its launcher button to the tool block /// Lets every registered tool append its launcher button to the tool block
/// below Exit (<c>MenuContainer/ToolMenuButtons</c>). Tools register /// below Exit (<c>MenuContainer/ToolMenuButtons</c>). Tools register
/// themselves in <see cref="ToolFactory"/>, so the block stays empty when no /// themselves in <see cref="ToolFactory"/>, so the block stays empty when no
/// tools are installed and needs no changes when a mod introduces a new tool. /// tools are installed and needs no changes when a mod introduces a new tool.
/// Each button carries its translation key in metadata, keeping /// Each button carries its translation key in metadata, keeping
/// <see cref="RefreshButtons"/> aware of it. /// <see cref="RefreshButtons"/> aware of it.
/// </summary> /// </summary>
private void BuildToolButtons() private void BuildToolButtons()
{ {
var container = GetNodeOrNull<VBoxContainer>("MenuContainer/ToolMenuButtons"); var container = GetNodeOrNull<VBoxContainer>("MenuContainer/ToolMenuButtons");
if (container == null) if (container == null)
return; return;
foreach (var tool in ToolFactory.Instance.GetAll()) foreach (var tool in ToolFactory.Instance.GetAll())
tool.AddMenuButton(container); tool.AddMenuButton(container);
MenuBuilder.CollectLocaleKeys(container, _btnKeys); MenuBuilder.CollectLocaleKeys(container, _btnKeys);
GameLogger.Log("MAIN_MENU", $"Built {container.GetChildCount()} tool button(s)"); GameLogger.Log("MAIN_MENU", $"Built {container.GetChildCount()} tool button(s)");
} }
private void CheckModCompilationErrors() private void CheckModCompilationErrors()
{ {
if (GameLogger.CompilationErrors.Count == 0) if (GameLogger.CompilationErrors.Count == 0)
return; return;
var errorText = string.Join("\r\n", GameLogger.CompilationErrors); var errorText = string.Join("\r\n", GameLogger.CompilationErrors);
ShowModErrorPopup(errorText); ShowModErrorPopup(errorText);
} }
private void RefreshButtons() private void RefreshButtons()
{ {
foreach (var kv in _btnKeys) foreach (var kv in _btnKeys)
kv.Key.Text = TranslationServer.Translate(kv.Value); kv.Key.Text = TranslationServer.Translate(kv.Value);
} }
private void ShowModErrorPopup(string errorText) private void ShowModErrorPopup(string errorText)
{ {
var dialog = new AcceptDialog(); var dialog = new AcceptDialog();
dialog.Title = "Mod Compilation Error"; dialog.Title = "Mod Compilation Error";
dialog.DialogText = errorText; dialog.DialogText = errorText;
dialog.OkButtonText = "OK"; dialog.OkButtonText = "OK";
dialog.Exclusive = true; dialog.Exclusive = true;
AddChild(dialog); AddChild(dialog);
dialog.PopupCentered(); dialog.PopupCentered();
} }
private void OnTelegramLinkClicked(Variant meta) private void OnTelegramLinkClicked(Variant meta)
{ {
OS.ShellOpen($"https://{meta.AsString()}"); OS.ShellOpen($"https://{meta.AsString()}");
} }
} }
} }
@@ -9,6 +9,7 @@ using Cthangover.Core.Games;
using Cthangover.Core.Mods; using Cthangover.Core.Mods;
using Cthangover.Core.Mods.HotReload; using Cthangover.Core.Mods.HotReload;
using Cthangover.Core.Scenes; using Cthangover.Core.Scenes;
using Cthangover.Core.UI;
using Cthangover.Core.Utils; using Cthangover.Core.Utils;
using Godot; using Godot;
@@ -136,7 +137,7 @@ namespace Mods.CollectionEditTest
Check("New game after the edit switched the mod set", reload?.Applied == true && reload.Plan?.Load.Contains(AddedMod) == true); Check("New game after the edit switched the mod set", reload?.Applied == true && reload.Plan?.Load.Contains(AddedMod) == true);
Check($"'{AddedMod}' is loaded after the edit", EditorsLoaded); Check($"'{AddedMod}' is loaded after the edit", EditorsLoaded);
Check($"'{StudioMod}' is loaded after the edit", ModRegistry.Instance.Mods.ContainsKey(StudioMod)); Check($"'{StudioMod}' is loaded after the edit", ModRegistry.Instance.Mods.ContainsKey(StudioMod));
Check("the studio's button is on the game's toolbar", HasToolbarButton(TranslationServer.Translate("studio/title").ToString())); Check("the studio's button is on the game's toolbar", HasToolbarButton(StudioButtonTooltip()));
Check("the game matches its edited collection", !GameCatalog.IsActiveOutdated()); Check("the game matches its edited collection", !GameCatalog.IsActiveOutdated());
Check($"entered a scene of '{GameId}' ('{enteredScenes[0]}')", true); Check($"entered a scene of '{GameId}' ('{enteredScenes[0]}')", true);
Finish(); Finish();
@@ -193,6 +194,17 @@ namespace Mods.CollectionEditTest
} }
} }
/// <summary>
/// Tooltip of the studio's toolbar button, read from the panel entry the studio
/// registered: the suite does not depend on the studio (it is added by the edit),
/// so its locale key must not be hard-coded here.
/// </summary>
private static string StudioButtonTooltip()
{
var locale = UiRegistry.PanelEntries.FirstOrDefault(e => e.ModId == StudioMod && e.Button != null)?.Button.Locale;
return locale == null ? null : TranslationServer.Translate(locale).ToString();
}
private bool HasToolbarButton(string tooltip) private bool HasToolbarButton(string tooltip)
=> GetTree().Root.FindChildren("*", "Button", true, false).OfType<Button>().Any(b => b.TooltipText == tooltip && b.IsVisibleInTree()); => GetTree().Root.FindChildren("*", "Button", true, false).OfType<Button>().Any(b => b.TooltipText == tooltip && b.IsVisibleInTree());
@@ -6,6 +6,7 @@ using Cthangover.Core.Bootstrap;
using Cthangover.Core.Events; using Cthangover.Core.Events;
using Cthangover.Core.Games; using Cthangover.Core.Games;
using Cthangover.Core.Mods; using Cthangover.Core.Mods;
using Cthangover.Core.Characters;
using Cthangover.Core.Mods.HotReload; using Cthangover.Core.Mods.HotReload;
using Cthangover.Core.Utils; using Cthangover.Core.Utils;
using Godot; using Godot;
@@ -23,6 +24,8 @@ namespace Mods.HotReloadTest
/// cascades), the old contexts are collected, the front-end stays.</item> /// cascades), the old contexts are collected, the front-end stays.</item>
/// <item>A compile error in the library is rejected: v2 keeps running.</item> /// <item>A compile error in the library is rejected: v2 keeps running.</item>
/// <item>Fixing it (v3) reloads again.</item> /// <item>Fixing it (v3) reloads again.</item>
/// <item>Editing a character of a data-only probe (no code) is picked up by
/// the kept characters engine mod: its factory serves the new template.</item>
/// <item>Dropping the probes from the selection unloads them and their node.</item> /// <item>Dropping the probes from the selection unloads them and their node.</item>
/// </list> /// </list>
/// Reload outcomes are read from <see cref="ModSetReloadedEvent"/>s, since /// Reload outcomes are read from <see cref="ModSetReloadedEvent"/>s, since
@@ -35,6 +38,8 @@ namespace Mods.HotReloadTest
private const string LibId = "hot_reload_probe_lib"; private const string LibId = "hot_reload_probe_lib";
private const string AppId = "hot_reload_probe_app"; private const string AppId = "hot_reload_probe_app";
private const string DataId = "hot_reload_probe_data";
private const string ProbeCharacterId = "hot_reload_probe_character";
private const string ProbeNodeName = "HotReloadProbeNode"; private const string ProbeNodeName = "HotReloadProbeNode";
private const float TimeoutSeconds = 300f; private const float TimeoutSeconds = 300f;
@@ -109,8 +114,9 @@ namespace Mods.HotReloadTest
return; return;
Check("hot reload watcher is installed", ModHotReloadService.Instance != null); Check("hot reload watcher is installed", ModHotReloadService.Instance != null);
startSelection = GameLaunchArgs.Current; startSelection = GameLaunchArgs.Current;
GameLaunchArgs.SetCurrent(GameLaunchArgs.Parse(startSelection.UserArgs.Concat(new[] { GameLaunchArgs.WithModsPrefix + AppId }))); GameLaunchArgs.SetCurrent(GameLaunchArgs.Parse(startSelection.UserArgs.Concat(new[] { GameLaunchArgs.WithModsPrefix + AppId + "," + DataId })));
reloads.Clear(); reloads.Clear();
WriteData("probe_v1");
WriteProbes("v1"); WriteProbes("v1");
break; break;
@@ -118,6 +124,7 @@ namespace Mods.HotReloadTest
if (Busy || ProbeValue() != "v1") if (Busy || ProbeValue() != "v1")
return; return;
Check("probes loaded by the watcher", AnyApplied(LoadedBoth)); Check("probes loaded by the watcher", AnyApplied(LoadedBoth));
Check("data probe's character is served", ProbeCharacterName() == "probe_v1");
CheckNoLeaks("v1"); CheckNoLeaks("v1");
reloads.Clear(); reloads.Clear();
WriteLib("v2", compiles: true); WriteLib("v2", compiles: true);
@@ -148,11 +155,19 @@ namespace Mods.HotReloadTest
Check("fixing the error reloads again", AnyApplied(LoadedBoth)); Check("fixing the error reloads again", AnyApplied(LoadedBoth));
CheckNoLeaks("v3"); CheckNoLeaks("v3");
reloads.Clear(); reloads.Clear();
WriteData("probe_v2");
break;
case 5:
if (Busy || !reloads.Any(r => r.Applied))
return;
Check("editing a data-only mod reaches the kept character factory", ProbeCharacterName() == "probe_v2");
reloads.Clear();
GameLaunchArgs.SetCurrent(startSelection); GameLaunchArgs.SetCurrent(startSelection);
StartManualReload(); StartManualReload();
break; break;
case 5: case 6:
if (Busy || ProbeValue() != null || !AnyApplied(UnloadedBoth)) if (Busy || ProbeValue() != null || !AnyApplied(UnloadedBoth))
return; return;
Check("dropping the probes unloads them and their node", true); Check("dropping the probes unloads them and their node", true);
@@ -205,6 +220,19 @@ namespace Mods.HotReloadTest
GameLogger.Log("TEST", $"HotReloadTest: leaked: {string.Join(", ", leaked)}", LogLevel.Error); GameLogger.Log("TEST", $"HotReloadTest: leaked: {string.Join(", ", leaked)}", LogLevel.Error);
} }
private static string ProbeCharacterName() => CharacterTemplateRegistry.Get(ProbeCharacterId)?.Name;
private void WriteData(string name)
{
var data = Path.Combine(modsRoot, DataId);
Directory.CreateDirectory(Path.Combine(data, "characters"));
File.WriteAllText(Path.Combine(data, "manifest.json"),
"{ \"id\": \"" + DataId + "\", \"name\": \"Hot reload probe data\", \"version\": \"1.0.0\", \"author\": \"test\", " +
"\"depends\": [\"characters_base>=1\"] }");
File.WriteAllText(Path.Combine(data, "characters", "probe.json"),
"{ \"Kind\": \"character\", \"Items\": [ { \"Id\": \"" + ProbeCharacterId + "\", \"Name\": \"" + name + "\", \"Health\": 1 } ] }");
}
private void WriteProbes(string version) private void WriteProbes(string version)
{ {
var app = Path.Combine(modsRoot, AppId); var app = Path.Combine(modsRoot, AppId);
@@ -229,7 +257,7 @@ namespace Mods.HotReloadTest
private void DeleteProbes() private void DeleteProbes()
{ {
foreach (var id in new[] { LibId, AppId }) foreach (var id in new[] { LibId, AppId, DataId })
{ {
var dir = Path.Combine(modsRoot, id); var dir = Path.Combine(modsRoot, id);
if (Directory.Exists(dir)) if (Directory.Exists(dir))