diff --git a/manifest.json b/manifest.json
index 74d66ed..c7fba93 100644
--- a/manifest.json
+++ b/manifest.json
@@ -9,6 +9,9 @@
"sources": [
"src/**/*.cs"
],
+ "test_sources": [
+ "tests/*.cs"
+ ],
"ui": [
{
"id": "game_menu",
diff --git a/src/Messages/MessagesHelper.cs b/src/Messages/MessagesHelper.cs
index f2ec71c..daabb6b 100644
--- a/src/Messages/MessagesHelper.cs
+++ b/src/Messages/MessagesHelper.cs
@@ -12,7 +12,8 @@ namespace Cthangover.Mods.Interface.Messages
/// pickup notifications). Manages a cycling position index of 0-5 so
/// consecutive messages stack vertically without overlapping. Each message
/// is instantiated from message.tscn and parented to the Background node
- /// (found via scene tree search). Messages auto-fade and self-destruct
+ /// (found via scene tree search). Without that shell, only the text is buffered for its return;
+ /// no detached native controls are allocated. Visible messages auto-fade and self-destruct
/// via UiMessage's OnUpdate.
///
public static class MessagesHelper
@@ -85,6 +86,7 @@ namespace Cthangover.Mods.Interface.Messages
///
/// Instantiates a from message.tscn, parents
/// it to the Background node, and configures it for display and auto-fade.
+ /// A scene without a background queues the text until the shell returns.
///
/// Y-offset in pixels for this message.
/// The message text to display.
@@ -92,22 +94,23 @@ namespace Cthangover.Mods.Interface.Messages
/// Fade speed multiplier (default 1).
public static void AddMessage(float position, string text, Color color, float speed = 1f)
{
- if (prefab == null)
- prefab = new ModScenePrefab("scenes/ui/message.tscn");
-
- var instance = prefab.Instantiate();
- var background = SceneContextNode.FindNode("Background");
+ // Battle and menu scenes may have no shell background. Resolve the owner before allocating native nodes.
+ var tree = SceneContextNode.Instance?.GetTree();
+ var background = tree?.Root.FindChild("Background", true, false) as Control
+ ?? tree?.GetFirstNodeInGroup("Background") as Control;
if (background == null)
{
- background = SceneContextNode.Instance?.GetTree()?.GetFirstNodeInGroup("Background") as Control;
- if (background == null)
- {
- GameLogger.Log("UI", "Background node not found for messages", LogLevel.Error);
- return;
- }
+ // Keep battle rewards until the shell returns, without creating a detached message tree.
+ NotificationBuffer.Add(text);
+ return;
+ }
+ prefab ??= new ModScenePrefab("scenes/ui/message.tscn");
+ var instance = prefab.Instantiate();
+ if (instance != null)
+ {
+ background.AddChild(instance);
+ instance.Setup(position, text, speed, color);
}
- background.AddChild(instance);
- instance.Setup(position, text, speed, color);
ModEventBus.Publish(new NotificationRequestedEvent
{
diff --git a/tests/MessagesHelperTests.cs b/tests/MessagesHelperTests.cs
new file mode 100644
index 0000000..8a01de5
--- /dev/null
+++ b/tests/MessagesHelperTests.cs
@@ -0,0 +1,51 @@
+namespace Cthangover.Mods.Interface.Messages.Tests
+{
+ using System.Collections.Generic;
+ using Cthangover.Core.Mods.Testing;
+ using Cthangover.Core.Scenes;
+ using Cthangover.Core.UI.Messages;
+ using Godot;
+
+ /// Battle/menu notifications must survive a missing shell without allocating orphan UI trees.
+ public class MessagesHelperTests
+ {
+ [ModTest("messages/without_background_preserves_each_notification")]
+ static void WithoutBackground(ModTestContext ctx)
+ {
+ var root = SceneContextNode.Instance.GetTree().Root;
+ var background = root.FindChild("Background", true, false) as Control;
+ var originalName = background?.Name ?? default;
+ var grouped = background?.IsInGroup("Background") == true;
+ var previous = new List();
+ while (NotificationBuffer.TryDequeue(out var pendingText)) previous.Add(pendingText);
+ try
+ {
+ // The shared test scene has a background; model a battle without detaching or freeing borrowed UI.
+ if (background != null)
+ {
+ background.Name = "MessagesContractBackground";
+ if (grouped) background.RemoveFromGroup("Background");
+ }
+ ModAssert.True(root.FindChild("Background", true, false) == null, "fixture has no shell background");
+ for (var i = 0; i < 40; i++) MessagesHelper.AddMessage("reward/" + i);
+ ModAssert.Equal(40, NotificationBuffer.PendingCount, "every late reward is buffered exactly once");
+ for (var i = 0; i < 40; i++)
+ {
+ ModAssert.True(NotificationBuffer.TryDequeue(out var actual), "notification is retained");
+ ModAssert.Equal("reward/" + i, actual, "text and order survive until the shell returns");
+ }
+ ModAssert.Equal(0, NotificationBuffer.PendingCount, "no duplicate banners remain");
+ }
+ finally
+ {
+ if (background != null)
+ {
+ background.Name = originalName;
+ if (grouped) background.AddToGroup("Background");
+ }
+ NotificationBuffer.Clear();
+ foreach (var previousText in previous) NotificationBuffer.Add(previousText);
+ }
+ }
+ }
+}