diff --git a/locale/en.properties b/locale/en.properties
index db76392..611d438 100644
--- a/locale/en.properties
+++ b/locale/en.properties
@@ -1,5 +1,5 @@
mod_cooking_name = Cooking
-mod_cooking_desc = Cooking mechanic — full autonomous mod
+mod_cooking_desc = Cooking mechanic: the kitchen workbench in home_kitchen with recipe-based cooking (e.g. wolf meat into rations), a ration statistics panel and the cooking scenario.
ui/cook/cook = Cook
ui/cook/close = Close
diff --git a/locale/ru.properties b/locale/ru.properties
index ea62b24..cc6500a 100644
--- a/locale/ru.properties
+++ b/locale/ru.properties
@@ -1,5 +1,5 @@
mod_cooking_name = Готовка
-mod_cooking_desc = Механика готовки — полностью автономный мод
+mod_cooking_desc = Механика готовки: верстак на кухне home_kitchen с рецептами (например, волчье мясо в рационы), панель статистики рационов и сценар готовки.
ui/cook/cook = Готовить
ui/cook/close = Выйти
diff --git a/manifest.json b/manifest.json
index bd0603d..5d78130 100644
--- a/manifest.json
+++ b/manifest.json
@@ -3,12 +3,15 @@
"name": "Cooking",
"version": "1.0.0",
"author": "ct",
- "description": "Cooking mechanic - full autonomous mod",
+ "description": "Autonomous cooking mechanic: the kitchen workbench overlay in home_kitchen with recipe-based cooking (e.g. wolf meat into rations), a ration statistics panel and the home_kitchen_cooking scenario.",
"category": "gameplay",
"sources": [
"src/*.cs"
],
- "depends": [ "visual_novel_core>=1", "interface>=1" ],
+ "test_sources": [
+ "tests/*.cs"
+ ],
+ "depends": [ "visual_novel_core>=1", "interface>=1", "items_base>=1", "time_base>=1" ],
"ui": [
{
"id": "cooking_workbench",
@@ -31,4 +34,4 @@
"priority": 20
}
]
-}
+}
\ No newline at end of file
diff --git a/src/CookingSettingsForTests.cs b/src/CookingSettingsForTests.cs
new file mode 100644
index 0000000..ecadbf8
--- /dev/null
+++ b/src/CookingSettingsForTests.cs
@@ -0,0 +1,18 @@
+using Cthangover.Core.Settings;
+
+namespace Cthangover.Cooking
+{
+ ///
+ /// Test accessor over : the real class exposes
+ /// RationsPerCharacter with a private setter because only the core
+ /// settings pipeline may write it, and the getter is static — mod unit
+ /// tests exercise the clamp through the public ReadValues contract
+ /// and assert via this static passthrough, so no reflection into private
+ /// members is needed. Stateless and harmless in production builds.
+ ///
+ public static class CookingSettingsForTests
+ {
+ /// Current parsed value, for test assertions.
+ public static int RationsPerCharacter => CookingSettings.RationsPerCharacter;
+ }
+}
diff --git a/src/ui/RationStatisticsPanel.cs b/src/ui/RationStatisticsPanel.cs
index 7a80dcb..b0b1890 100644
--- a/src/ui/RationStatisticsPanel.cs
+++ b/src/ui/RationStatisticsPanel.cs
@@ -5,6 +5,7 @@ using Cthangover.Core.Settings;
using Cthangover.Core.UI;
using Godot;
+using Cthangover.Mods.Interface.Base.Lists;
namespace Mods.Cooking.Rations
{
///
diff --git a/src/ui/workbench/RecipeIconItemBehaviour.cs b/src/ui/workbench/RecipeIconItemBehaviour.cs
index 690a426..40bfeef 100644
--- a/src/ui/workbench/RecipeIconItemBehaviour.cs
+++ b/src/ui/workbench/RecipeIconItemBehaviour.cs
@@ -5,6 +5,7 @@ using Cthangover.Core.UI.Theming;
using Cthangover.Core.Utils;
using Godot;
+using Cthangover.Mods.Interface.Base.Lists;
namespace Mods.Cooking.Workbench
{
///
diff --git a/src/ui/workbench/RecipeItemBehaviour.cs b/src/ui/workbench/RecipeItemBehaviour.cs
index d10ea81..7bb3d98 100644
--- a/src/ui/workbench/RecipeItemBehaviour.cs
+++ b/src/ui/workbench/RecipeItemBehaviour.cs
@@ -6,6 +6,7 @@ using Cthangover.Core.UI;
using Cthangover.Core.UI.Theming;
using Godot;
+using Cthangover.Mods.Interface.Base.Lists;
namespace Mods.Cooking.Workbench
{
///
diff --git a/src/ui/workbench/WorkbenchPanel.cs b/src/ui/workbench/WorkbenchPanel.cs
index fb22cde..82214b9 100644
--- a/src/ui/workbench/WorkbenchPanel.cs
+++ b/src/ui/workbench/WorkbenchPanel.cs
@@ -9,9 +9,11 @@ using Cthangover.Core.Settings;
using Cthangover.Core.UI;
using Cthangover.Core.UI.Theming;
using Cthangover.Core.UI.Tool;
+using Cthangover.Mods.Time;
using Cthangover.Core.UI.Inventory;
using Godot;
+using Cthangover.Mods.Interface.Base.Lists;
namespace Mods.Cooking.Workbench
{
///
@@ -122,12 +124,12 @@ namespace Mods.Cooking.Workbench
///
/// Hides the workbench panel and plays a UI close-click sound
- /// via the scene's node.
+ /// via the scene's node.
///
public void OnCloseClick()
{
Hide();
- var audioService = SceneContextNode.FindNode("AudioService");
+ var audioService = SceneContextNode.FindNode("AudioService") as IAudioService;
audioService?.PlaySound("ui/close_click", SoundType.UI);
}
@@ -160,7 +162,7 @@ namespace Mods.Cooking.Workbench
var timer = SceneContextNode.FindNode("TimeController");
timer?.UpdateRenderedTime();
- var audioService = SceneContextNode.FindNode("AudioService");
+ var audioService = SceneContextNode.FindNode("AudioService") as IAudioService;
audioService?.PlaySound("ui/cook", SoundType.UI);
foreach (var recipe in recipes)
diff --git a/tests/CookingModTests.cs b/tests/CookingModTests.cs
new file mode 100644
index 0000000..168f124
--- /dev/null
+++ b/tests/CookingModTests.cs
@@ -0,0 +1,122 @@
+using System.Text.Json;
+using System.Threading.Tasks;
+using Cthangover.Core.Items;
+using Cthangover.Core.Mods.Testing;
+using Cthangover.Core.Settings;
+
+namespace Cthangover.Cooking.Tests
+{
+ ///
+ /// Unit tests of the cooking mod's pure logic (plan-mod-testing.md §4.2).
+ /// Compiled by ModTestEngine into a separate {modId}__tests assembly,
+ /// only in the explicit test mode. No scene-tree interaction: anything
+ /// requiring a live scene belongs to the integration contour.
+ ///
+ public class CookingModTests
+ {
+ [ModTest("recipe/wolf_meat_data_shape")]
+ static void WolfMeatRecipeDataShape(ModTestContext ctx)
+ {
+ // The recipe JSON declared by the mod must keep the exact shape
+ // RecipeFactory consumes: single recipe, cooking type, inputs/outputs.
+ var raw = ctx.ReadFileText("recipes/cooking.json");
+ ModAssert.NotNull(raw, "recipes/cooking.json exists");
+ using var doc = JsonDocument.Parse(raw);
+ var items = doc.RootElement.GetProperty("Items");
+ ModAssert.Equal(1, items.GetArrayLength(), "recipe count");
+
+ var recipe = items[0];
+ ModAssert.Equal("recipe/wolf_meat_to_ration", recipe.GetProperty("Id").GetString(), "recipe id");
+ ModAssert.Equal("Cooking", recipe.GetProperty("Type").GetString(), "workbench type");
+ ModAssert.Equal(15, recipe.GetProperty("Time").GetInt32(), "crafting time");
+ ModAssert.Equal(2, recipe.GetProperty("Input").GetArrayLength(), "input ingredient count");
+ ModAssert.Equal(1, recipe.GetProperty("Output").GetArrayLength(), "output ingredient count");
+
+ var meat = recipe.GetProperty("Input")[0];
+ ModAssert.Equal("food/wolf_meat", meat.GetProperty("Id").GetString(), "input meat id");
+ ModAssert.Equal(1, meat.GetProperty("Count").GetInt32(), "input meat count");
+ var branches = recipe.GetProperty("Input")[1];
+ ModAssert.Equal("resource/branches", branches.GetProperty("Id").GetString(), "input branches id");
+ ModAssert.Equal(2, branches.GetProperty("Count").GetInt32(), "input branches count");
+ }
+
+ [ModTest("recipe/recipe_data_unlock_flow")]
+ static void RecipeDataUnlockFlow(ModTestContext ctx)
+ {
+ // The unlock contract WolfMeatToRation relies on: Add is idempotent
+ // and Has mirrors membership. Exercises the same flow the item
+ // action performs, through the public RecipeData API.
+ var data = new RecipeData();
+ ModAssert.False(data.Has("recipe/wolf_meat_to_ration"), "fresh state does not know the recipe");
+ ModAssert.True(data.Add("recipe/wolf_meat_to_ration"), "first Add unlocks");
+ ModAssert.False(data.Add("recipe/wolf_meat_to_ration"), "second Add is a no-op");
+ ModAssert.True(data.Has("recipe/wolf_meat_to_ration"), "recipe stays unlocked");
+
+ // Round-trip through JSON: Data is a public property, the save
+ // system serializes RecipeData as a whole.
+ var json = JsonSerializer.Serialize(data);
+ var restored = JsonSerializer.Deserialize(json);
+ ModAssert.True(restored.Has("recipe/wolf_meat_to_ration"), "unlock survives JSON round-trip");
+ }
+
+ [ModTest("settings/rations_per_character_clamp")]
+ static void SettingsClamp(ModTestContext ctx)
+ {
+ // CookingSettings.ReadValues clamps below-minimum input to 1 —
+ // verify through a real DataBlob key-value round-trip.
+ var blob = new DataBlob();
+ blob.SetInt("rations_per_character", -5);
+ new global::Cthangover.Cooking.CookingSettings().ReadValues(blob);
+ ModAssert.Equal(1, global::Cthangover.Cooking.CookingSettingsForTests.RationsPerCharacter, "negative value clamps to 1");
+
+ var blob2 = new DataBlob();
+ blob2.SetInt("rations_per_character", 4);
+ new global::Cthangover.Cooking.CookingSettings().ReadValues(blob2);
+ ModAssert.Equal(4, global::Cthangover.Cooking.CookingSettingsForTests.RationsPerCharacter, "valid value passes through");
+
+ // Write/read round-trip through the blob's own serialization.
+ new global::Cthangover.Cooking.CookingSettings().WriteValues(blob2);
+ ModAssert.Equal(4, blob2.GetInt("rations_per_character", 0), "WriteValues stores the value");
+ }
+
+ [ModTest("manifest/declares_required_dependencies")]
+ static void ManifestDependencies(ModTestContext ctx)
+ {
+ // The cooking UI binds to interface widget types and item/time
+ // subsystems: the manifest must keep these declared, otherwise
+ // compilation against the dependencies breaks silently.
+ var raw = ctx.ReadFileText("manifest.json");
+ ModAssert.NotNull(raw, "manifest.json exists");
+ using var doc = JsonDocument.Parse(raw);
+ var depends = doc.RootElement.GetProperty("depends");
+ var found = new[] { "visual_novel_core", "interface", "items_base", "time_base" };
+ foreach (var dep in found)
+ {
+ var ok = false;
+ foreach (var d in depends.EnumerateArray())
+ {
+ var s = d.GetString() ?? "";
+ var id = s.Split('>', '<', '=', '?')[0];
+ if (id == dep) { ok = true; break; }
+ }
+ ModAssert.True(ok, $"dependency '{dep}' declared");
+ }
+ }
+
+ [ModTest("frames/async_test_spans_frames", TimeoutSeconds = 10)]
+ static async Task AsyncTestSpansFrames(ModTestContext ctx)
+ {
+ // Async tests may wait for engine frames (deferred UI rebuilds,
+ // signals). The recovery cycle between tests must keep the scene
+ // tree intact across those frames: core autoloads and the running
+ // scene survive, and the run still finishes with a report.
+ var tree = (Godot.SceneTree)Godot.Engine.GetMainLoop();
+ for (var i = 0; i < 3; i++)
+ await tree.ToSignal(tree, Godot.SceneTree.SignalName.ProcessFrame);
+
+ ModAssert.True(tree.Root.HasNode("GameData"), "/root/GameData survives frames between tests");
+ ModAssert.NotNull(tree.CurrentScene, "current scene survives frames between tests");
+ ModAssert.NotNull(ctx.ReadFileText("recipes/cooking.json"), "mod files stay readable after awaiting frames");
+ }
+ }
+}