diff --git a/CardSets/ExampleSet/images/pack.png b/CardSets/ExampleSet/images/pack.png
new file mode 100644
index 0000000..b343c3f
Binary files /dev/null and b/CardSets/ExampleSet/images/pack.png differ
diff --git a/TradingCardMod.csproj b/TradingCardMod.csproj
index 76f4029..f41943c 100644
--- a/TradingCardMod.csproj
+++ b/TradingCardMod.csproj
@@ -50,6 +50,9 @@
$(DuckovPath)$(SubPath)UnityEngine.InputLegacyModule.dll
+
+ $(DuckovPath)$(SubPath)UniTask.dll
+
diff --git a/src/CardPack.cs b/src/CardPack.cs
new file mode 100644
index 0000000..bf518a0
--- /dev/null
+++ b/src/CardPack.cs
@@ -0,0 +1,155 @@
+using System.Collections.Generic;
+
+namespace TradingCardMod
+{
+ ///
+ /// Represents a card pack that can be opened to receive random cards.
+ ///
+ public class CardPack
+ {
+ ///
+ /// Display name of the pack.
+ ///
+ public string PackName { get; set; } = string.Empty;
+
+ ///
+ /// The card set this pack draws from.
+ ///
+ public string SetName { get; set; } = string.Empty;
+
+ ///
+ /// Path to the pack's icon image.
+ ///
+ public string ImagePath { get; set; } = string.Empty;
+
+ ///
+ /// Filename of the pack image (before full path is set).
+ ///
+ public string ImageFile { get; set; } = string.Empty;
+
+ ///
+ /// In-game currency value of the pack.
+ ///
+ public int Value { get; set; }
+
+ ///
+ /// Weight for appearing in loot (0 = not lootable).
+ ///
+ public float Weight { get; set; } = 0.1f;
+
+ ///
+ /// The slots in this pack, each with their own drop weights.
+ ///
+ public List Slots { get; set; } = new List();
+
+ ///
+ /// Whether this is an auto-generated default pack.
+ ///
+ public bool IsDefault { get; set; }
+
+ ///
+ /// Generates a unique TypeID for this pack.
+ /// Uses hash of set name + pack name for uniqueness.
+ ///
+ public int GenerateTypeID()
+ {
+ // Start from 300000 range for packs (cards are 100000+, storage is 200000+)
+ string uniqueKey = $"CardPack_{SetName}_{PackName}";
+ return 300000 + System.Math.Abs(uniqueKey.GetHashCode() % 100000);
+ }
+ }
+
+ ///
+ /// Represents a single slot in a card pack with drop weights.
+ /// Each slot can use either rarity-based or card-specific weights.
+ ///
+ public class PackSlot
+ {
+ ///
+ /// Whether this slot uses rarity-based weights (true) or specific card weights (false).
+ ///
+ public bool UseRarityWeights { get; set; } = true;
+
+ ///
+ /// Rarity-based weights. Key = rarity string (e.g., "Common"), Value = weight.
+ /// Used when UseRarityWeights is true.
+ ///
+ public Dictionary RarityWeights { get; set; } = new Dictionary();
+
+ ///
+ /// Specific card weights. Key = card name, Value = weight.
+ /// Used when UseRarityWeights is false.
+ ///
+ public Dictionary CardWeights { get; set; } = new Dictionary();
+ }
+
+ ///
+ /// Default slot configurations for auto-generated packs.
+ ///
+ public static class DefaultPackSlots
+ {
+ ///
+ /// Slot 1: Favors common cards.
+ ///
+ public static PackSlot CommonSlot => new PackSlot
+ {
+ UseRarityWeights = true,
+ RarityWeights = new Dictionary
+ {
+ { "Common", 100f },
+ { "Uncommon", 30f },
+ { "Rare", 5f },
+ { "Very Rare", 1f },
+ { "Ultra Rare", 0f },
+ { "Legendary", 0f }
+ }
+ };
+
+ ///
+ /// Slot 2: Balanced towards uncommon.
+ ///
+ public static PackSlot UncommonSlot => new PackSlot
+ {
+ UseRarityWeights = true,
+ RarityWeights = new Dictionary
+ {
+ { "Common", 60f },
+ { "Uncommon", 80f },
+ { "Rare", 20f },
+ { "Very Rare", 5f },
+ { "Ultra Rare", 1f },
+ { "Legendary", 1f }
+ }
+ };
+
+ ///
+ /// Slot 3: Better odds for rare+ cards.
+ ///
+ public static PackSlot RareSlot => new PackSlot
+ {
+ UseRarityWeights = true,
+ RarityWeights = new Dictionary
+ {
+ { "Common", 30f },
+ { "Uncommon", 60f },
+ { "Rare", 60f },
+ { "Very Rare", 20f },
+ { "Ultra Rare", 5f },
+ { "Legendary", 5f }
+ }
+ };
+
+ ///
+ /// Gets the default slots for an auto-generated pack (3 slots).
+ ///
+ public static List GetDefaultSlots()
+ {
+ return new List
+ {
+ CommonSlot,
+ UncommonSlot,
+ RareSlot
+ };
+ }
+ }
+}
diff --git a/src/ModBehaviour.cs b/src/ModBehaviour.cs
index fb5a60b..304d144 100644
--- a/src/ModBehaviour.cs
+++ b/src/ModBehaviour.cs
@@ -17,6 +17,42 @@ namespace TradingCardMod
private static ModBehaviour? _instance;
public static ModBehaviour Instance => _instance!;
+ ///
+ /// Gets the list of registered card items (for loot injection).
+ ///
+ public List- GetRegisteredItems() => _registeredItems;
+
+ ///
+ /// Gets cards for a specific set (used by PackUsageBehavior).
+ ///
+ public List GetCardsBySet(string setName)
+ {
+ if (_cardsBySet.TryGetValue(setName, out var cards))
+ {
+ return cards;
+ }
+ return new List();
+ }
+
+ ///
+ /// Gets the card name to TypeID mapping (used by PackUsageBehavior).
+ ///
+ public Dictionary GetCardTypeIds() => _cardNameToTypeId;
+
+ ///
+ /// Gets slot configuration for a specific pack (used by PackUsageBehavior).
+ ///
+ public List GetPackSlots(string setName, string packName)
+ {
+ string key = $"{setName}|{packName}";
+ if (_packDefinitions.TryGetValue(key, out var pack))
+ {
+ return pack.Slots;
+ }
+ Debug.LogWarning($"[TradingCardMod] Pack definition not found: {key}");
+ return new List();
+ }
+
// Base game item ID to clone (135 is commonly used for collectibles)
private const int BASE_ITEM_ID = 135;
@@ -32,6 +68,14 @@ namespace TradingCardMod
private Item? _binderItem;
private Item? _cardBoxItem;
+ // Track cards and IDs per set for pack creation
+ private Dictionary> _cardsBySet = new Dictionary>();
+ private Dictionary _cardNameToTypeId = new Dictionary();
+ private List
- _registeredPacks = new List
- ();
+
+ // Store pack definitions for runtime lookup (key = "SetName|PackName")
+ private Dictionary _packDefinitions = new Dictionary();
+
// Debug: track spawn cycling
private int _debugSpawnIndex = 0;
private List
- _allSpawnableItems = new List
- ();
@@ -54,6 +98,9 @@ namespace TradingCardMod
try
{
+ // Log all available tags for reference
+ TagHelper.LogAvailableTags();
+
// Create our custom tag first
_tradingCardTag = TagHelper.GetOrCreateTradingCardTag();
@@ -63,10 +110,14 @@ namespace TradingCardMod
// Create storage items
CreateStorageItems();
- // Build spawnable items list (cards + storage)
+ // Create card packs
+ CreateCardPacks();
+
+ // Build spawnable items list (cards + storage + packs)
_allSpawnableItems.AddRange(_registeredItems);
if (_binderItem != null) _allSpawnableItems.Add(_binderItem);
if (_cardBoxItem != null) _allSpawnableItems.Add(_cardBoxItem);
+ _allSpawnableItems.AddRange(_registeredPacks);
Debug.Log("[TradingCardMod] Mod initialized successfully!");
}
@@ -102,6 +153,34 @@ namespace TradingCardMod
Debug.Log($"[TradingCardMod] Total cards loaded: {_loadedCards.Count}");
Debug.Log($"[TradingCardMod] Total items registered: {_registeredItems.Count}");
Debug.Log("[TradingCardMod] DEBUG: Press F9 to spawn items (cycles through cards, then binder, then box)");
+
+ // Clear the search cache so our items can be found
+ ClearSearchCache();
+ }
+
+ ///
+ /// Clears the ItemAssetsCollection search cache so dynamically registered items can be found.
+ ///
+ private void ClearSearchCache()
+ {
+ try
+ {
+ var cacheField = typeof(ItemAssetsCollection).GetField("cachedSearchResults",
+ System.Reflection.BindingFlags.Static | System.Reflection.BindingFlags.NonPublic);
+ if (cacheField != null)
+ {
+ var cache = cacheField.GetValue(null) as System.Collections.IDictionary;
+ if (cache != null)
+ {
+ cache.Clear();
+ Debug.Log("[TradingCardMod] Cleared search cache for loot table integration");
+ }
+ }
+ }
+ catch (Exception ex)
+ {
+ Debug.LogWarning($"[TradingCardMod] Could not clear search cache: {ex.Message}");
+ }
}
///
@@ -138,6 +217,61 @@ namespace TradingCardMod
);
}
+ ///
+ /// Creates card packs for each loaded card set.
+ ///
+ private void CreateCardPacks()
+ {
+ foreach (var setEntry in _cardsBySet)
+ {
+ string setName = setEntry.Key;
+ var cards = setEntry.Value;
+
+ if (cards.Count == 0)
+ {
+ Debug.LogWarning($"[TradingCardMod] No cards in set {setName}, skipping pack creation");
+ continue;
+ }
+
+ // Create default pack for this set
+ string setDirectory = Path.Combine(_modPath, "CardSets", setName);
+ string imagesDirectory = Path.Combine(setDirectory, "images");
+
+ CardPack defaultPack = PackParser.CreateDefaultPack(setName, imagesDirectory);
+
+ // Check for user-defined packs
+ string packsFile = Path.Combine(setDirectory, "packs.txt");
+ var userPacks = PackParser.ParseFile(packsFile, setName, imagesDirectory);
+
+ // Create all packs
+ var allPacks = new List { defaultPack };
+ allPacks.AddRange(userPacks);
+
+ foreach (var pack in allPacks)
+ {
+ // Store pack definition for runtime lookup
+ string packKey = $"{pack.SetName}|{pack.PackName}";
+ _packDefinitions[packKey] = pack;
+
+ // Load pack icon
+ Sprite? packIcon = null;
+ if (File.Exists(pack.ImagePath))
+ {
+ packIcon = LoadSpriteFromFile(pack.ImagePath, pack.GenerateTypeID());
+ }
+
+ // Create pack item
+ Item? packItem = PackHelper.CreatePackItem(pack, packIcon);
+ if (packItem != null)
+ {
+ _registeredPacks.Add(packItem);
+ }
+ }
+ }
+
+ Debug.Log($"[TradingCardMod] Created {_registeredPacks.Count} card packs");
+ }
+
///
/// Update is called every frame. Used for debug input handling.
///
@@ -162,14 +296,23 @@ namespace TradingCardMod
}
// Cycle through all spawnable items
- Item itemToSpawn = _allSpawnableItems[_debugSpawnIndex % _allSpawnableItems.Count];
+ Item prefab = _allSpawnableItems[_debugSpawnIndex % _allSpawnableItems.Count];
_debugSpawnIndex++;
try
{
- // Use game's utility to give item to player
- ItemUtilities.SendToPlayer(itemToSpawn);
- Debug.Log($"[TradingCardMod] Spawned: {itemToSpawn.DisplayName} (ID: {itemToSpawn.TypeID})");
+ // Instantiate a fresh copy of the item (don't send prefab directly)
+ Item instance = ItemAssetsCollection.InstantiateSync(prefab.TypeID);
+ if (instance != null)
+ {
+ // Use game's utility to give item to player
+ ItemUtilities.SendToPlayer(instance);
+ Debug.Log($"[TradingCardMod] Spawned: {instance.DisplayName} (ID: {instance.TypeID})");
+ }
+ else
+ {
+ Debug.LogError($"[TradingCardMod] Failed to instantiate {prefab.DisplayName} (ID: {prefab.TypeID})");
+ }
}
catch (Exception ex)
{
@@ -200,6 +343,12 @@ namespace TradingCardMod
string imagesDirectory = Path.Combine(setDirectory, "images");
var cards = CardParser.ParseFile(cardsFile, imagesDirectory);
+ // Initialize set tracking
+ if (!_cardsBySet.ContainsKey(setName))
+ {
+ _cardsBySet[setName] = new List();
+ }
+
foreach (var card in cards)
{
// Validate card
@@ -214,6 +363,7 @@ namespace TradingCardMod
}
_loadedCards.Add(card);
+ _cardsBySet[setName].Add(card);
// Register card as game item
RegisterCardWithGame(card);
@@ -261,12 +411,14 @@ namespace TradingCardMod
item.SetPrivateField("typeID", typeId);
item.SetPrivateField("weight", card.Weight);
- item.SetPrivateField("value", card.Value);
item.SetPrivateField("displayName", locKey);
- item.SetPrivateField("quality", card.GetQuality());
item.SetPrivateField("order", 0);
item.SetPrivateField("maxStackCount", 1);
+ // Use public setters for properties that have them
+ item.Value = card.Value;
+ item.Quality = card.GetQuality();
+
// Set display quality based on rarity
SetDisplayQuality(item, card.GetQuality());
@@ -280,6 +432,25 @@ namespace TradingCardMod
item.Tags.Add(luxuryTag);
}
+ // ============================================================
+ // TODO: REMOVE BEFORE RELEASE - TEST TAGS FOR LOOT SPAWNING
+ // These tags make cards appear frequently in loot for testing.
+ // Replace with appropriate tags (Collection, Misc, etc.) or
+ // implement proper loot table integration before shipping.
+ // ============================================================
+ Tag? medicTag = TagHelper.GetTargetTag("Medic");
+ if (medicTag != null)
+ {
+ item.Tags.Add(medicTag);
+ }
+
+ Tag? toolTag = TagHelper.GetTargetTag("Tool");
+ if (toolTag != null)
+ {
+ item.Tags.Add(toolTag);
+ }
+ // ============================================================
+
// Add our custom TradingCard tag
if (_tradingCardTag != null)
{
@@ -305,6 +476,7 @@ namespace TradingCardMod
if (ItemAssetsCollection.AddDynamicEntry(item))
{
_registeredItems.Add(item);
+ _cardNameToTypeId[card.CardName] = typeId;
Debug.Log($"[TradingCardMod] Registered: {card.CardName} (ID: {typeId})");
}
else
@@ -440,10 +612,17 @@ namespace TradingCardMod
// Clean up storage items
StorageHelper.Cleanup();
+ // Clean up packs
+ PackHelper.Cleanup();
+
// Clean up tags
TagHelper.Cleanup();
_loadedCards.Clear();
+ _cardsBySet.Clear();
+ _cardNameToTypeId.Clear();
+ _registeredPacks.Clear();
+ _packDefinitions.Clear();
_allSpawnableItems.Clear();
Debug.Log("[TradingCardMod] Cleanup complete.");
diff --git a/src/PackHelper.cs b/src/PackHelper.cs
new file mode 100644
index 0000000..8ef1e1a
--- /dev/null
+++ b/src/PackHelper.cs
@@ -0,0 +1,168 @@
+using System;
+using System.Collections.Generic;
+using UnityEngine;
+using ItemStatsSystem;
+using SodaCraft.Localizations;
+using Duckov.Utilities;
+
+namespace TradingCardMod
+{
+ ///
+ /// Helper class for creating card pack items.
+ ///
+ public static class PackHelper
+ {
+ // Base item to clone for packs (same as cards)
+ private const int BASE_ITEM_ID = 135;
+
+ // Track created packs for cleanup
+ private static readonly List
- _createdPacks = new List
- ();
+ private static readonly List _createdGameObjects = new List();
+
+ ///
+ /// Creates a card pack item with gacha functionality.
+ ///
+ public static Item? CreatePackItem(
+ CardPack pack,
+ Sprite? icon = null)
+ {
+ try
+ {
+ // Get base item to clone
+ Item original = ItemAssetsCollection.GetPrefab(BASE_ITEM_ID);
+ if (original == null)
+ {
+ Debug.LogError($"[TradingCardMod] Base item ID {BASE_ITEM_ID} not found for pack!");
+ return null;
+ }
+
+ // Clone the item
+ GameObject clone = UnityEngine.Object.Instantiate(original.gameObject);
+ clone.name = $"CardPack_{pack.SetName}_{pack.PackName}";
+ UnityEngine.Object.DontDestroyOnLoad(clone);
+ _createdGameObjects.Add(clone);
+
+ Item item = clone.GetComponent
- ();
+ if (item == null)
+ {
+ Debug.LogError("[TradingCardMod] Cloned pack object has no Item component!");
+ return null;
+ }
+
+ // Set item properties
+ int typeId = pack.GenerateTypeID();
+ string locKey = $"TC_Pack_{pack.SetName}_{pack.PackName}".Replace(" ", "_");
+
+ item.SetPrivateField("typeID", typeId);
+ item.SetPrivateField("weight", pack.Weight);
+ item.SetPrivateField("displayName", locKey);
+ item.SetPrivateField("order", 0);
+ item.SetPrivateField("maxStackCount", 10); // Packs can stack
+
+ // Use public setters
+ item.Value = pack.Value;
+ item.Quality = 3; // Uncommon quality for packs
+ item.DisplayQuality = (DisplayQuality)3;
+
+ // Set tags
+ item.Tags.Clear();
+
+ // Add Misc tag for loot spawning
+ Tag? miscTag = TagHelper.GetTargetTag("Misc");
+ if (miscTag != null)
+ {
+ item.Tags.Add(miscTag);
+ }
+
+ // Set icon if provided
+ if (icon != null)
+ {
+ item.SetPrivateField("icon", icon);
+ }
+
+ // Set up UsageUtilities for the "Use" context menu
+ // First, get or create UsageUtilities component
+ UsageUtilities usageUtils = clone.GetComponent();
+ if (usageUtils == null)
+ {
+ usageUtils = clone.AddComponent();
+ }
+
+ // Clear any existing behaviors from the cloned base item
+ usageUtils.behaviors.Clear();
+
+ // Add our custom usage behavior for gacha
+ var usageBehavior = clone.AddComponent();
+ usageBehavior.SetName = pack.SetName;
+ usageBehavior.PackName = pack.PackName;
+ // Note: Slots, AvailableCards, and CardTypeIds are looked up at runtime via ModBehaviour
+
+ // Register our behavior with UsageUtilities
+ usageUtils.behaviors.Add(usageBehavior);
+
+ // Set the Item's usageUtilities field to enable the Use option
+ item.SetPrivateField("usageUtilities", usageUtils);
+
+ // Set localization
+ LocalizationManager.SetOverrideText(locKey, pack.PackName);
+ string slotDesc = pack.Slots.Count == 1 ? "1 card" : $"{pack.Slots.Count} cards";
+ LocalizationManager.SetOverrideText($"{locKey}_Desc",
+ $"Open to receive {slotDesc} from {pack.SetName}");
+
+ // Register with game
+ if (ItemAssetsCollection.AddDynamicEntry(item))
+ {
+ _createdPacks.Add(item);
+ Debug.Log($"[TradingCardMod] Registered pack: {pack.PackName} (ID: {typeId}, Slots: {pack.Slots.Count})");
+ return item;
+ }
+ else
+ {
+ Debug.LogError($"[TradingCardMod] Failed to register pack {pack.PackName}!");
+ UnityEngine.Object.Destroy(clone);
+ _createdGameObjects.Remove(clone);
+ return null;
+ }
+ }
+ catch (Exception ex)
+ {
+ Debug.LogError($"[TradingCardMod] Error creating pack {pack.PackName}: {ex.Message}");
+ return null;
+ }
+ }
+
+ ///
+ /// Gets all pack items created by this helper.
+ ///
+ public static IReadOnlyList
- GetCreatedPacks()
+ {
+ return _createdPacks.AsReadOnly();
+ }
+
+ ///
+ /// Cleans up all packs created by this helper.
+ ///
+ public static void Cleanup()
+ {
+ foreach (var item in _createdPacks)
+ {
+ if (item != null)
+ {
+ ItemAssetsCollection.RemoveDynamicEntry(item);
+ }
+ }
+ _createdPacks.Clear();
+
+ foreach (var go in _createdGameObjects)
+ {
+ if (go != null)
+ {
+ UnityEngine.Object.Destroy(go);
+ }
+ }
+ _createdGameObjects.Clear();
+
+ Debug.Log("[TradingCardMod] PackHelper cleaned up.");
+ }
+ }
+}
diff --git a/src/PackParser.cs b/src/PackParser.cs
new file mode 100644
index 0000000..b49b688
--- /dev/null
+++ b/src/PackParser.cs
@@ -0,0 +1,233 @@
+using System;
+using System.Collections.Generic;
+using System.IO;
+
+namespace TradingCardMod
+{
+ ///
+ /// Parses pack definition files (packs.txt) into CardPack objects.
+ ///
+ public static class PackParser
+ {
+ ///
+ /// Parses a packs.txt file into a list of CardPack objects.
+ ///
+ /// Path to the packs.txt file.
+ /// The card set name these packs belong to.
+ /// Directory containing pack images.
+ /// List of parsed CardPack objects.
+ public static List ParseFile(string filePath, string setName, string imagesDirectory)
+ {
+ var packs = new List();
+
+ if (!File.Exists(filePath))
+ {
+ return packs;
+ }
+
+ string[] lines = File.ReadAllLines(filePath);
+ CardPack? currentPack = null;
+
+ for (int i = 0; i < lines.Length; i++)
+ {
+ string line = lines[i];
+ string trimmedLine = line.Trim();
+
+ // Skip empty lines and comments
+ if (string.IsNullOrWhiteSpace(trimmedLine) || trimmedLine.StartsWith("#"))
+ {
+ continue;
+ }
+
+ // Check if this is a slot definition (indented line)
+ bool isIndented = line.StartsWith(" ") || line.StartsWith("\t");
+
+ if (isIndented && currentPack != null)
+ {
+ // Parse slot definition
+ PackSlot? slot = ParseSlotLine(trimmedLine);
+ if (slot != null)
+ {
+ currentPack.Slots.Add(slot);
+ }
+ }
+ else if (!isIndented)
+ {
+ // Parse pack header line
+ CardPack? pack = ParsePackHeader(trimmedLine, setName, imagesDirectory);
+ if (pack != null)
+ {
+ if (currentPack != null)
+ {
+ packs.Add(currentPack);
+ }
+ currentPack = pack;
+ }
+ }
+ }
+
+ // Don't forget the last pack
+ if (currentPack != null)
+ {
+ packs.Add(currentPack);
+ }
+
+ return packs;
+ }
+
+ ///
+ /// Parses a pack header line.
+ /// Format: PackName | Image | Value
+ ///
+ private static CardPack? ParsePackHeader(string line, string setName, string imagesDirectory)
+ {
+ string[] parts = line.Split('|');
+
+ if (parts.Length < 3)
+ {
+ return null;
+ }
+
+ string packName = parts[0].Trim();
+ string imageFile = parts[1].Trim();
+
+ if (!int.TryParse(parts[2].Trim(), out int value))
+ {
+ value = 100;
+ }
+
+ float weight = 0.1f;
+ if (parts.Length >= 4 && float.TryParse(parts[3].Trim(), out float parsedWeight))
+ {
+ weight = parsedWeight;
+ }
+
+ return new CardPack
+ {
+ PackName = packName,
+ SetName = setName,
+ ImageFile = imageFile,
+ ImagePath = Path.Combine(imagesDirectory, imageFile),
+ Value = value,
+ Weight = weight,
+ IsDefault = false
+ };
+ }
+
+ ///
+ /// Parses a slot definition line.
+ /// Format: RARITY: Common:100, Uncommon:50, Rare:10
+ /// or: CARDS: CardName:100, CardName:50
+ ///
+ private static PackSlot? ParseSlotLine(string line)
+ {
+ var slot = new PackSlot();
+
+ if (line.StartsWith("RARITY:", StringComparison.OrdinalIgnoreCase))
+ {
+ slot.UseRarityWeights = true;
+ string weightsStr = line.Substring(7).Trim();
+ slot.RarityWeights = ParseWeights(weightsStr);
+ }
+ else if (line.StartsWith("CARDS:", StringComparison.OrdinalIgnoreCase))
+ {
+ slot.UseRarityWeights = false;
+ string weightsStr = line.Substring(6).Trim();
+ slot.CardWeights = ParseWeights(weightsStr);
+ }
+ else
+ {
+ // Default to rarity weights if no prefix
+ slot.UseRarityWeights = true;
+ slot.RarityWeights = ParseWeights(line);
+ }
+
+ return slot;
+ }
+
+ ///
+ /// Parses a comma-separated list of Name:Weight pairs.
+ ///
+ private static Dictionary ParseWeights(string weightsStr)
+ {
+ var weights = new Dictionary();
+
+ string[] pairs = weightsStr.Split(',');
+ foreach (string pair in pairs)
+ {
+ string[] keyValue = pair.Split(':');
+ if (keyValue.Length == 2)
+ {
+ string key = keyValue[0].Trim();
+ if (float.TryParse(keyValue[1].Trim(), out float weight))
+ {
+ weights[key] = weight;
+ }
+ }
+ }
+
+ return weights;
+ }
+
+ ///
+ /// Creates a default pack for a card set.
+ ///
+ /// The card set name.
+ /// Directory for pack images.
+ /// A default CardPack with standard slot weights.
+ public static CardPack CreateDefaultPack(string setName, string imagesDirectory)
+ {
+ string packName = $"{setName} Pack";
+ string imageFile = "pack.png";
+
+ return new CardPack
+ {
+ PackName = packName,
+ SetName = setName,
+ ImageFile = imageFile,
+ ImagePath = Path.Combine(imagesDirectory, imageFile),
+ Value = 100,
+ Weight = 0.1f,
+ IsDefault = true,
+ Slots = DefaultPackSlots.GetDefaultSlots()
+ };
+ }
+
+ ///
+ /// Validates a CardPack and returns any errors found.
+ ///
+ public static List ValidatePack(CardPack pack)
+ {
+ var errors = new List();
+
+ if (string.IsNullOrWhiteSpace(pack.PackName))
+ {
+ errors.Add("Pack name is required");
+ }
+
+ if (pack.Slots.Count == 0)
+ {
+ errors.Add("Pack must have at least one slot");
+ }
+
+ foreach (var slot in pack.Slots)
+ {
+ if (slot.UseRarityWeights && slot.RarityWeights.Count == 0)
+ {
+ errors.Add("Rarity slot must have at least one weight defined");
+ }
+ else if (!slot.UseRarityWeights && slot.CardWeights.Count == 0)
+ {
+ errors.Add("Card slot must have at least one card defined");
+ }
+ }
+
+ if (pack.Value < 0)
+ {
+ errors.Add("Pack value must be non-negative");
+ }
+
+ return errors;
+ }
+ }
+}
diff --git a/src/PackUsageBehavior.cs b/src/PackUsageBehavior.cs
new file mode 100644
index 0000000..23dfca5
--- /dev/null
+++ b/src/PackUsageBehavior.cs
@@ -0,0 +1,252 @@
+using System;
+using System.Collections.Generic;
+using System.Linq;
+using Cysharp.Threading.Tasks;
+using ItemStatsSystem;
+using UnityEngine;
+
+namespace TradingCardMod
+{
+ ///
+ /// Custom usage behavior for card packs that generates multiple cards based on slot weights.
+ ///
+ public class PackUsageBehavior : UsageBehavior
+ {
+ ///
+ /// The card set this pack draws from.
+ ///
+ public string SetName = string.Empty;
+
+ ///
+ /// The pack name within the set (for looking up slot config).
+ ///
+ public string PackName = string.Empty;
+
+ private bool _running;
+
+ ///
+ /// Gets available cards for this set from ModBehaviour.
+ ///
+ private List GetAvailableCards()
+ {
+ if (ModBehaviour.Instance == null) return new List();
+ return ModBehaviour.Instance.GetCardsBySet(SetName);
+ }
+
+ ///
+ /// Gets card type IDs from ModBehaviour.
+ ///
+ private Dictionary GetCardTypeIds()
+ {
+ if (ModBehaviour.Instance == null) return new Dictionary();
+ return ModBehaviour.Instance.GetCardTypeIds();
+ }
+
+ ///
+ /// Gets slot configuration from ModBehaviour.
+ ///
+ private List GetSlots()
+ {
+ if (ModBehaviour.Instance == null) return new List();
+ return ModBehaviour.Instance.GetPackSlots(SetName, PackName);
+ }
+
+ public override DisplaySettingsData DisplaySettings => new DisplaySettingsData
+ {
+ display = true,
+ description = $"Open to receive {GetSlots().Count} trading cards"
+ };
+
+ public override bool CanBeUsed(Item item, object user)
+ {
+ if (!(user is CharacterMainControl))
+ {
+ return false;
+ }
+ return true;
+ }
+
+ protected override void OnUse(Item item, object user)
+ {
+ CharacterMainControl character = user as CharacterMainControl;
+ var slots = GetSlots();
+ if (character != null && slots.Count > 0)
+ {
+ GenerateCards(item, character, slots).Forget();
+ }
+ else
+ {
+ Debug.LogWarning($"[TradingCardMod] Cannot open pack: character={character != null}, slots={slots.Count}");
+ }
+ }
+
+ private async UniTask GenerateCards(Item packItem, CharacterMainControl character, List slots)
+ {
+ if (_running)
+ {
+ return;
+ }
+ _running = true;
+
+ var generatedCards = new List();
+
+ try
+ {
+ foreach (var slot in slots)
+ {
+ int? cardTypeId = RollSlot(slot);
+ if (cardTypeId.HasValue)
+ {
+ Item card = await ItemAssetsCollection.InstantiateAsync(cardTypeId.Value);
+ if (card != null)
+ {
+ string cardName = card.DisplayName;
+ generatedCards.Add(cardName);
+
+ bool pickedUp = character.PickupItem(card);
+ if (!pickedUp && card != null)
+ {
+ if (card.ActiveAgent != null)
+ {
+ card.AgentUtilities.ReleaseActiveAgent();
+ }
+ PlayerStorage.Push(card);
+ }
+ }
+ }
+ }
+
+ // Show notification
+ if (generatedCards.Count > 0)
+ {
+ string message = $"Received: {string.Join(", ", generatedCards)}";
+ Debug.Log($"[TradingCardMod] Pack opened: {message}");
+ // NotificationText.Push(message); // Uncomment if NotificationText is accessible
+ }
+
+ // Consume the pack after successfully generating cards
+ ConsumeItem(packItem);
+ }
+ catch (Exception ex)
+ {
+ Debug.LogError($"[TradingCardMod] Error generating cards from pack: {ex.Message}");
+ }
+
+ _running = false;
+ }
+
+ ///
+ /// Consumes one pack from the stack or detaches if only one left.
+ ///
+ private void ConsumeItem(Item item)
+ {
+ if (item == null) return;
+
+ if (item.StackCount > 1)
+ {
+ item.StackCount -= 1;
+ }
+ else
+ {
+ item.Detach();
+ }
+ }
+
+ ///
+ /// Rolls a single slot to determine which card to give.
+ ///
+ private int? RollSlot(PackSlot slot)
+ {
+ if (slot.UseRarityWeights)
+ {
+ return RollByRarity(slot.RarityWeights);
+ }
+ else
+ {
+ return RollByCardName(slot.CardWeights);
+ }
+ }
+
+ ///
+ /// Selects a card based on rarity weights.
+ ///
+ private int? RollByRarity(Dictionary rarityWeights)
+ {
+ // Build weighted list of cards based on rarity
+ var weightedCards = new List<(int typeId, float weight)>();
+ var availableCards = GetAvailableCards();
+ var cardTypeIds = GetCardTypeIds();
+
+ foreach (var card in availableCards)
+ {
+ if (cardTypeIds.TryGetValue(card.CardName, out int typeId))
+ {
+ // Get the weight for this card's rarity
+ if (rarityWeights.TryGetValue(card.Rarity, out float weight) && weight > 0)
+ {
+ weightedCards.Add((typeId, weight));
+ }
+ }
+ }
+
+ if (weightedCards.Count == 0)
+ {
+ Debug.LogWarning($"[TradingCardMod] No cards available for rarity roll (set: {SetName}, cards: {availableCards.Count})");
+ return null;
+ }
+
+ return WeightedRandom(weightedCards);
+ }
+
+ ///
+ /// Selects a card based on specific card name weights.
+ ///
+ private int? RollByCardName(Dictionary cardWeights)
+ {
+ var weightedCards = new List<(int typeId, float weight)>();
+ var cardTypeIds = GetCardTypeIds();
+
+ foreach (var kvp in cardWeights)
+ {
+ if (cardTypeIds.TryGetValue(kvp.Key, out int typeId))
+ {
+ weightedCards.Add((typeId, kvp.Value));
+ }
+ else
+ {
+ Debug.LogWarning($"[TradingCardMod] Card '{kvp.Key}' not found for pack slot");
+ }
+ }
+
+ if (weightedCards.Count == 0)
+ {
+ Debug.LogWarning("[TradingCardMod] No cards available for card name roll");
+ return null;
+ }
+
+ return WeightedRandom(weightedCards);
+ }
+
+ ///
+ /// Performs weighted random selection.
+ ///
+ private int WeightedRandom(List<(int typeId, float weight)> items)
+ {
+ float totalWeight = items.Sum(x => x.weight);
+ float roll = UnityEngine.Random.Range(0f, totalWeight);
+
+ float cumulative = 0f;
+ foreach (var item in items)
+ {
+ cumulative += item.weight;
+ if (roll <= cumulative)
+ {
+ return item.typeId;
+ }
+ }
+
+ // Fallback to last item
+ return items[items.Count - 1].typeId;
+ }
+ }
+}
diff --git a/src/Patches.cs b/src/Patches.cs
index 9e649fd..ba1061a 100644
--- a/src/Patches.cs
+++ b/src/Patches.cs
@@ -74,4 +74,7 @@ namespace TradingCardMod
}
}
}
+
+ // Search injection patch disabled - causing index errors
+ // TODO: Find better approach for loot table integration
}