Add reusable crafting stations, recipes and validation

This commit is contained in:
cthangover committed 2026-10-27 01:07:27 +01:00
commit 5e32676171
13 files changed
+740

No files matched your search

+43
View File
@@ -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.
+23
View File
@@ -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
+23
View File
@@ -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 = Минуты
+12
View File
@@ -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}]
}
+101
View File
@@ -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
+113
View File
@@ -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"
}
]
}
]
}
]
}
+70
View File
@@ -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;
}
}
}
+34
View File
@@ -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);
}
}
}
+170
View File
@@ -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();
}
}
}
+20
View File
@@ -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);
}
}
+24
View File
@@ -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();
}
}
+72
View File
@@ -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");
}
}
}
+35
View File
@@ -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");
}
}
}