diff --git a/items/test_patches.json b/items/test_patches.json
new file mode 100644
index 0000000..e57afe4
--- /dev/null
+++ b/items/test_patches.json
@@ -0,0 +1,36 @@
+{
+ "Kind": "item",
+ "Items": [
+ {
+ "Id": "test_patches/base",
+ "Name": "items/food/ration/name",
+ "Description": "items/food/ration/desc",
+ "Cost": 10,
+ "Sprite": "food/ration",
+ "ItemType": "Food",
+ "Shelf": {"Row": 1, "Col": 2}
+ },
+ {
+ "Id": "test_patches/child",
+ "Extends": "test_patches/base",
+ "Cost": 20,
+ "Shelf": {"Col": 5}
+ },
+ {
+ "Id": "test_patches/doomed",
+ "Name": "items/food/ration/name",
+ "Sprite": "food/ration",
+ "ItemType": "Food"
+ },
+ {
+ "Id": "test_patches/loop_a",
+ "Extends": "test_patches/loop_b",
+ "Sprite": "food/ration"
+ },
+ {
+ "Id": "test_patches/loop_b",
+ "Extends": "test_patches/loop_a",
+ "Sprite": "food/ration"
+ }
+ ]
+}
diff --git a/mod.jpg b/mod.jpg
index 7f6ac09..ed52d07 100644
Binary files a/mod.jpg and b/mod.jpg differ
diff --git a/patches/item.json b/patches/item.json
index d8dbace..6020eac 100644
--- a/patches/item.json
+++ b/patches/item.json
@@ -9,6 +9,26 @@
"Id": "*",
"Durability": 50,
"GlobalItemProp": "patched"
+ },
+ {
+ "Id": "FOOD/WOLF_MEAT",
+ "Cost": 77
+ },
+ {
+ "Id": "food/wolf_meat",
+ "Cost": "many"
+ },
+ {
+ "Id": "food/wolf_meat",
+ "Name$add": ["broken"]
+ },
+ {
+ "Id": "test_patches/base",
+ "Sprite": "food/wolf_meat"
+ },
+ {
+ "Id": "test_patches/doomed",
+ "$delete": true
}
]
}
diff --git a/patches/item/explain.json b/patches/item/explain.json
new file mode 100644
index 0000000..4a4dd93
--- /dev/null
+++ b/patches/item/explain.json
@@ -0,0 +1,8 @@
+{
+ "Items": [
+ {
+ "Id": "test_patches/child",
+ "Description": "items/food/wolf_meat/desc"
+ }
+ ]
+}
diff --git a/src/PropertyPatchesTest.cs b/src/PropertyPatchesTest.cs
index 2d627ff..3fbabde 100644
--- a/src/PropertyPatchesTest.cs
+++ b/src/PropertyPatchesTest.cs
@@ -38,7 +38,9 @@ namespace Mods.TestDefaultsAndPatches
/// 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.
+ /// survive; patch ids match case-insensitively, a broken patch is skipped
+ /// alone, $delete removes an entity, Extends inherits from a
+ /// parent resolved with its own patches and a cycle skips its members. Prints [TEST_RESULT] name=property_patches and quits.
///
public partial class PropertyPatchesTestDriverNode : Node
{
@@ -113,9 +115,67 @@ namespace Mods.TestDefaultsAndPatches
Check("food/ration resolved", false);
if (items.TryGetValue("food/wolf_meat", out var meat))
+ {
Expect(meat.ExtraProperties, "Durability", "50", "wolf_meat: wildcard value");
+ Check($"wolf_meat: patch id is case-insensitive (FOOD/WOLF_MEAT → Cost 77, got {meat.Cost})", meat.Cost == 77);
+ Check("wolf_meat: broken patches (Cost \"many\", Name$add) are skipped, the item keeps its name",
+ meat.Name == "items/food/wolf_meat/name");
+ }
else
- Check("food/wolf_meat resolved", false);
+ Check("food/wolf_meat resolved (a broken patch must not drop the kind)", false);
+
+ Check("$delete removes test_patches/doomed", !items.ContainsKey("test_patches/doomed"));
+ Check("an Extends cycle skips both entities", !items.ContainsKey("test_patches/loop_a") && !items.ContainsKey("test_patches/loop_b"));
+ if (items.TryGetValue("test_patches/child", out var child))
+ {
+ Check("Extends: child inherits Name", child.Name == "items/food/ration/name");
+ Check($"Extends: child's own Cost wins (got {child.Cost})", child.Cost == 20);
+ Check($"Extends: the parent is resolved with its patches first (Sprite {child.Sprite})", child.Sprite == "food/wolf_meat");
+ var shelf = child.ExtraProperties != null && child.ExtraProperties.TryGetValue("Shelf", out var value) && value is JsonElement element
+ && element.ValueKind == JsonValueKind.Object ? element : default;
+ Check($"Extends: nested objects deep-merge (Shelf {(shelf.ValueKind == JsonValueKind.Object ? shelf.GetRawText() : "missing")})",
+ shelf.ValueKind == JsonValueKind.Object && shelf.TryGetProperty("Row", out var row) && row.GetInt32() == 1
+ && shelf.TryGetProperty("Col", out var col) && col.GetInt32() == 5);
+ Expect(child.ExtraProperties, "Durability", "50", "child: wildcard patch applies after Extends");
+ }
+ else
+ Check("test_patches/child resolved", false);
+
+ ExplainChild();
+ }
+
+ ///
+ /// EntityResolver.Explain on an Extends chain with patches on the parent, the
+ /// child (a second file) and a wildcard: every field names its layer, entity, mod and file.
+ ///
+ private void ExplainChild()
+ {
+ const string mod = "test_defaults_and_patches";
+ const string data = "items/test_patches.json";
+ var resolver = Cthangover.Core.Mods.ModManager.Instance.GetResolver();
+ var explanation = resolver?.Explain("item", "test_patches/child");
+ Check("Explain(item, test_patches/child) returns a value", explanation?.Value != null);
+ if (explanation?.Value == null)
+ return;
+
+ Check($"Explain value matches Collect (Cost {explanation.Value["Cost"]})", explanation.Value["Cost"]?.GetValue() == 20);
+ ExpectOrigin(explanation, "Name", EntityLayer.Definition, "test_patches/base", mod, data);
+ ExpectOrigin(explanation, "Cost", EntityLayer.Definition, "test_patches/child", mod, data);
+ ExpectOrigin(explanation, "Shelf.Row", EntityLayer.Definition, "test_patches/base", mod, data);
+ ExpectOrigin(explanation, "Shelf.Col", EntityLayer.Definition, "test_patches/child", mod, data);
+ ExpectOrigin(explanation, "Sprite", EntityLayer.Patch, "test_patches/base", mod, "patches/item.json");
+ ExpectOrigin(explanation, "Description", EntityLayer.Patch, "test_patches/child", mod, "patches/item/explain.json");
+ ExpectOrigin(explanation, "Durability", EntityLayer.Patch, "*", mod, "patches/item.json");
+ Check("Explain of a $delete-d entity says Deleted", resolver.Explain("item", "test_patches/doomed")?.Deleted == true);
+ Check("Explain of an Extends cycle member is null", resolver.Explain("item", "test_patches/loop_a") == null);
+ }
+
+ private void ExpectOrigin(EntityExplanation explanation, string path, EntityLayer layer, string entity, string mod, string file)
+ {
+ explanation.Fields.TryGetValue(path, out var origin);
+ var actual = origin == null ? "default" : $"{origin.Layer} {origin.EntityId} {origin.ModId}/{origin.File}";
+ Check($"Explain {path}: {layer} {entity} {mod}/{file} (got {actual})",
+ origin != null && origin.Layer == layer && origin.EntityId == entity && origin.ModId == mod && origin.File == file);
}
private void Expect(Dictionary properties, string key, string expected, string what)