Add disassembly support for storage items

- Add DisassemblyHelper class for runtime disassembly formula management
- Binder Sheet disassembles into 2x Polyethylene Sheet
- Card Binder disassembles into 4x Polyethylene Sheet
- Cards and packs intentionally have no disassembly (collectibles)
- Document disassembly pattern in CLAUDE.md (struct boxing for reflection)
- Fix .claude/settings.json permission patterns

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
Cal Corum 2025-12-29 08:58:07 -06:00
parent 30285644d6
commit 8c7a131869
4 changed files with 378 additions and 4 deletions

View File

@ -1,10 +1,10 @@
{
"$schema": "https://claude.ai/code/settings-schema.json",
"$schema": "https://json.schemastore.org/claude-code-settings.json",
"permissions": {
"allow": [
"Bash(dotnet build*)",
"Bash(dotnet clean*)",
"Bash(dotnet restore*)",
"Bash(dotnet build:*)",
"Bash(dotnet clean:*)",
"Bash(dotnet restore:*)",
"WebFetch(domain:code.claude.com)"
]
}

View File

@ -83,6 +83,43 @@ The game loads mods from `Duckov_Data/Mods/`. Each mod requires:
- **`StorageHelper`** (`src/StorageHelper.cs`): Helper class for creating storage items with multi-tag slot filtering, enabling hierarchical storage systems.
- **`DisassemblyHelper`** (`src/DisassemblyHelper.cs`): Helper class for adding disassembly (deconstruction) formulas to custom items at runtime.
### Disassembly System Pattern
The game's disassembly system uses `DecomposeDatabase` (singleton in `Duckov.Economy` namespace) to store formulas. Key implementation details:
**Types:**
- `DecomposeFormula`: Contains `item` (TypeID), `valid` (bool), and `result` (Cost)
- `Cost`: A **struct** with `money` (long) and `items` (ItemEntry[])
- `Cost+ItemEntry`: **Nested struct** inside Cost with `id` (int) and `amount` (long)
**Critical Reflection Pattern (Cost is a struct):**
```csharp
// Cost is a VALUE TYPE - must box before reflection, then unbox
Cost result = new Cost { money = 0L };
object boxedResult = result; // Box the struct
itemsField.SetValue(boxedResult, itemEntries); // Modify boxed copy
result = (Cost)boxedResult; // Unbox back
formula.result = result;
```
**Adding a Formula:**
1. Get `DecomposeDatabase.Instance`
2. Access private `entries` field via reflection
3. Create `DecomposeFormula` with item TypeID and `valid = true`
4. Create `Cost` with money and/or items array
5. For items: Create `Cost+ItemEntry[]` via reflection (type found from `Cost.items` field type)
6. Add formula to list, write back via reflection
7. Call private `RebuildDictionary()` method
**Known Material TypeIDs:**
- Polyethylene Sheet: `764`
**Cleanup:** Track added item IDs and remove formulas in `OnDestroy()`.
See `DisassemblyHelper.cs` for complete implementation.
### Dependencies
- **HarmonyLoadMod** (Workshop ID: 3589088839): Required mod dependency providing Harmony 2.4.1. Referenced at build time but not bundled to avoid version conflicts.
@ -153,6 +190,10 @@ Key namespaces and APIs from the game:
- Spawn storage items (Card Binder, Binder Sheet)
- Spawn random cards by rarity
- Draggable OnGUI window interface
- Disassembly support for storage items:
- Binder Sheet → 2x Polyethylene Sheet
- Card Binder → 4x Polyethylene Sheet
- Cards/packs intentionally have no disassembly (collectibles)
- Deploy/remove scripts for quick iteration
- Unit tests for parsing logic and pack system

293
src/DisassemblyHelper.cs Normal file
View File

@ -0,0 +1,293 @@
using System;
using System.Collections.Generic;
using System.Linq;
using System.Reflection;
using UnityEngine;
using Duckov.Economy;
namespace TradingCardMod
{
/// <summary>
/// Helper class for adding disassembly formulas to custom items.
/// Uses reflection to modify the game's DecomposeDatabase at runtime.
/// Based on the approach from AdditionalCollectibles mod.
/// </summary>
public static class DisassemblyHelper
{
// Common material IDs from the game
public static readonly int Metal01 = 91043;
public static readonly int Metal02 = 91044;
public static readonly int Metal03 = 91045;
public static readonly int Plastic01 = 91046;
public static readonly int Plastic02 = 91047;
public static readonly int Plastic03 = 91048;
// Track added formulas for cleanup
private static readonly List<int> _addedItemIds = new List<int>();
// Cached reflection info for ItemEntry type
private static Type? _itemEntryType;
private static FieldInfo? _itemEntryIdField;
private static FieldInfo? _itemEntryAmountField;
/// <summary>
/// Initializes the ItemEntry type info via reflection.
/// Gets the type from Cost.items field to find the correct ItemEntry type.
/// </summary>
private static bool InitializeItemEntryType()
{
if (_itemEntryType != null)
return true;
try
{
// Get ItemEntry type from Cost.items field (more reliable than searching by name)
FieldInfo? itemsField = typeof(Cost).GetField("items", BindingFlags.Instance | BindingFlags.Public);
if (itemsField == null)
{
Debug.LogError("[TradingCardMod] Could not find Cost.items field!");
return false;
}
// items is ItemEntry[], so get the element type
Type itemsType = itemsField.FieldType;
if (!itemsType.IsArray)
{
Debug.LogError("[TradingCardMod] Cost.items is not an array!");
return false;
}
_itemEntryType = itemsType.GetElementType();
if (_itemEntryType == null)
{
Debug.LogError("[TradingCardMod] Could not get ItemEntry element type!");
return false;
}
Debug.Log($"[TradingCardMod] Found ItemEntry type: {_itemEntryType.FullName}");
_itemEntryIdField = _itemEntryType.GetField("id", BindingFlags.Instance | BindingFlags.Public);
_itemEntryAmountField = _itemEntryType.GetField("amount", BindingFlags.Instance | BindingFlags.Public);
if (_itemEntryIdField == null || _itemEntryAmountField == null)
{
// Try non-public fields
_itemEntryIdField = _itemEntryType.GetField("id", BindingFlags.Instance | BindingFlags.NonPublic);
_itemEntryAmountField = _itemEntryType.GetField("amount", BindingFlags.Instance | BindingFlags.NonPublic);
}
if (_itemEntryIdField == null || _itemEntryAmountField == null)
{
// Log available fields for debugging
var fields = _itemEntryType.GetFields(BindingFlags.Instance | BindingFlags.Public | BindingFlags.NonPublic);
Debug.LogError($"[TradingCardMod] Could not find ItemEntry id/amount fields! Available: {string.Join(", ", fields.Select(f => f.Name))}");
return false;
}
Debug.Log($"[TradingCardMod] ItemEntry fields: id={_itemEntryIdField.Name}, amount={_itemEntryAmountField.Name}");
return true;
}
catch (Exception ex)
{
Debug.LogError($"[TradingCardMod] Error initializing ItemEntry type: {ex.Message}");
return false;
}
}
/// <summary>
/// Creates an ItemEntry array using reflection.
/// </summary>
private static Array? CreateItemEntryArray((int id, long amount)[] items)
{
if (!InitializeItemEntryType() || _itemEntryType == null)
return null;
Array array = Array.CreateInstance(_itemEntryType, items.Length);
for (int i = 0; i < items.Length; i++)
{
object entry = Activator.CreateInstance(_itemEntryType)!;
_itemEntryIdField!.SetValue(entry, items[i].id);
_itemEntryAmountField!.SetValue(entry, items[i].amount);
array.SetValue(entry, i);
}
return array;
}
/// <summary>
/// Adds a disassembly formula for an item.
/// </summary>
/// <param name="itemId">The TypeID of the item that can be disassembled</param>
/// <param name="money">Money reward when disassembled (0 for none)</param>
/// <param name="resultItems">Array of (itemId, amount) tuples for materials received</param>
/// <returns>True if formula was added successfully</returns>
public static bool AddFormula(int itemId, long money, params (int id, long amount)[] resultItems)
{
try
{
DecomposeDatabase instance = DecomposeDatabase.Instance;
if (instance == null)
{
Debug.LogError("[TradingCardMod] DecomposeDatabase.Instance is null!");
return false;
}
// Get the private entries field via reflection
FieldInfo? entriesField = typeof(DecomposeDatabase).GetField("entries",
BindingFlags.Instance | BindingFlags.NonPublic);
if (entriesField == null)
{
Debug.LogError("[TradingCardMod] Could not find 'entries' field on DecomposeDatabase!");
return false;
}
DecomposeFormula[] currentEntries = (DecomposeFormula[])entriesField.GetValue(instance);
List<DecomposeFormula> formulaList = new List<DecomposeFormula>(currentEntries);
// Check if formula already exists for this item
foreach (DecomposeFormula existing in formulaList)
{
if (existing.item == itemId)
{
Debug.LogWarning($"[TradingCardMod] Disassembly formula already exists for item {itemId}, skipping");
return false;
}
}
// Create new formula
DecomposeFormula newFormula = new DecomposeFormula
{
item = itemId,
valid = true
};
// Create cost (result items)
// Cost is a struct, so we need to box it for reflection to work
Cost result = new Cost
{
money = money
};
// Build item entries array using reflection
if (resultItems.Length > 0)
{
Array? itemEntries = CreateItemEntryArray(resultItems);
if (itemEntries == null)
{
Debug.LogError("[TradingCardMod] Failed to create ItemEntry array!");
return false;
}
// Set items field on Cost using reflection (it expects Cost+ItemEntry[])
// Must box the struct first, modify, then unbox
FieldInfo? itemsField = typeof(Cost).GetField("items", BindingFlags.Instance | BindingFlags.Public);
if (itemsField != null)
{
object boxedResult = result; // Box the struct
itemsField.SetValue(boxedResult, itemEntries);
result = (Cost)boxedResult; // Unbox back
Debug.Log($"[TradingCardMod] Set {itemEntries.Length} items on Cost for item {itemId}");
}
}
newFormula.result = result;
// Add to list and write back
formulaList.Add(newFormula);
entriesField.SetValue(instance, formulaList.ToArray());
// Track for cleanup
if (!_addedItemIds.Contains(itemId))
{
_addedItemIds.Add(itemId);
}
// Rebuild internal dictionary for lookup performance
MethodInfo? rebuildMethod = typeof(DecomposeDatabase).GetMethod("RebuildDictionary",
BindingFlags.Instance | BindingFlags.NonPublic);
rebuildMethod?.Invoke(instance, null);
Debug.Log($"[TradingCardMod] Added disassembly formula for item {itemId}");
return true;
}
catch (Exception ex)
{
Debug.LogError($"[TradingCardMod] Error adding disassembly formula: {ex.Message}");
return false;
}
}
/// <summary>
/// Adds a simple money-only disassembly formula.
/// </summary>
/// <param name="itemId">The TypeID of the item</param>
/// <param name="money">Money reward when disassembled</param>
/// <returns>True if formula was added successfully</returns>
public static bool AddMoneyOnlyFormula(int itemId, long money)
{
return AddFormula(itemId, money);
}
/// <summary>
/// Removes all disassembly formulas added by this mod.
/// Should be called during mod cleanup/unload.
/// </summary>
public static void Cleanup()
{
try
{
DecomposeDatabase instance = DecomposeDatabase.Instance;
if (instance == null)
{
Debug.LogWarning("[TradingCardMod] DecomposeDatabase.Instance is null during cleanup");
_addedItemIds.Clear();
return;
}
FieldInfo entriesField = typeof(DecomposeDatabase).GetField("entries",
BindingFlags.Instance | BindingFlags.NonPublic);
if (entriesField == null)
{
Debug.LogWarning("[TradingCardMod] Could not find 'entries' field during cleanup");
_addedItemIds.Clear();
return;
}
DecomposeFormula[] currentEntries = (DecomposeFormula[])entriesField.GetValue(instance);
List<DecomposeFormula> formulaList = new List<DecomposeFormula>(currentEntries);
int removedCount = 0;
for (int i = formulaList.Count - 1; i >= 0; i--)
{
if (_addedItemIds.Contains(formulaList[i].item))
{
formulaList.RemoveAt(i);
removedCount++;
}
}
entriesField.SetValue(instance, formulaList.ToArray());
// Rebuild dictionary
MethodInfo rebuildMethod = typeof(DecomposeDatabase).GetMethod("RebuildDictionary",
BindingFlags.Instance | BindingFlags.NonPublic);
rebuildMethod?.Invoke(instance, null);
Debug.Log($"[TradingCardMod] Removed {removedCount} disassembly formulas");
_addedItemIds.Clear();
}
catch (Exception ex)
{
Debug.LogError($"[TradingCardMod] Error during disassembly cleanup: {ex.Message}");
_addedItemIds.Clear();
}
}
/// <summary>
/// Gets the count of disassembly formulas added by this mod.
/// </summary>
public static int GetAddedFormulaCount()
{
return _addedItemIds.Count;
}
}
}

View File

@ -126,6 +126,9 @@ namespace TradingCardMod
// Create card packs
CreateCardPacks();
// Set up disassembly formulas for all items
SetupDisassembly();
Debug.Log("[TradingCardMod] Mod initialized successfully!");
}
catch (Exception ex)
@ -448,6 +451,40 @@ namespace TradingCardMod
Debug.Log($"[TradingCardMod] Created {_registeredPacks.Count} card packs");
}
/// <summary>
/// Sets up disassembly formulas for storage items only.
/// Cards intentionally have no disassembly (they're collectibles).
/// </summary>
private void SetupDisassembly()
{
int formulasAdded = 0;
// Polyethylene Sheet TypeID (base game material)
const int POLYETHYLENE_SHEET_ID = 764;
// Binder Sheet: 2x polyethylene sheet
if (_binderItem != null)
{
if (DisassemblyHelper.AddFormula(BINDER_SHEET_ITEM_ID, 0L, (POLYETHYLENE_SHEET_ID, 2L)))
{
formulasAdded++;
}
}
// Card Binder: 4x polyethylene sheet
if (_cardBoxItem != null)
{
if (DisassemblyHelper.AddFormula(CARD_BINDER_ITEM_ID, 0L, (POLYETHYLENE_SHEET_ID, 4L)))
{
formulasAdded++;
}
}
// No disassembly for cards or packs (collectibles)
Debug.Log($"[TradingCardMod] Added {formulasAdded} disassembly formulas");
}
/// <summary>
/// Update is called every frame. Used for debug input handling.
/// </summary>
@ -919,6 +956,9 @@ namespace TradingCardMod
// Clean up packs
PackHelper.Cleanup();
// Clean up disassembly formulas
DisassemblyHelper.Cleanup();
// Clean up tags
TagHelper.Cleanup();