diff --git a/manifest.json b/manifest.json
index e830533..1096ae3 100644
--- a/manifest.json
+++ b/manifest.json
@@ -3,7 +3,17 @@
"name": "Test Defaults and Patches",
"version": "1.0.0",
"author": "ct",
- "description": "Test mod demonstrating property patches system with wildcard and targeted patches",
+ "description": "Test fixture of the property patches system: a targeted patch (Marao, food/ration) and wildcard patches of every character and item. Marked as a fixture, so --all-mods and games never load it; the property_patches itest loads it explicitly (--with-mods=test_defaults_and_patches --itest=property_patches) and checks the merged entities.",
"category": "other",
- "depends": ["visual_novel_core>=1"]
+ "depends": [
+ "visual_novel_core>=1",
+ "murakami>=1",
+ "cooking>=1",
+ "characters_base>=1",
+ "items_base>=1"
+ ],
+ "fixture": true,
+ "sources": [
+ "src/*.cs"
+ ]
}
diff --git a/src/PropertyPatchesTest.cs b/src/PropertyPatchesTest.cs
new file mode 100644
index 0000000..011bd14
--- /dev/null
+++ b/src/PropertyPatchesTest.cs
@@ -0,0 +1,152 @@
+using System;
+using System.Collections.Generic;
+using System.Linq;
+using System.Text.Json;
+using Cthangover.Core.Bootstrap;
+using Cthangover.Core.Characters;
+using Cthangover.Core.Items;
+using Cthangover.Core.Mods;
+using Cthangover.Core.Mods.Resolvers;
+using Cthangover.Core.Utils;
+using Godot;
+
+namespace Mods.TestDefaultsAndPatches
+{
+ ///
+ /// Entry point of the property patches itest: active only with
+ /// --itest=property_patches. The fixture itself is loaded only by
+ /// --with-mods=test_defaults_and_patches (manifest fixture).
+ ///
+ public class PropertyPatchesTestInitializer : IModInitializer
+ {
+ private static bool queued;
+
+ public void OnModLoaded(string modId) { }
+
+ public void OnModResourcesReady()
+ {
+ if (queued || !OS.GetCmdlineUserArgs().Contains("--itest=property_patches"))
+ return;
+ queued = true;
+ GameLogger.Log("TEST", "PropertyPatchesTest: activating");
+ (Godot.Engine.GetMainLoop() as SceneTree)?.Root?.CallDeferred("add_child", new PropertyPatchesTestDriverNode());
+ }
+ }
+
+ ///
+ /// Checks the merge of patches/character.json and
+ /// patches/item.json into the real entities of other mods, as the
+ /// game resolves them: a targeted patch wins over the wildcard, the
+ /// wildcard reaches every entity of the kind, and untouched typed fields
+ /// survive. Prints [TEST_RESULT] name=property_patches and quits.
+ ///
+ public partial class PropertyPatchesTestDriverNode : Node
+ {
+ private const string ResultName = "property_patches";
+ private const double TimeoutSeconds = 60;
+
+ 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 characterPatches = Patches.Collect("character");
+ Check("character patches collected (Marao + *)", characterPatches != null && characterPatches.ContainsKey("Marao") && characterPatches.ContainsKey("*"));
+ var itemPatches = Patches.Collect("item");
+ Check("item patches collected (food/ration + *)", itemPatches != null && itemPatches.ContainsKey("food/ration") && itemPatches.ContainsKey("*"));
+
+ var characters = Entities.Collect();
+ Check("Marao resolved", characters.ContainsKey("Marao"));
+ if (characters.TryGetValue("Marao", out var marao))
+ {
+ Expect(marao.ExtraProperties, "TestPatched", "42", "Marao: targeted value");
+ Expect(marao.ExtraProperties, "TestDefault", "100", "Marao: targeted beats wildcard");
+ Expect(marao.ExtraProperties, "TestGlobalPatch", "1", "Marao: wildcard-only key");
+ }
+
+ var other = characters.Values.FirstOrDefault(c => c.ID != "Marao");
+ Check("another character exists", other != null);
+ if (other != null)
+ {
+ Expect(other.ExtraProperties, "TestDefault", "-20", $"{other.ID}: wildcard value");
+ Check($"{other.ID}: no targeted key", other.ExtraProperties == null || !other.ExtraProperties.ContainsKey("TestPatched"));
+ }
+
+ var items = Entities.Collect();
+ if (items.TryGetValue("food/ration", out var ration))
+ {
+ Expect(ration.ExtraProperties, "Durability", "99", "ration: targeted beats wildcard");
+ Expect(ration.ExtraProperties, "SpecialProp", "true", "ration: targeted-only key");
+ Expect(ration.ExtraProperties, "GlobalItemProp", "patched", "ration: wildcard-only key");
+ Check("ration: typed field survives the merge", !string.IsNullOrEmpty(ration.Name));
+ }
+ else
+ Check("food/ration resolved", false);
+
+ if (items.TryGetValue("food/wolf_meat", out var meat))
+ Expect(meat.ExtraProperties, "Durability", "50", "wolf_meat: wildcard value");
+ else
+ Check("food/wolf_meat resolved", false);
+ }
+
+ private void Expect(Dictionary properties, string key, string expected, string what)
+ {
+ var actual = properties != null && properties.TryGetValue(key, out var value) ? Text(value) : null;
+ Check($"{what}: {key}={expected}" + (actual == expected ? string.Empty : $" (got {actual ?? "nothing"})"), actual == expected);
+ }
+
+ private static string Text(object value) => value switch
+ {
+ JsonElement { ValueKind: JsonValueKind.String } s => s.GetString(),
+ JsonElement e => e.GetRawText(),
+ bool b => b ? "true" : "false",
+ null => null,
+ _ => Convert.ToString(value, System.Globalization.CultureInfo.InvariantCulture),
+ };
+
+ 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);
+ }
+ }
+}