commit 5e32676171f3fd6be9425f7a00ccee902df9582a Author: iisaev Date: Tue Oct 27 01:07:27 2026 +0100 Add reusable crafting stations, recipes and validation diff --git a/README.md b/README.md new file mode 100644 index 0000000..47ffd99 --- /dev/null +++ b/README.md @@ -0,0 +1,43 @@ +# Crafting without a map dependency + +This mod needs `items_base` and `ui_base`. It can be opened from a visual novel choice, a scene interactive or an RPG event. It does not depend on a game, tiles, stats or a battle core. + +1. Define ordinary `item` entities for materials and products. +2. Define a recipe (`Kind: recipe`). +3. List it in a station (`Kind: craft_station`). +4. Run `action craft.open station=my_game/bench` from any scenario. + +```json +{ + "Kind": "recipe", + "Items": [{ + "Id": "my_game/salve", + "Name": "my_game/recipe/salve/name", + "Description": "my_game/recipe/salve/desc", + "Time": 5, + "Input": [{"Id": "my_game/herb", "Count": 2}], + "Output": [{"Id": "my_game/salve", "Count": 1}] + }] +} +``` + +```json +{ + "Kind": "craft_station", + "Items": [{ + "Id": "my_game/bench", + "Name": "my_game/station/name", + "Recipes": ["my_game/salve"] + }] +} +``` + +Names and descriptions are locale keys. Add them to your mod's `locale/ru.properties` and `locale/en.properties`. The Data Editor discovers both kinds from `schemas/crafting.json`; the command palette knows `craft.open` and validates the station reference. No C# is needed to add recipes or stations. + +The window shows materials held/needed, products and live availability. Esc closes it. The shared overlay host blocks walking while it is open. A successful craft advances the game clock by `Time` minutes. Ingredients and products use the existing items save slot; there is no second crafting inventory or separate save format. + +All raw ingredients and outputs must resolve, counts must be positive and totals must fit in an integer. Duplicate ingredient rows count together. A refused craft changes no stack. This deliberately does not use RecipeFactory's tolerant display resolution, which can omit unknown ingredients. + +`CraftLedger` exposes batch arithmetic for other interfaces. `CraftPanel` rebinds its inventory on show after a new game or load. The station is an explicit recipe list; discovery/unlock rules belong to the game's scenarios (offer another station after a quest or flag). + +Run `--run-tests --mod=crafting` for ledger and JSON round-trip tests. RPG Plus's `woodland` and `woodland_pact` suites exercise the window, quests, real victory loot, crafted equipment and save/load together. diff --git a/locale/en.properties b/locale/en.properties new file mode 100644 index 0000000..bf94853 --- /dev/null +++ b/locale/en.properties @@ -0,0 +1,23 @@ +mod_crafting_name = Crafting +mod_crafting_desc = Data-defined crafting stations for any game: ingredients, products and crafting without code. +crafting/create = Craft +crafting/hint = Ingredients are consumed only when you craft. Esc closes. +crafting/ingredients = INGREDIENTS · owned / needed +crafting/products = PRODUCTS +crafting/success = Done. The product is in your bag. +crafting/missing = Not enough ingredients. +crafting/empty = This station has no recipes yet. +crafting/schema/open = Open crafting station +crafting/schema/open_desc = Shows the recipes offered by a station. +crafting/schema/station = Crafting station +crafting/schema/station_desc = The recipes offered by one crafting station. +crafting/schema/name = Name +crafting/schema/recipes = Recipes +crafting/schema/recipe = Recipe +crafting/schema/recipe_desc = Ingredients consumed and products created. +crafting/schema/description = Description +crafting/schema/input = Ingredients +crafting/schema/output = Products +crafting/schema/item = Item +crafting/schema/count = Count +crafting/schema/time = Minutes diff --git a/locale/ru.properties b/locale/ru.properties new file mode 100644 index 0000000..6daef6c --- /dev/null +++ b/locale/ru.properties @@ -0,0 +1,23 @@ +mod_crafting_name = Мастерская +mod_crafting_desc = Рецепты и мастерские для любой игры: материалы, изделия, изготовление без кода. +crafting/create = Изготовить +crafting/hint = Материалы расходуются только при изготовлении. Esc — закрыть. +crafting/ingredients = МАТЕРИАЛЫ · есть / нужно +crafting/products = РЕЗУЛЬТАТ +crafting/success = Готово. Изделие в сумке. +crafting/missing = Не хватает материалов. +crafting/empty = В этой мастерской пока нет рецептов. +crafting/schema/open = Открыть мастерскую +crafting/schema/open_desc = Показывает рецепты указанной мастерской. +crafting/schema/station = Мастерская +crafting/schema/station_desc = Рецепты, доступные в одной мастерской. +crafting/schema/name = Название +crafting/schema/recipes = Рецепты +crafting/schema/recipe = Рецепт +crafting/schema/recipe_desc = Из каких материалов и что изготовить. +crafting/schema/description = Описание +crafting/schema/input = Материалы +crafting/schema/output = Изделия +crafting/schema/item = Предмет +crafting/schema/count = Количество +crafting/schema/time = Минуты diff --git a/manifest.json b/manifest.json new file mode 100644 index 0000000..634fad7 --- /dev/null +++ b/manifest.json @@ -0,0 +1,12 @@ +{ + "id": "crafting", + "name": "Crafting", + "version": "1.0.0", + "author": "ct", + "description": "Genre-neutral crafting stations built from recipe and craft_station data. Opens from any scenario with craft.open station=; validates all ingredients and products before consuming anything. No game or tile-world dependency.", + "category": "gameplay", + "sources": ["src/**/*.cs"], + "test_sources": ["tests/*.cs"], + "depends": ["ui_base>=1", "items_base>=1"], + "ui": [{"id":"crafting", "kind":"overlay", "slot":"ModLastPanel", "scene_path":"scenes/craft_panel.tscn", "once":true, "show_on_start":false, "priority":10}] +} diff --git a/scenes/craft_panel.tscn b/scenes/craft_panel.tscn new file mode 100644 index 0000000..050b73b --- /dev/null +++ b/scenes/craft_panel.tscn @@ -0,0 +1,101 @@ +[gd_scene format=3] + +[ext_resource type="Script" path="res://mods/crafting/src/CraftPanel.cs" id="1"] + +[node name="CraftPanel" type="Control"] +layout_mode = 3 +anchors_preset = 15 +anchor_right = 1.0 +anchor_bottom = 1.0 +grow_horizontal = 2 +grow_vertical = 2 +script = ExtResource("1") + +[node name="Backdrop" type="ColorRect" parent="."] +layout_mode = 1 +anchors_preset = 15 +anchor_right = 1.0 +anchor_bottom = 1.0 +mouse_filter = 2 + +[node name="Panel" type="Panel" parent="."] +layout_mode = 1 +anchor_left = 0.5 +anchor_top = 0.5 +anchor_right = 0.5 +anchor_bottom = 0.5 +offset_left = -560.0 +offset_top = -320.0 +offset_right = 560.0 +offset_bottom = 320.0 + +[node name="Margin" type="MarginContainer" parent="Panel"] +layout_mode = 1 +anchors_preset = 15 +anchor_right = 1.0 +anchor_bottom = 1.0 +theme_override_constants/margin_left = 28 +theme_override_constants/margin_right = 28 +theme_override_constants/margin_top = 24 +theme_override_constants/margin_bottom = 24 + +[node name="Root" type="VBoxContainer" parent="Panel/Margin"] +layout_mode = 2 +theme_override_constants/separation = 20 + +[node name="Header" type="HBoxContainer" parent="Panel/Margin/Root"] +layout_mode = 2 + +[node name="Title" type="Label" parent="Panel/Margin/Root/Header"] +layout_mode = 2 +size_flags_horizontal = 3 +theme_override_font_sizes/font_size = 26 + +[node name="Close" type="Button" parent="Panel/Margin/Root/Header"] +layout_mode = 2 +custom_minimum_size = Vector2(44, 44) +text = "✕" + +[node name="Body" type="HBoxContainer" parent="Panel/Margin/Root"] +layout_mode = 2 +size_flags_vertical = 3 +theme_override_constants/separation = 36 + +[node name="Scroll" type="ScrollContainer" parent="Panel/Margin/Root/Body"] +layout_mode = 2 +size_flags_horizontal = 3 +horizontal_scroll_mode = 0 + +[node name="Recipes" type="VBoxContainer" parent="Panel/Margin/Root/Body/Scroll"] +layout_mode = 2 +size_flags_horizontal = 3 +theme_override_constants/separation = 10 + +[node name="Details" type="VBoxContainer" parent="Panel/Margin/Root/Body"] +layout_mode = 2 +size_flags_horizontal = 3 +theme_override_constants/separation = 16 + +[node name="Icon" type="TextureRect" parent="Panel/Margin/Root/Body/Details"] +layout_mode = 2 +custom_minimum_size = Vector2(100, 100) +expand_mode = 1 +stretch_mode = 5 + +[node name="Description" type="Label" parent="Panel/Margin/Root/Body/Details"] +layout_mode = 2 +autowrap_mode = 3 + +[node name="Ingredients" type="Label" parent="Panel/Margin/Root/Body/Details"] +layout_mode = 2 +size_flags_vertical = 3 +autowrap_mode = 3 + +[node name="Craft" type="Button" parent="Panel/Margin/Root/Body/Details"] +layout_mode = 2 +custom_minimum_size = Vector2(0, 48) +text = "crafting/create" + +[node name="Feedback" type="Label" parent="Panel/Margin/Root"] +layout_mode = 2 +autowrap_mode = 3 diff --git a/schemas/crafting.json b/schemas/crafting.json new file mode 100644 index 0000000..4fbe800 --- /dev/null +++ b/schemas/crafting.json @@ -0,0 +1,113 @@ +{ + "Actions": [ + { + "Id": "craft.open", + "Name": "crafting/schema/open", + "Description": "crafting/schema/open_desc", + "Category": "items", + "Params": [ + { + "Key": "station", + "Type": "ref", + "Kind": "craft_station", + "Required": true, + "Name": "crafting/schema/station" + } + ] + } + ], + "Kinds": [ + { + "Kind": "craft_station", + "Name": "crafting/schema/station", + "Description": "crafting/schema/station_desc", + "Folder": "stations", + "NewId": "{mod}/new_station", + "Order": 14, + "Fields": [ + { + "Key": "Name", + "Type": "locale", + "KeyPattern": "{mod}/station/{id}", + "Name": "crafting/schema/name" + }, + { + "Key": "Recipes", + "Type": "ref_list", + "Kind": "recipe", + "Name": "crafting/schema/recipes" + } + ] + }, + { + "Kind": "recipe", + "Name": "crafting/schema/recipe", + "Description": "crafting/schema/recipe_desc", + "Folder": "recipes", + "NewId": "{mod}/new_recipe", + "Order": 15, + "Fields": [ + { + "Key": "Name", + "Type": "locale", + "KeyPattern": "{mod}/recipe/{id}/name", + "Name": "crafting/schema/name" + }, + { + "Key": "Description", + "Type": "locale", + "KeyPattern": "{mod}/recipe/{id}/desc", + "Name": "crafting/schema/description" + }, + { + "Key": "Time", + "Type": "int", + "Default": "5", + "Name": "crafting/schema/time" + }, + { + "Key": "Input", + "Type": "list", + "Name": "crafting/schema/input", + "Of": [ + { + "Key": "Id", + "Type": "ref", + "Kind": "item", + "Required": true, + "Name": "crafting/schema/item" + }, + { + "Key": "Count", + "Type": "int", + "Default": "1", + "Required": true, + "Name": "crafting/schema/count" + } + ] + }, + { + "Key": "Output", + "Type": "list", + "Name": "crafting/schema/output", + "Of": [ + { + "Key": "Id", + "Type": "ref", + "Kind": "item", + "Required": true, + "Name": "crafting/schema/item" + }, + { + "Key": "Count", + "Type": "int", + "Default": "1", + "Required": true, + "Name": "crafting/schema/count" + } + ] + } + ] + } + ] +} diff --git a/src/CraftLedger.cs b/src/CraftLedger.cs new file mode 100644 index 0000000..2a6abca --- /dev/null +++ b/src/CraftLedger.cs @@ -0,0 +1,70 @@ +namespace Cthangover.Mods.Crafting +{ + using System; + using System.Collections.Generic; + using Cthangover.Core.Mods.Resolvers; + using Cthangover.Core.Items; + + /// + /// Craft arithmetic against raw recipe definitions. RecipeFactory tolerates unknown + /// ingredients by dropping them, which is useful for display but unsafe for consumption. + /// Here malformed entries, duplicate totals, overflow and every product are checked + /// before the first mutation. Duplicate ingredient rows count together. + /// + public static class CraftLedger + { + /// Checks a complete batch without changing inventory; invalid data refuses the whole craft. + public static bool CanCraft(IInventory inventory, RecipeInfo recipe, int count = 1) + { + if (inventory == null || recipe == null || count < 1 || recipe.Time < 0 || (long)recipe.Time * count > int.MaxValue) + return false; + var inputs = Totals(recipe.Input, count); + var outputs = Totals(recipe.Output, count); + if (inputs == null || outputs == null) + return false; + foreach (var pair in inputs) + if (inventory.CheckCount(pair.Key) < pair.Value) + return false; + foreach (var pair in outputs) + { + var consumed = inputs.TryGetValue(pair.Key, out var amount) ? amount : 0; + if ((long)inventory.CheckCount(pair.Key) - consumed + pair.Value > int.MaxValue) + return false; + } + return true; + } + + /// Consumes and produces one validated batch on the game thread; refusal leaves every stack untouched. + public static bool TryCraft(IInventory inventory, RecipeInfo recipe, int count = 1) + { + if (!CanCraft(inventory, recipe, count)) + return false; + var inputs = Totals(recipe.Input, count); + var outputs = Totals(recipe.Output, count); + foreach (var pair in inputs) + inventory.Remove(pair.Key, pair.Value); + foreach (var pair in outputs) + inventory.Add(pair.Key, pair.Value); + return true; + } + + private static Dictionary Totals(List entries, int count) + { + if (entries == null || entries.Count == 0) + return null; + var definitions = Entities.Collect(); + var totals = new Dictionary(StringComparer.Ordinal); + foreach (var entry in entries) + { + if (entry == null || entry.Count <= 0 || string.IsNullOrWhiteSpace(entry.ID) || !definitions.ContainsKey(entry.ID)) + return null; + totals.TryGetValue(entry.ID, out var previous); + var total = previous + (long)entry.Count * count; + if (total > int.MaxValue) + return null; + totals[entry.ID] = (int)total; + } + return totals; + } + } +} diff --git a/src/CraftOpenAction.cs b/src/CraftOpenAction.cs new file mode 100644 index 0000000..93ccfdb --- /dev/null +++ b/src/CraftOpenAction.cs @@ -0,0 +1,34 @@ +namespace Cthangover.Mods.Crafting +{ + using Cthangover.Core.Actions; + using Cthangover.Core.Mods.Resolvers; + using Cthangover.Core.UI; + + /// Opens a data-defined station using the shared overlay host, including its walking/input blocker. + public class CraftOpenAction : IScenarioAction + { + /// Scenario action id. + public string Name => "craft.open"; + + /// Resolves the station from the current entity snapshot and opens its window. + public void Run(IActionContext ctx) + { + var id = ctx.GetParam("station")?.Trim(); + var stations = Entities.Collect(); + if (id == null || !stations.TryGetValue(id, out var station)) + { + ctx.LogWarning("CRAFT", $"craft.open: unknown station '{id}'"); + return; + } + var entry = UiRegistry.Get(CraftPanel.EntryId); + if (entry == null || UiHost.Instance?.EnsureInstance(entry) is not CraftPanel panel) + { + ctx.LogWarning("CRAFT", "craft.open: crafting overlay is unavailable"); + return; + } + panel.SetStation(station); + if (!panel.Visible) + UiRegistry.Toggle(CraftPanel.EntryId); + } + } +} diff --git a/src/CraftPanel.cs b/src/CraftPanel.cs new file mode 100644 index 0000000..0442a74 --- /dev/null +++ b/src/CraftPanel.cs @@ -0,0 +1,170 @@ +namespace Cthangover.Mods.Crafting +{ + using System.Collections.Generic; + using Cthangover.Core.Factories.Impls; + using Cthangover.Core.Items; + using Cthangover.Core.Mods.Resolvers; + using Cthangover.Core.Settings; + using Cthangover.Core.UI; + using Cthangover.Core.UI.Theming; + using Cthangover.Mods.Interface.Base.Lists; + using Cthangover.Mods.Interface.Theming; + using Godot; + + /// + /// Station browser with a recipe list, live ingredient counts and a product preview. + /// The scene owns layout; rows are generated from the station's data. Inventory is + /// rebound on every show because loading or starting a game replaces it. + /// + public partial class CraftPanel : Widget + { + /// Registry entry used by craft.open and the shell's overlay blocker. + public const string EntryId = "crafting"; + private CraftStation station; + private RecipeInfo selected; + private IInventory inventory; + private VBoxContainer recipes; + private Label title; + private Label description; + private Label ingredients; + private Label feedback; + private TextureRect icon; + private Button craft; + private bool crafting; + + /// Switches stations without storing game state in the window. + public void SetStation(CraftStation value) + { + station = value; + selected = null; + if (inventory != null) + Rebuild(); + } + + protected override void OnceConstruct() + { + title = GetNode