Add reusable crafting stations, recipes and validation
This commit is contained in:
commit
5e32676171
13 files changed
+740
No files matched your search
@@ -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.
|
||||
@@ -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
|
||||
@@ -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 = Минуты
|
||||
@@ -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=<id>; 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}]
|
||||
}
|
||||
@@ -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
|
||||
@@ -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"
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,70 @@
|
||||
namespace Cthangover.Mods.Crafting
|
||||
{
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using Cthangover.Core.Mods.Resolvers;
|
||||
using Cthangover.Core.Items;
|
||||
|
||||
/// <summary>
|
||||
/// 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.
|
||||
/// </summary>
|
||||
public static class CraftLedger
|
||||
{
|
||||
/// <summary>Checks a complete batch without changing inventory; invalid data refuses the whole craft.</summary>
|
||||
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;
|
||||
}
|
||||
|
||||
/// <summary>Consumes and produces one validated batch on the game thread; refusal leaves every stack untouched.</summary>
|
||||
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<string, int> Totals(List<IngredientInfo> entries, int count)
|
||||
{
|
||||
if (entries == null || entries.Count == 0)
|
||||
return null;
|
||||
var definitions = Entities.Collect<ItemInfo>();
|
||||
var totals = new Dictionary<string, int>(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;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,34 @@
|
||||
namespace Cthangover.Mods.Crafting
|
||||
{
|
||||
using Cthangover.Core.Actions;
|
||||
using Cthangover.Core.Mods.Resolvers;
|
||||
using Cthangover.Core.UI;
|
||||
|
||||
/// <summary>Opens a data-defined station using the shared overlay host, including its walking/input blocker.</summary>
|
||||
public class CraftOpenAction : IScenarioAction
|
||||
{
|
||||
/// <summary>Scenario action id.</summary>
|
||||
public string Name => "craft.open";
|
||||
|
||||
/// <summary>Resolves the station from the current entity snapshot and opens its window.</summary>
|
||||
public void Run(IActionContext ctx)
|
||||
{
|
||||
var id = ctx.GetParam("station")?.Trim();
|
||||
var stations = Entities.Collect<CraftStation>();
|
||||
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);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
/// <summary>
|
||||
/// 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.
|
||||
/// </summary>
|
||||
public partial class CraftPanel : Widget
|
||||
{
|
||||
/// <summary>Registry entry used by craft.open and the shell's overlay blocker.</summary>
|
||||
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;
|
||||
|
||||
/// <summary>Switches stations without storing game state in the window.</summary>
|
||||
public void SetStation(CraftStation value)
|
||||
{
|
||||
station = value;
|
||||
selected = null;
|
||||
if (inventory != null)
|
||||
Rebuild();
|
||||
}
|
||||
|
||||
protected override void OnceConstruct()
|
||||
{
|
||||
title = GetNode<Label>("Panel/Margin/Root/Header/Title");
|
||||
recipes = GetNode<VBoxContainer>("Panel/Margin/Root/Body/Scroll/Recipes");
|
||||
description = GetNode<Label>("Panel/Margin/Root/Body/Details/Description");
|
||||
ingredients = GetNode<Label>("Panel/Margin/Root/Body/Details/Ingredients");
|
||||
feedback = GetNode<Label>("Panel/Margin/Root/Feedback");
|
||||
icon = GetNode<TextureRect>("Panel/Margin/Root/Body/Details/Icon");
|
||||
craft = GetNode<Button>("Panel/Margin/Root/Body/Details/Craft");
|
||||
craft.Pressed += OnCraft;
|
||||
GetNode<Button>("Panel/Margin/Root/Header/Close").Pressed += OnClose;
|
||||
GetNode<ColorRect>("Backdrop").Color = new Color(0.015f, 0.025f, 0.03f, 0.72f);
|
||||
GetNode<Panel>("Panel").AddThemeStyleboxOverride("panel", UiKit.Frame(10, UiTokens.Surface0, UiTokens.Line));
|
||||
title.AddThemeColorOverride("font_color", UiTokens.AccentBright);
|
||||
ingredients.AddThemeColorOverride("font_color", UiTokens.Muted);
|
||||
}
|
||||
|
||||
protected override void ShowConstruct()
|
||||
{
|
||||
if (inventory != null)
|
||||
inventory.Change -= OnChanged;
|
||||
inventory = GameData.Instance.Runtime.Inventory;
|
||||
inventory.Change += OnChanged;
|
||||
feedback.Text = Tr("crafting/hint");
|
||||
Rebuild();
|
||||
}
|
||||
|
||||
protected override void HideDestruct()
|
||||
{
|
||||
if (inventory != null)
|
||||
inventory.Change -= OnChanged;
|
||||
inventory = null;
|
||||
}
|
||||
|
||||
protected override void OnInput(InputEvent @event)
|
||||
{
|
||||
if (Visible && @event.IsActionPressed("ui_cancel"))
|
||||
{
|
||||
OnClose();
|
||||
GetViewport().SetInputAsHandled();
|
||||
}
|
||||
}
|
||||
|
||||
private void OnClose() => UiRegistry.Toggle(EntryId);
|
||||
|
||||
private void OnChanged(string id, int count)
|
||||
{
|
||||
if (!crafting)
|
||||
Rebuild();
|
||||
}
|
||||
|
||||
private void Rebuild()
|
||||
{
|
||||
if (station == null || inventory == null)
|
||||
return;
|
||||
title.Text = Tr(station.Name ?? station.ID);
|
||||
foreach (var child in recipes.GetChildren())
|
||||
{
|
||||
recipes.RemoveChild(child);
|
||||
child.QueueFree();
|
||||
}
|
||||
var definitions = Entities.Collect<RecipeInfo>();
|
||||
selected = selected != null && definitions.TryGetValue(selected.ID, out var current) ? current : null;
|
||||
foreach (var id in station.Recipes ?? new List<string>())
|
||||
{
|
||||
if (!definitions.TryGetValue(id, out var recipe))
|
||||
continue;
|
||||
selected ??= recipe;
|
||||
var button = new CraftRecipeButton
|
||||
{
|
||||
RecipeId = id,
|
||||
Text = Tr(recipe.Name ?? id),
|
||||
Alignment = HorizontalAlignment.Left,
|
||||
CustomMinimumSize = new Vector2(240, 64),
|
||||
Icon = recipe.Output?.Count > 0 ? ItemFactory.Instance.Get(recipe.Output[0].ID)?.Sprite : null,
|
||||
ExpandIcon = true,
|
||||
ToggleMode = true,
|
||||
ButtonPressed = selected.ID == id,
|
||||
};
|
||||
button.AddThemeConstantOverride("icon_max_width", 40);
|
||||
button.Selected += OnRecipe;
|
||||
recipes.AddChild(button);
|
||||
}
|
||||
ShowRecipe();
|
||||
}
|
||||
|
||||
private void OnRecipe(string id)
|
||||
{
|
||||
Entities.Collect<RecipeInfo>().TryGetValue(id, out selected);
|
||||
Rebuild();
|
||||
}
|
||||
|
||||
private void ShowRecipe()
|
||||
{
|
||||
craft.Text = Tr("crafting/create");
|
||||
craft.Disabled = !CraftLedger.CanCraft(inventory, selected);
|
||||
description.Text = selected == null ? Tr("crafting/empty") : Tr(selected.Description ?? selected.Name ?? selected.ID);
|
||||
icon.Texture = selected?.Output?.Count > 0 ? ItemFactory.Instance.Get(selected.Output[0].ID)?.Sprite : null;
|
||||
var lines = new List<string>();
|
||||
if (selected != null)
|
||||
{
|
||||
lines.Add(Tr("crafting/ingredients"));
|
||||
foreach (var entry in selected.Input ?? new List<IngredientInfo>())
|
||||
{
|
||||
var item = ItemFactory.Instance.Get(entry.ID);
|
||||
lines.Add($"{Tr(item?.Name ?? entry.ID)} {inventory.CheckCount(entry.ID)} / {entry.Count}");
|
||||
}
|
||||
lines.Add("");
|
||||
lines.Add(Tr("crafting/products"));
|
||||
foreach (var entry in selected.Output ?? new List<IngredientInfo>())
|
||||
lines.Add($"{Tr(ItemFactory.Instance.Get(entry.ID)?.Name ?? entry.ID)} × {entry.Count}");
|
||||
}
|
||||
ingredients.Text = string.Join("\n", lines);
|
||||
}
|
||||
|
||||
private void OnCraft()
|
||||
{
|
||||
crafting = true;
|
||||
var success = CraftLedger.TryCraft(inventory, selected);
|
||||
crafting = false;
|
||||
if (success)
|
||||
GameData.Instance.Runtime.Time.AddTime(0, 0, 0, 0, selected.Time);
|
||||
feedback.Text = Tr(success ? "crafting/success" : "crafting/missing");
|
||||
Rebuild();
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
namespace Cthangover.Mods.Crafting
|
||||
{
|
||||
using System;
|
||||
using Godot;
|
||||
|
||||
/// <summary>A data-driven recipe row carries its id through the parameterless Godot pressed signal.</summary>
|
||||
public partial class CraftRecipeButton : Button
|
||||
{
|
||||
/// <summary>Recipe selected by this row.</summary>
|
||||
public string RecipeId { get; set; }
|
||||
|
||||
/// <summary>Raised by a player click or keyboard activation.</summary>
|
||||
public event Action<string> Selected;
|
||||
|
||||
/// <summary>Uses a named handler so rows do not retain closures over the recipe list.</summary>
|
||||
public CraftRecipeButton() => Pressed += OnPressed;
|
||||
|
||||
private void OnPressed() => Selected?.Invoke(RecipeId);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,24 @@
|
||||
namespace Cthangover.Mods.Crafting
|
||||
{
|
||||
using System.Collections.Generic;
|
||||
using System.Text.Json.Serialization;
|
||||
using Cthangover.Core.Factories;
|
||||
|
||||
/// <summary>
|
||||
/// A station offers an explicit set of existing recipe entities. The same definition
|
||||
/// can be opened from a novel choice, an interactive or a map event; no scene is implied.
|
||||
/// </summary>
|
||||
[EntityKind("craft_station")]
|
||||
public class CraftStation : IIdentifiable
|
||||
{
|
||||
/// <summary>Station id used by craft.open.</summary>
|
||||
[JsonPropertyName("Id")]
|
||||
public string ID { get; set; }
|
||||
|
||||
/// <summary>Localized window title.</summary>
|
||||
public string Name { get; set; }
|
||||
|
||||
/// <summary>Recipe ids offered in order; unknown recipes are omitted by the window.</summary>
|
||||
public List<string> Recipes { get; set; } = new();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,72 @@
|
||||
namespace Cthangover.Mods.Crafting.Tests
|
||||
{
|
||||
using System.Collections.Generic;
|
||||
using System.Linq;
|
||||
using Cthangover.Core.Items;
|
||||
using Cthangover.Core.Mods.Resolvers;
|
||||
using Cthangover.Core.Mods.Testing;
|
||||
|
||||
/// <summary>Refused recipes preserve every stack; duplicate rows are a combined requirement, never a discount.</summary>
|
||||
public class CraftLedgerTests
|
||||
{
|
||||
private static List<string> Goods() => Entities.Collect<ItemInfo>().Keys.Take(2).ToList();
|
||||
|
||||
private static RecipeInfo Recipe(string input, string output) => new()
|
||||
{
|
||||
ID = "test/craft", Time = 5,
|
||||
Input = new List<IngredientInfo> { new() { ID = input, Count = 2 } },
|
||||
Output = new List<IngredientInfo> { new() { ID = output, Count = 1 } },
|
||||
};
|
||||
|
||||
[ModTest("craft/batch_spends_inputs_and_adds_products")]
|
||||
static void Batch(ModTestContext ctx)
|
||||
{
|
||||
var ids = Goods();
|
||||
var bag = new Inventory();
|
||||
bag.Add(ids[0], 7);
|
||||
var recipe = Recipe(ids[0], ids[1]);
|
||||
ModAssert.True(CraftLedger.CanCraft(bag, recipe, 3), "three batches fit");
|
||||
ModAssert.Equal(7, bag.CheckCount(ids[0]), "preview does not consume");
|
||||
ModAssert.True(CraftLedger.TryCraft(bag, recipe, 3), "craft succeeds");
|
||||
ModAssert.Equal(1, bag.CheckCount(ids[0]), "six ingredients consumed");
|
||||
ModAssert.Equal(3, bag.CheckCount(ids[1]), "three products created");
|
||||
}
|
||||
|
||||
[ModTest("craft/missing_duplicate_and_invalid_entries_are_all_or_nothing")]
|
||||
static void Refusals(ModTestContext ctx)
|
||||
{
|
||||
var ids = Goods();
|
||||
var bag = new Inventory();
|
||||
bag.Add(ids[0], 3);
|
||||
var recipe = Recipe(ids[0], ids[1]);
|
||||
recipe.Input.Add(new IngredientInfo { ID = ids[0], Count = 2 });
|
||||
ModAssert.False(CraftLedger.TryCraft(bag, recipe), "duplicate rows require four, not two");
|
||||
recipe.Input.RemoveAt(1);
|
||||
recipe.Output[0].ID = "missing/craft/product";
|
||||
ModAssert.False(CraftLedger.TryCraft(bag, recipe), "missing product refuses consumption");
|
||||
recipe.Output[0].ID = ids[1];
|
||||
recipe.Input.Add(new IngredientInfo { ID = "missing/craft/input", Count = 1 });
|
||||
ModAssert.False(CraftLedger.TryCraft(bag, recipe), "missing ingredient is not silently dropped");
|
||||
recipe.Input.RemoveAt(1);
|
||||
ModAssert.False(CraftLedger.TryCraft(bag, recipe, 0), "zero batch");
|
||||
ModAssert.False(CraftLedger.TryCraft(bag, recipe, -1), "negative batch");
|
||||
ModAssert.False(CraftLedger.TryCraft(bag, recipe, int.MaxValue), "overflowing batch");
|
||||
recipe.Input[0].Count = -1;
|
||||
ModAssert.False(CraftLedger.TryCraft(bag, recipe), "negative ingredient");
|
||||
ModAssert.Equal(3, bag.CheckCount(ids[0]), "all refusals left inputs untouched");
|
||||
ModAssert.Equal(0, bag.CheckCount(ids[1]), "all refusals left products untouched");
|
||||
}
|
||||
|
||||
[ModTest("craft/output_overflow_refuses_before_consumption")]
|
||||
static void Overflow(ModTestContext ctx)
|
||||
{
|
||||
var ids = Goods();
|
||||
var bag = new Inventory();
|
||||
bag.Add(ids[0], 4);
|
||||
bag.Add(ids[1], int.MaxValue);
|
||||
ModAssert.False(CraftLedger.TryCraft(bag, Recipe(ids[0], ids[1])), "full output stack");
|
||||
ModAssert.Equal(4, bag.CheckCount(ids[0]), "input untouched");
|
||||
ModAssert.Equal(int.MaxValue, bag.CheckCount(ids[1]), "output untouched");
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,35 @@
|
||||
namespace Cthangover.Mods.Crafting.Tests
|
||||
{
|
||||
using System.Collections.Generic;
|
||||
using System.Text.Json;
|
||||
using Cthangover.Core.Items;
|
||||
using Cthangover.Core.Mods.Testing;
|
||||
|
||||
/// <summary>Stations and existing recipe definitions are portable JSON data, with no map or game reference.</summary>
|
||||
public class CraftStationTests
|
||||
{
|
||||
[ModTest("craft/station_and_recipe_json_round_trip")]
|
||||
static void RoundTrip(ModTestContext ctx)
|
||||
{
|
||||
var station = new CraftStation { ID = "test/bench", Name = "test/name", Recipes = new List<string> { "test/salve", "test/lantern" } };
|
||||
var restored = JsonSerializer.Deserialize<CraftStation>(JsonSerializer.Serialize(station));
|
||||
ModAssert.Equal(station.ID, restored.ID, "station id");
|
||||
ModAssert.Equal(station.Name, restored.Name, "station title");
|
||||
ModAssert.Equal(2, restored.Recipes.Count, "recipe count");
|
||||
ModAssert.Equal("test/lantern", restored.Recipes[1], "recipe order");
|
||||
var recipe = new RecipeInfo { ID = "test/salve", Name = "test/name", Description = "test/desc", Time = 6,
|
||||
Input = new List<IngredientInfo> { new() { ID = "test/mushroom", Count = 2 } },
|
||||
Output = new List<IngredientInfo> { new() { ID = "test/salve", Count = 1 } } };
|
||||
var again = JsonSerializer.Deserialize<RecipeInfo>(JsonSerializer.Serialize(recipe));
|
||||
ModAssert.Equal(recipe.ID, again.ID, "recipe id");
|
||||
ModAssert.Equal(recipe.Name, again.Name, "recipe name");
|
||||
ModAssert.Equal(recipe.Description, again.Description, "description");
|
||||
ModAssert.Equal((int)recipe.Type, (int)again.Type, "station type retained");
|
||||
ModAssert.Equal(6, again.Time, "duration");
|
||||
ModAssert.Equal("test/mushroom", again.Input[0].ID, "ingredient");
|
||||
ModAssert.Equal(2, again.Input[0].Count, "ingredient count");
|
||||
ModAssert.Equal("test/salve", again.Output[0].ID, "product");
|
||||
ModAssert.Equal(1, again.Output[0].Count, "product count");
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user