From 8d23f152ebfbccc0756814095e2fdf7e14582733 Mon Sep 17 00:00:00 2001 From: Cal Corum Date: Tue, 18 Nov 2025 19:40:43 -0600 Subject: [PATCH] Add unit testing framework and refactor parsing logic MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Created TradingCardMod.Tests.csproj with xUnit for testable components - Extracted CardParser.cs with pure parsing logic (no Unity deps) - Extracted TradingCard.cs data class from ModBehaviour - Added 37 unit tests covering parsing, validation, rarity mapping - Updated cards.txt format with optional description field - Fixed DLL references (explicit HintPath for paths with spaces) - Fixed Harmony UnpatchAll API usage - Updated CLAUDE.md with test commands and current project status 🤖 Generated with [Claude Code](https://claude.ai/code) Co-Authored-By: Claude --- .claude/settings.json | 3 +- CLAUDE.md | 76 ++++++- CardSets/ExampleSet/cards.txt | 23 +- TradingCardMod.Tests.csproj | 33 +++ TradingCardMod.csproj | 30 ++- src/CardParser.cs | 157 +++++++++++++ src/Patches.cs | 2 +- src/TradingCard.cs | 55 +++++ tests/CardParserTests.cs | 402 ++++++++++++++++++++++++++++++++++ 9 files changed, 756 insertions(+), 25 deletions(-) create mode 100644 TradingCardMod.Tests.csproj create mode 100644 src/CardParser.cs create mode 100644 src/TradingCard.cs create mode 100644 tests/CardParserTests.cs diff --git a/.claude/settings.json b/.claude/settings.json index f7ac691..9f61922 100644 --- a/.claude/settings.json +++ b/.claude/settings.json @@ -4,7 +4,8 @@ "allow": [ "Bash(dotnet build*)", "Bash(dotnet clean*)", - "Bash(dotnet restore*)" + "Bash(dotnet restore*)", + "WebFetch(domain:code.claude.com)" ] } } diff --git a/CLAUDE.md b/CLAUDE.md index cada07c..f8dc6d1 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -10,16 +10,34 @@ A Unity mod for Escape from Duckov that adds a customizable trading card system ```bash # Build the mod -dotnet build +dotnet build TradingCardMod.csproj # Build release version -dotnet build -c Release +dotnet build TradingCardMod.csproj -c Release # Output location: bin/Debug/netstandard2.1/TradingCardMod.dll ``` **Important**: Before building, update `DuckovPath` in `TradingCardMod.csproj` (line 10) to your actual game installation path. +## Testing + +```bash +# Run all unit tests +dotnet test TradingCardMod.Tests.csproj + +# Run tests with verbose output +dotnet test TradingCardMod.Tests.csproj --verbosity normal + +# Run specific test class +dotnet test TradingCardMod.Tests.csproj --filter "FullyQualifiedName~CardParserTests" + +# Run single test +dotnet test TradingCardMod.Tests.csproj --filter "ParseLine_ValidLineWithoutDescription_ReturnsCard" +``` + +**Test Coverage:** Parsing logic, validation, rarity mapping, TypeID generation, and description auto-generation are all tested. Unity-dependent code (item creation, tags) cannot be unit tested. + ## Architecture ### Mod Loading System @@ -72,9 +90,53 @@ Key namespaces and APIs from the game: 3. Launch game and enable mod in Mods menu 4. Check game logs for `[TradingCardMod]` messages -## TODO Items in Code +## Current Project Status -The following features need implementation (marked with TODO comments): -- `RegisterCardWithGame()` - Create Unity prefabs and register with ItemAssetsCollection -- Item cleanup in `OnDestroy()` - Remove registered items on mod unload -- `TradingCard.ItemPrefab` property - Store Unity prefab reference +**Phase:** 2 - Core Card Framework +**Project Plan:** `.claude/scratchpad/PROJECT_PLAN.md` +**Technical Analysis:** `.claude/scratchpad/item-system-analysis.md` + +### Implementation Approach: Clone + Reflection + +Based on analysis of the AdditionalCollectibles mod, we're using a viable approach: + +1. **Clone existing game items** as templates (not create from scratch) +2. **Use reflection** to set private fields (typeID, weight, value, etc.) +3. **Create custom tags** by cloning existing ScriptableObject tags +4. **Load sprites** from user files in `CardSets/*/images/` + +Key patterns: +```csharp +// Clone base item +Item original = ItemAssetsCollection.GetPrefab(135); +GameObject clone = Object.Instantiate(original.gameObject); +Object.DontDestroyOnLoad(clone); + +// Set properties via reflection +item.SetPrivateField("typeID", newId); +item.SetPrivateField("value", cardValue); + +// Get/create tags +Tag tag = Resources.FindObjectsOfTypeAll() + .FirstOrDefault(t => t.name == "Luxury"); +``` + +### Next Implementation Steps + +1. Create `src/ItemExtensions.cs` - reflection helper methods +2. Create `src/TagHelper.cs` - tag operations +3. Update `src/ModBehaviour.cs` - use clone+reflection approach +4. Test card creation in-game + +### Files to Create + +| File | Purpose | +|------|---------| +| `src/ItemExtensions.cs` | `SetPrivateField()`, `GetPrivateField()` extensions | +| `src/TagHelper.cs` | `GetTargetTag()`, `CreateOrCloneTag()` | + +## Research References + +- **Decompiled game code:** `.claude/scratchpad/decompiled/` +- **Item system analysis:** `.claude/scratchpad/item-system-analysis.md` +- **AdditionalCollectibles mod:** Workshop ID 3591453758 (reference implementation) diff --git a/CardSets/ExampleSet/cards.txt b/CardSets/ExampleSet/cards.txt index 9489c0f..00c58a4 100644 --- a/CardSets/ExampleSet/cards.txt +++ b/CardSets/ExampleSet/cards.txt @@ -1,20 +1,21 @@ # Example Card Set - Trading Card Mod for Escape from Duckov -# Format: CardName | SetName | SetNumber | ImageFile | Rarity | Weight | Value +# Format: CardName | SetName | SetNumber | ImageFile | Rarity | Weight | Value | Description (optional) # # Fields: -# CardName - Display name of the card -# SetName - Name of the card collection -# SetNumber - Number within the set (for sorting) -# ImageFile - Filename of the card image (must be in images/ subfolder) -# Rarity - Card rarity (Common, Uncommon, Rare, Ultra Rare) -# Weight - Physical weight in game units -# Value - In-game currency value +# CardName - Display name of the card +# SetName - Name of the card collection +# SetNumber - Number within the set (for sorting) +# ImageFile - Filename of the card image (must be in images/ subfolder) +# Rarity - Card rarity. Valid values: Common, Uncommon, Rare, Very Rare, Ultra Rare, Legendary +# Weight - Physical weight in game units +# Value - In-game currency value +# Description - (Optional) Custom tooltip text. If omitted, auto-generates as "SetName #SetNumber - Rarity" # # Add your own cards below! Just follow the format above. # Place corresponding images in the images/ subfolder. -Duck Hero | Example Set | 001 | duck_hero.png | Rare | 0.01 | 100 -Golden Quacker | Example Set | 002 | golden_quacker.png | Ultra Rare | 0.01 | 500 +Duck Hero | Example Set | 001 | duck_hero.png | Rare | 0.01 | 100 | The brave defender of all ponds +Golden Quacker | Example Set | 002 | golden_quacker.png | Ultra Rare | 0.01 | 500 | A legendary duck made of pure gold Pond Guardian | Example Set | 003 | pond_guardian.png | Uncommon | 0.01 | 25 Bread Seeker | Example Set | 004 | bread_seeker.png | Common | 0.01 | 10 -Feathered Fury | Example Set | 005 | feathered_fury.png | Rare | 0.01 | 75 +Feathered Fury | Example Set | 005 | feathered_fury.png | Rare | 0.01 | 75 | Known for its fierce battle cry diff --git a/TradingCardMod.Tests.csproj b/TradingCardMod.Tests.csproj new file mode 100644 index 0000000..f54312c --- /dev/null +++ b/TradingCardMod.Tests.csproj @@ -0,0 +1,33 @@ + + + + net8.0 + enable + false + true + TradingCardMod.Tests + false + + + + + + + runtime; build; native; contentfiles; analyzers; buildtransitive + all + + + runtime; build; native; contentfiles; analyzers; buildtransitive + all + + + + + + + + + + + + diff --git a/TradingCardMod.csproj b/TradingCardMod.csproj index bc00d08..bbbed72 100644 --- a/TradingCardMod.csproj +++ b/TradingCardMod.csproj @@ -20,10 +20,30 @@ - - - - + + $(DuckovPath)$(SubPath)TeamSoda.Duckov.Core.dll + + + $(DuckovPath)$(SubPath)TeamSoda.Duckov.Utilities.dll + + + $(DuckovPath)$(SubPath)TeamSoda.MiniLocalizor.dll + + + $(DuckovPath)$(SubPath)SodaLocalization.dll + + + $(DuckovPath)$(SubPath)ItemStatsSystem.dll + + + $(DuckovPath)$(SubPath)Duckov.dll + + + $(DuckovPath)$(SubPath)UnityEngine.dll + + + $(DuckovPath)$(SubPath)UnityEngine.CoreModule.dll + @@ -35,6 +55,6 @@ - + diff --git a/src/CardParser.cs b/src/CardParser.cs new file mode 100644 index 0000000..24cc1cd --- /dev/null +++ b/src/CardParser.cs @@ -0,0 +1,157 @@ +using System; +using System.Collections.Generic; +using System.IO; + +namespace TradingCardMod +{ + /// + /// Handles parsing of card definition files. + /// This class contains no Unity dependencies and is fully testable. + /// + public static class CardParser + { + /// + /// Maps rarity string to game quality level. + /// Quality levels: 2=Common, 3=Uncommon, 4=Rare, 5=Very Rare, 6=Ultra Rare/Legendary + /// + public static int RarityToQuality(string rarity) + { + return rarity.Trim().ToLowerInvariant() switch + { + "common" => 2, + "uncommon" => 3, + "rare" => 4, + "very rare" => 5, + "ultra rare" => 6, + "legendary" => 6, + _ => 3 // Default to uncommon + }; + } + + /// + /// Parses a single line from cards.txt into a TradingCard object. + /// Format: CardName | SetName | SetNumber | ImageFile | Rarity | Weight | Value | Description (optional) + /// + /// The line to parse + /// A TradingCard if parsing succeeds, null otherwise + public static TradingCard? ParseLine(string line) + { + if (string.IsNullOrWhiteSpace(line)) + return null; + + // Skip comments + if (line.TrimStart().StartsWith("#")) + return null; + + string[] parts = line.Split('|'); + + if (parts.Length < 7) + return null; + + try + { + var card = new TradingCard + { + CardName = parts[0].Trim(), + SetName = parts[1].Trim(), + SetNumber = int.Parse(parts[2].Trim()), + ImageFile = parts[3].Trim(), + Rarity = parts[4].Trim(), + Weight = float.Parse(parts[5].Trim()), + Value = int.Parse(parts[6].Trim()) + }; + + // Optional description field + if (parts.Length >= 8 && !string.IsNullOrWhiteSpace(parts[7])) + { + card.Description = parts[7].Trim(); + } + + return card; + } + catch + { + return null; + } + } + + /// + /// Parses all cards from a cards.txt file. + /// + /// Path to the cards.txt file + /// Directory containing card images + /// List of parsed cards with ImagePath set + public static List ParseFile(string filePath, string imagesDirectory) + { + var cards = new List(); + + if (!File.Exists(filePath)) + return cards; + + string[] lines = File.ReadAllLines(filePath); + + foreach (string line in lines) + { + TradingCard? card = ParseLine(line); + if (card != null) + { + card.ImagePath = Path.Combine(imagesDirectory, card.ImageFile); + cards.Add(card); + } + } + + return cards; + } + + /// + /// Validates a TradingCard for required fields. + /// + /// The card to validate + /// List of validation errors, empty if valid + public static List ValidateCard(TradingCard card) + { + var errors = new List(); + + if (string.IsNullOrWhiteSpace(card.CardName)) + errors.Add("CardName is required"); + + if (string.IsNullOrWhiteSpace(card.SetName)) + errors.Add("SetName is required"); + + if (card.SetNumber < 0) + errors.Add("SetNumber must be non-negative"); + + if (string.IsNullOrWhiteSpace(card.ImageFile)) + errors.Add("ImageFile is required"); + + if (string.IsNullOrWhiteSpace(card.Rarity)) + errors.Add("Rarity is required"); + + if (card.Weight < 0) + errors.Add("Weight must be non-negative"); + + if (card.Value < 0) + errors.Add("Value must be non-negative"); + + return errors; + } + + /// + /// Checks if a rarity string is valid. + /// + public static bool IsValidRarity(string rarity) + { + string normalized = rarity.Trim().ToLowerInvariant(); + return normalized switch + { + "common" => true, + "uncommon" => true, + "rare" => true, + "very rare" => true, + "ultra rare" => true, + "legendary" => true, + _ => false + }; + } + } +} diff --git a/src/Patches.cs b/src/Patches.cs index d9bc91b..dc3c31c 100644 --- a/src/Patches.cs +++ b/src/Patches.cs @@ -43,7 +43,7 @@ namespace TradingCardMod { try { - _harmony?.UnpatchSelf(); + _harmony?.UnpatchAll(HarmonyId); Debug.Log($"[TradingCardMod] Harmony patches removed"); } catch (Exception ex) diff --git a/src/TradingCard.cs b/src/TradingCard.cs new file mode 100644 index 0000000..6acef85 --- /dev/null +++ b/src/TradingCard.cs @@ -0,0 +1,55 @@ +using System; + +namespace TradingCardMod +{ + /// + /// Represents a trading card's data loaded from a card set file. + /// This class contains no Unity dependencies and is fully testable. + /// + public class TradingCard + { + public string CardName { get; set; } = string.Empty; + public string SetName { get; set; } = string.Empty; + public int SetNumber { get; set; } + public string ImageFile { get; set; } = string.Empty; + public string Rarity { get; set; } = string.Empty; + public float Weight { get; set; } + public int Value { get; set; } + public string? Description { get; set; } + + // Set at runtime after loading + public string ImagePath { get; set; } = string.Empty; + + /// + /// Generates a unique TypeID for this card to avoid conflicts. + /// Uses hash of set name + card name for uniqueness. + /// + public int GenerateTypeID() + { + // Start from a high number to avoid conflicts with base game items + // Use hash to ensure consistency across loads + string uniqueKey = $"TradingCard_{SetName}_{CardName}"; + return 100000 + Math.Abs(uniqueKey.GetHashCode() % 900000); + } + + /// + /// Gets the quality level (0-6) based on rarity string. + /// + public int GetQuality() + { + return CardParser.RarityToQuality(Rarity); + } + + /// + /// Gets the description, auto-generating if not provided. + /// + public string GetDescription() + { + if (!string.IsNullOrWhiteSpace(Description)) + { + return Description; + } + return $"{SetName} #{SetNumber:D3} - {Rarity}"; + } + } +} diff --git a/tests/CardParserTests.cs b/tests/CardParserTests.cs new file mode 100644 index 0000000..c231544 --- /dev/null +++ b/tests/CardParserTests.cs @@ -0,0 +1,402 @@ +using Xunit; +using TradingCardMod; + +namespace TradingCardMod.Tests +{ + /// + /// Unit tests for the CardParser class. + /// Tests cover parsing, validation, and rarity mapping functionality. + /// + public class CardParserTests + { + #region ParseLine Tests + + [Fact] + public void ParseLine_ValidLineWithoutDescription_ReturnsCard() + { + // Arrange + string line = "Duck Hero | Example Set | 001 | duck_hero.png | Rare | 0.01 | 100"; + + // Act + var card = CardParser.ParseLine(line); + + // Assert + Assert.NotNull(card); + Assert.Equal("Duck Hero", card.CardName); + Assert.Equal("Example Set", card.SetName); + Assert.Equal(1, card.SetNumber); + Assert.Equal("duck_hero.png", card.ImageFile); + Assert.Equal("Rare", card.Rarity); + Assert.Equal(0.01f, card.Weight); + Assert.Equal(100, card.Value); + Assert.Null(card.Description); + } + + [Fact] + public void ParseLine_ValidLineWithDescription_ReturnsCardWithDescription() + { + // Arrange + string line = "Golden Quacker | Example Set | 002 | golden_quacker.png | Ultra Rare | 0.01 | 500 | A legendary duck made of pure gold"; + + // Act + var card = CardParser.ParseLine(line); + + // Assert + Assert.NotNull(card); + Assert.Equal("Golden Quacker", card.CardName); + Assert.Equal("A legendary duck made of pure gold", card.Description); + } + + [Fact] + public void ParseLine_CommentLine_ReturnsNull() + { + // Arrange + string line = "# This is a comment"; + + // Act + var card = CardParser.ParseLine(line); + + // Assert + Assert.Null(card); + } + + [Fact] + public void ParseLine_EmptyLine_ReturnsNull() + { + // Arrange + string line = ""; + + // Act + var card = CardParser.ParseLine(line); + + // Assert + Assert.Null(card); + } + + [Fact] + public void ParseLine_WhitespaceLine_ReturnsNull() + { + // Arrange + string line = " \t "; + + // Act + var card = CardParser.ParseLine(line); + + // Assert + Assert.Null(card); + } + + [Fact] + public void ParseLine_TooFewFields_ReturnsNull() + { + // Arrange - only 6 fields instead of required 7 + string line = "Duck Hero | Example Set | 001 | duck_hero.png | Rare | 0.01"; + + // Act + var card = CardParser.ParseLine(line); + + // Assert + Assert.Null(card); + } + + [Fact] + public void ParseLine_InvalidNumber_ReturnsNull() + { + // Arrange - SetNumber is not a valid integer + string line = "Duck Hero | Example Set | ABC | duck_hero.png | Rare | 0.01 | 100"; + + // Act + var card = CardParser.ParseLine(line); + + // Assert + Assert.Null(card); + } + + [Fact] + public void ParseLine_InvalidWeight_ReturnsNull() + { + // Arrange - Weight is not a valid float + string line = "Duck Hero | Example Set | 001 | duck_hero.png | Rare | heavy | 100"; + + // Act + var card = CardParser.ParseLine(line); + + // Assert + Assert.Null(card); + } + + [Fact] + public void ParseLine_TrimsWhitespace_ReturnsCleanCard() + { + // Arrange - extra whitespace around values + string line = " Duck Hero | Example Set | 001 | duck_hero.png | Rare | 0.01 | 100 "; + + // Act + var card = CardParser.ParseLine(line); + + // Assert + Assert.NotNull(card); + Assert.Equal("Duck Hero", card.CardName); + Assert.Equal("Example Set", card.SetName); + } + + #endregion + + #region RarityToQuality Tests + + [Theory] + [InlineData("Common", 2)] + [InlineData("common", 2)] + [InlineData("COMMON", 2)] + [InlineData("Uncommon", 3)] + [InlineData("Rare", 4)] + [InlineData("Very Rare", 5)] + [InlineData("Ultra Rare", 6)] + [InlineData("Legendary", 6)] + public void RarityToQuality_ValidRarity_ReturnsCorrectQuality(string rarity, int expectedQuality) + { + // Act + int quality = CardParser.RarityToQuality(rarity); + + // Assert + Assert.Equal(expectedQuality, quality); + } + + [Fact] + public void RarityToQuality_UnknownRarity_ReturnsDefault() + { + // Arrange - unknown rarity should default to uncommon (3) + string rarity = "Super Duper Rare"; + + // Act + int quality = CardParser.RarityToQuality(rarity); + + // Assert + Assert.Equal(3, quality); + } + + [Fact] + public void RarityToQuality_WithExtraWhitespace_ReturnsCorrectQuality() + { + // Arrange + string rarity = " Rare "; + + // Act + int quality = CardParser.RarityToQuality(rarity); + + // Assert + Assert.Equal(4, quality); + } + + #endregion + + #region IsValidRarity Tests + + [Theory] + [InlineData("Common", true)] + [InlineData("Uncommon", true)] + [InlineData("Rare", true)] + [InlineData("Very Rare", true)] + [InlineData("Ultra Rare", true)] + [InlineData("Legendary", true)] + [InlineData("Invalid", false)] + [InlineData("Super Rare", false)] + [InlineData("", false)] + public void IsValidRarity_ReturnsExpectedResult(string rarity, bool expected) + { + // Act + bool result = CardParser.IsValidRarity(rarity); + + // Assert + Assert.Equal(expected, result); + } + + #endregion + + #region ValidateCard Tests + + [Fact] + public void ValidateCard_ValidCard_ReturnsNoErrors() + { + // Arrange + var card = new TradingCard + { + CardName = "Duck Hero", + SetName = "Example Set", + SetNumber = 1, + ImageFile = "duck_hero.png", + Rarity = "Rare", + Weight = 0.01f, + Value = 100 + }; + + // Act + var errors = CardParser.ValidateCard(card); + + // Assert + Assert.Empty(errors); + } + + [Fact] + public void ValidateCard_MissingCardName_ReturnsError() + { + // Arrange + var card = new TradingCard + { + CardName = "", + SetName = "Example Set", + SetNumber = 1, + ImageFile = "duck_hero.png", + Rarity = "Rare", + Weight = 0.01f, + Value = 100 + }; + + // Act + var errors = CardParser.ValidateCard(card); + + // Assert + Assert.Single(errors); + Assert.Contains("CardName", errors[0]); + } + + [Fact] + public void ValidateCard_NegativeValue_ReturnsError() + { + // Arrange + var card = new TradingCard + { + CardName = "Duck Hero", + SetName = "Example Set", + SetNumber = 1, + ImageFile = "duck_hero.png", + Rarity = "Rare", + Weight = 0.01f, + Value = -100 + }; + + // Act + var errors = CardParser.ValidateCard(card); + + // Assert + Assert.Single(errors); + Assert.Contains("Value", errors[0]); + } + + [Fact] + public void ValidateCard_MultipleErrors_ReturnsAllErrors() + { + // Arrange - missing card name and negative weight + var card = new TradingCard + { + CardName = "", + SetName = "Example Set", + SetNumber = 1, + ImageFile = "duck_hero.png", + Rarity = "Rare", + Weight = -0.01f, + Value = 100 + }; + + // Act + var errors = CardParser.ValidateCard(card); + + // Assert + Assert.Equal(2, errors.Count); + } + + #endregion + + #region TradingCard Tests + + [Fact] + public void TradingCard_GenerateTypeID_ReturnsConsistentValue() + { + // Arrange + var card = new TradingCard + { + CardName = "Duck Hero", + SetName = "Example Set" + }; + + // Act + int id1 = card.GenerateTypeID(); + int id2 = card.GenerateTypeID(); + + // Assert - same card should always generate same ID + Assert.Equal(id1, id2); + Assert.True(id1 >= 100000); + Assert.True(id1 < 1000000); + } + + [Fact] + public void TradingCard_GenerateTypeID_DifferentCardsGetDifferentIDs() + { + // Arrange + var card1 = new TradingCard { CardName = "Duck Hero", SetName = "Set A" }; + var card2 = new TradingCard { CardName = "Golden Quacker", SetName = "Set A" }; + + // Act + int id1 = card1.GenerateTypeID(); + int id2 = card2.GenerateTypeID(); + + // Assert + Assert.NotEqual(id1, id2); + } + + [Fact] + public void TradingCard_GetDescription_WithCustomDescription_ReturnsCustom() + { + // Arrange + var card = new TradingCard + { + CardName = "Duck Hero", + SetName = "Example Set", + SetNumber = 1, + Rarity = "Rare", + Description = "Custom description" + }; + + // Act + string description = card.GetDescription(); + + // Assert + Assert.Equal("Custom description", description); + } + + [Fact] + public void TradingCard_GetDescription_WithoutDescription_ReturnsAutoGenerated() + { + // Arrange + var card = new TradingCard + { + CardName = "Duck Hero", + SetName = "Example Set", + SetNumber = 1, + Rarity = "Rare", + Description = null + }; + + // Act + string description = card.GetDescription(); + + // Assert + Assert.Equal("Example Set #001 - Rare", description); + } + + [Fact] + public void TradingCard_GetQuality_ReturnsCorrectQuality() + { + // Arrange + var card = new TradingCard { Rarity = "Ultra Rare" }; + + // Act + int quality = card.GetQuality(); + + // Assert + Assert.Equal(6, quality); + } + + #endregion + } +}