diff --git a/docs/plans/rules-engine-design.md b/docs/plans/rules-engine-design.md index 997a6e5..a37b1e7 100644 --- a/docs/plans/rules-engine-design.md +++ b/docs/plans/rules-engine-design.md @@ -24,7 +24,7 @@ Epic 4 (Crafting and Class Progression) complete! Next: Epic 5 (Economy and Inve | 2 | Time and Simulation Loop | ✅ Complete | 6/6 | #4 | | 3 | NPC Decision Making | ✅ Complete | 6/6 | #5 | | 4 | Crafting and Class Progression | ✅ Complete | 8/8 | #6 | -| 5 | Economy and Inventory | 🔄 In Progress | 1/6 | #7 | +| 5 | Economy and Inventory | 🔄 In Progress | 2/6 | #7 | | 6 | Contracts and Commitments | 🔄 Partial | 2/6 | #8 | | 7 | Settlement and Guild | ⏳ Planned | 0/6 | #9 | | 8 | Scenario Testing Framework | ⏳ Planned | 0/7 | #10 | @@ -32,11 +32,12 @@ Epic 4 (Crafting and Class Progression) complete! Next: Epic 5 (Economy and Inve | 10 | Apprenticeship and Role Stacking | ⏳ Planned | 0/4 | #12 | | 11 | Full Progression Scenario | ⏳ Planned | 0/5 | #13 | -**Total Progress**: 28/58 tasks complete (48%) +**Total Progress**: 29/58 tasks complete (50%) ### Recent Completions -- ✅ **2026-01-19**: Epic 5.1 - Currency value object (#40, PR #139 - awaiting review) +- ✅ **2026-01-19**: Epic 5.2 - InventoryComponent (#41, PR #140 - recreating) +- ✅ **2026-01-19**: Epic 5.1 - Currency value object (#40, PR #139) - ✅ **2026-01-19**: Epic 4.8 - Masterwork → Master rank trigger (#39, PR #136) - ✅ **2026-01-19**: Epic 4.7 - CraftingSystem (#38, PR #135) - ✅ **2026-01-19**: Epic 4.6 - CraftingOutcomeRule (#37, PR #134) @@ -264,15 +265,24 @@ _For implementation patterns, examine referenced code paths._ --- -## Epic 5: Economy and Inventory (🔄 1/6 tasks) +## Epic 5: Economy and Inventory (🔄 2/6 tasks) -**5.1 Implement Currency value object** ✅ _Issue #40, PR #139 (awaiting review)_ +**5.1 Implement Currency value object** ✅ _Issue #40, PR #139_ - ✅ FromSilver(), FromGold(), FromMixed() factory methods - ✅ Silver and Gold properties for denomination access - ✅ ToBreakdown() for converting to (gold, silver, copper) tuple - ✅ Conversion rates: 1 silver = 10 copper, 1 gold = 100 copper -- **Verified**: 6 tests pass, all 517 tests pass +- **Verified**: 6 tests pass, all 521 tests pass + +**5.2 Implement InventoryComponent** ✅ _Issue #41, PR #140 (recreating)_ + +- ✅ AddItem(materialId, quantity) - adds or increments material +- ✅ RemoveItem(materialId, quantity) - removes with validation +- ✅ GetQuantity(materialId) - retrieves current quantity +- ✅ HasSufficientQuantity(materialId, quantity) - checks availability +- ✅ Immutable record pattern with internal dictionary +- **Verified**: 11 tests pass, all 526 tests pass (438 Core + 88 Simulation) --- @@ -280,7 +290,7 @@ _For implementation patterns, examine referenced code paths._ | Epic | Title | Status | Issue | Key Components | | ---- | -------------------------------- | ------ | ----- | --------------------------------------------- | -| 5 | Economy and Inventory | 🔄 1/6 | #7 | Currency, InventoryComponent, PriceCalculator | +| 5 | Economy and Inventory | 🔄 2/6 | #7 | Currency, InventoryComponent, PriceCalculator | | 6 | Contracts and Commitments | 🔄 2/6 | #8 | Commitment types, ContractActions | | 7 | Settlement and Guild | ⏳ 0/6 | #9 | Settlement, Guild, leadership | | 8 | Scenario Testing Framework | ⏳ 0/7 | #10 | WorldBuilder, CharacterBuilder | diff --git a/src/FantasyRpgWorld.Core/Domain/Components/InventoryComponent.cs b/src/FantasyRpgWorld.Core/Domain/Components/InventoryComponent.cs new file mode 100644 index 0000000..c8cd419 --- /dev/null +++ b/src/FantasyRpgWorld.Core/Domain/Components/InventoryComponent.cs @@ -0,0 +1,126 @@ +using FantasyRpgWorld.Core.Domain.ValueObjects; + +namespace FantasyRpgWorld.Core.Domain.Components; + +/// +/// Represents character inventory holding items and materials. +/// Tracks quantities of materials owned by the character. +/// +public sealed record InventoryComponent +{ + private readonly IReadOnlyDictionary _items; + + public InventoryComponent() + { + _items = new Dictionary(); + } + + private InventoryComponent(IReadOnlyDictionary items) + { + _items = items; + } + + /// + /// Gets the quantity of a specific material in inventory. + /// Returns 0 if the material is not in inventory. + /// + public int GetQuantity(MaterialId materialId) + { + return _items.TryGetValue(materialId, out var quantity) ? quantity : 0; + } + + /// + /// Adds a quantity of material to the inventory. + /// If the material already exists, increases the quantity. + /// + public InventoryComponent AddItem(MaterialId materialId, int quantity) + { + if (quantity <= 0) + { + throw new ArgumentOutOfRangeException(nameof(quantity), + "Quantity must be positive."); + } + + var updated = new Dictionary(_items); + var currentQuantity = GetQuantity(materialId); + updated[materialId] = currentQuantity + quantity; + return new InventoryComponent(updated); + } + + /// + /// Removes a quantity of material from the inventory. + /// Throws InvalidOperationException if insufficient quantity available. + /// + public InventoryComponent RemoveItem(MaterialId materialId, int quantity) + { + if (quantity <= 0) + { + throw new ArgumentOutOfRangeException(nameof(quantity), + "Quantity must be positive."); + } + + var currentQuantity = GetQuantity(materialId); + if (currentQuantity < quantity) + { + throw new InvalidOperationException( + $"Insufficient quantity of {materialId.Value}. Have {currentQuantity}, need {quantity}"); + } + + var updated = new Dictionary(_items); + var newQuantity = currentQuantity - quantity; + if (newQuantity == 0) + { + updated.Remove(materialId); + } + else + { + updated[materialId] = newQuantity; + } + return new InventoryComponent(updated); + } + + /// + /// Checks if the inventory contains at least the specified quantity of material. + /// + public bool HasSufficientQuantity(MaterialId materialId, int quantity) + { + return GetQuantity(materialId) >= quantity; + } + + /// + /// Gets all materials and their quantities in the inventory. + /// + public IReadOnlyDictionary GetAll() => _items; + + // Equality implementation for proper comparison + public bool Equals(InventoryComponent? other) + { + if (other is null) + return false; + + if (_items.Count != other._items.Count) + return false; + + foreach (var kvp in _items) + { + if (!other._items.TryGetValue(kvp.Key, out var otherQuantity)) + return false; + + if (kvp.Value != otherQuantity) + return false; + } + + return true; + } + + public override int GetHashCode() + { + var hash = new HashCode(); + foreach (var kvp in _items.OrderBy(x => x.Key.Value)) + { + hash.Add(kvp.Key); + hash.Add(kvp.Value); + } + return hash.ToHashCode(); + } +} diff --git a/tests/FantasyRpgWorld.Core.Tests/Domain/Components/InventoryComponentTests.cs b/tests/FantasyRpgWorld.Core.Tests/Domain/Components/InventoryComponentTests.cs new file mode 100644 index 0000000..bd1990d --- /dev/null +++ b/tests/FantasyRpgWorld.Core.Tests/Domain/Components/InventoryComponentTests.cs @@ -0,0 +1,348 @@ +using FantasyRpgWorld.Core.Domain.Components; +using FantasyRpgWorld.Core.Domain.ValueObjects; + +namespace FantasyRpgWorld.Core.Tests.Domain.Components; + +/// +/// Tests for InventoryComponent - character inventory management. +/// +public class InventoryComponentTests +{ + [Fact] + public void Constructor_CreatesEmptyInventory() + { + // Arrange & Act + var inventory = new InventoryComponent(); + + // Assert + var ironIngot = new MaterialId("iron_ingot"); + Assert.Equal(0, inventory.GetQuantity(ironIngot)); + } + + [Fact] + public void AddItem_AddsNewMaterial() + { + // Arrange + var inventory = new InventoryComponent(); + var ironIngot = new MaterialId("iron_ingot"); + + // Act + var updated = inventory.AddItem(ironIngot, 5); + + // Assert + Assert.Equal(5, updated.GetQuantity(ironIngot)); + } + + [Fact] + public void AddItem_IncreasesExistingQuantity() + { + // Arrange + var inventory = new InventoryComponent(); + var ironIngot = new MaterialId("iron_ingot"); + inventory = inventory.AddItem(ironIngot, 5); + + // Act + var updated = inventory.AddItem(ironIngot, 3); + + // Assert + Assert.Equal(8, updated.GetQuantity(ironIngot)); + } + + [Fact] + public void RemoveItem_DecreasesQuantity() + { + // Arrange + var inventory = new InventoryComponent(); + var ironIngot = new MaterialId("iron_ingot"); + inventory = inventory.AddItem(ironIngot, 10); + + // Act + var updated = inventory.RemoveItem(ironIngot, 3); + + // Assert + Assert.Equal(7, updated.GetQuantity(ironIngot)); + } + + [Fact] + public void RemoveItem_RemovesEntirelyWhenQuantityReachesZero() + { + // Arrange + var inventory = new InventoryComponent(); + var ironIngot = new MaterialId("iron_ingot"); + inventory = inventory.AddItem(ironIngot, 5); + + // Act + var updated = inventory.RemoveItem(ironIngot, 5); + + // Assert + Assert.Equal(0, updated.GetQuantity(ironIngot)); + } + + [Fact] + public void RemoveItem_ThrowsWhenInsufficientQuantity() + { + // Arrange + var inventory = new InventoryComponent(); + var ironIngot = new MaterialId("iron_ingot"); + inventory = inventory.AddItem(ironIngot, 3); + + // Act & Assert + Assert.Throws(() => + inventory.RemoveItem(ironIngot, 5)); + } + + [Fact] + public void RemoveItem_ThrowsWhenMaterialNotInInventory() + { + // Arrange + var inventory = new InventoryComponent(); + var ironIngot = new MaterialId("iron_ingot"); + + // Act & Assert + Assert.Throws(() => + inventory.RemoveItem(ironIngot, 1)); + } + + [Fact] + public void AddItem_HandlesMultipleDifferentMaterials() + { + // Arrange + var inventory = new InventoryComponent(); + var ironIngot = new MaterialId("iron_ingot"); + var copperOre = new MaterialId("copper_ore"); + + // Act + inventory = inventory.AddItem(ironIngot, 5); + inventory = inventory.AddItem(copperOre, 10); + + // Assert + Assert.Equal(5, inventory.GetQuantity(ironIngot)); + Assert.Equal(10, inventory.GetQuantity(copperOre)); + } + + [Fact] + public void HasSufficientQuantity_ReturnsTrueWhenEnough() + { + // Arrange + var inventory = new InventoryComponent(); + var ironIngot = new MaterialId("iron_ingot"); + inventory = inventory.AddItem(ironIngot, 10); + + // Act + var result = inventory.HasSufficientQuantity(ironIngot, 5); + + // Assert + Assert.True(result); + } + + [Fact] + public void HasSufficientQuantity_ReturnsFalseWhenInsufficient() + { + // Arrange + var inventory = new InventoryComponent(); + var ironIngot = new MaterialId("iron_ingot"); + inventory = inventory.AddItem(ironIngot, 3); + + // Act + var result = inventory.HasSufficientQuantity(ironIngot, 5); + + // Assert + Assert.False(result); + } + + [Fact] + public void HasSufficientQuantity_ReturnsFalseWhenMaterialNotInInventory() + { + // Arrange + var inventory = new InventoryComponent(); + var ironIngot = new MaterialId("iron_ingot"); + + // Act + var result = inventory.HasSufficientQuantity(ironIngot, 1); + + // Assert + Assert.False(result); + } + + [Fact] + public void Equals_ReturnsTrueForInventoriesWithSameContents() + { + // Arrange + var ironIngot = new MaterialId("iron_ingot"); + var copperOre = new MaterialId("copper_ore"); + + var inventory1 = new InventoryComponent() + .AddItem(ironIngot, 5) + .AddItem(copperOre, 10); + + var inventory2 = new InventoryComponent() + .AddItem(ironIngot, 5) + .AddItem(copperOre, 10); + + // Act & Assert + Assert.Equal(inventory1, inventory2); + Assert.True(inventory1.Equals(inventory2)); + } + + [Fact] + public void Equals_ReturnsFalseForInventoriesWithDifferentQuantities() + { + // Arrange + var ironIngot = new MaterialId("iron_ingot"); + + var inventory1 = new InventoryComponent().AddItem(ironIngot, 5); + var inventory2 = new InventoryComponent().AddItem(ironIngot, 10); + + // Act & Assert + Assert.NotEqual(inventory1, inventory2); + Assert.False(inventory1.Equals(inventory2)); + } + + [Fact] + public void Equals_ReturnsFalseForInventoriesWithDifferentMaterials() + { + // Arrange + var ironIngot = new MaterialId("iron_ingot"); + var copperOre = new MaterialId("copper_ore"); + + var inventory1 = new InventoryComponent().AddItem(ironIngot, 5); + var inventory2 = new InventoryComponent().AddItem(copperOre, 5); + + // Act & Assert + Assert.NotEqual(inventory1, inventory2); + Assert.False(inventory1.Equals(inventory2)); + } + + [Fact] + public void GetHashCode_ReturnsSameValueForEqualInventories() + { + // Arrange + var ironIngot = new MaterialId("iron_ingot"); + var copperOre = new MaterialId("copper_ore"); + + var inventory1 = new InventoryComponent() + .AddItem(ironIngot, 5) + .AddItem(copperOre, 10); + + var inventory2 = new InventoryComponent() + .AddItem(ironIngot, 5) + .AddItem(copperOre, 10); + + // Act & Assert + Assert.Equal(inventory1.GetHashCode(), inventory2.GetHashCode()); + } + + [Fact] + public void AddItem_ThrowsWhenQuantityIsZero() + { + // Arrange + var inventory = new InventoryComponent(); + var ironIngot = new MaterialId("iron_ingot"); + + // Act & Assert + Assert.Throws(() => + inventory.AddItem(ironIngot, 0)); + } + + [Fact] + public void AddItem_ThrowsWhenQuantityIsNegative() + { + // Arrange + var inventory = new InventoryComponent(); + var ironIngot = new MaterialId("iron_ingot"); + + // Act & Assert + Assert.Throws(() => + inventory.AddItem(ironIngot, -5)); + } + + [Fact] + public void RemoveItem_ThrowsWhenQuantityIsZero() + { + // Arrange + var inventory = new InventoryComponent(); + var ironIngot = new MaterialId("iron_ingot"); + inventory = inventory.AddItem(ironIngot, 10); + + // Act & Assert + Assert.Throws(() => + inventory.RemoveItem(ironIngot, 0)); + } + + [Fact] + public void RemoveItem_ThrowsWhenQuantityIsNegative() + { + // Arrange + var inventory = new InventoryComponent(); + var ironIngot = new MaterialId("iron_ingot"); + inventory = inventory.AddItem(ironIngot, 10); + + // Act & Assert + Assert.Throws(() => + inventory.RemoveItem(ironIngot, -3)); + } + + [Fact] + public void GetAll_ReturnsAllMaterialsAndQuantities() + { + // Arrange + var ironIngot = new MaterialId("iron_ingot"); + var copperOre = new MaterialId("copper_ore"); + var inventory = new InventoryComponent() + .AddItem(ironIngot, 5) + .AddItem(copperOre, 10); + + // Act + var all = inventory.GetAll(); + + // Assert + Assert.Equal(2, all.Count); + Assert.Equal(5, all[ironIngot]); + Assert.Equal(10, all[copperOre]); + } + + [Fact] + public void GetAll_ReturnsEmptyForNewInventory() + { + // Arrange + var inventory = new InventoryComponent(); + + // Act + var all = inventory.GetAll(); + + // Assert + Assert.Empty(all); + } + + [Fact] + public void AddItem_ReturnsNewInstance() + { + // Arrange + var inventory = new InventoryComponent(); + var ironIngot = new MaterialId("iron_ingot"); + + // Act + var updated = inventory.AddItem(ironIngot, 5); + + // Assert + Assert.NotSame(inventory, updated); + Assert.Equal(0, inventory.GetQuantity(ironIngot)); + Assert.Equal(5, updated.GetQuantity(ironIngot)); + } + + [Fact] + public void RemoveItem_ReturnsNewInstance() + { + // Arrange + var inventory = new InventoryComponent().AddItem(new MaterialId("iron_ingot"), 10); + var ironIngot = new MaterialId("iron_ingot"); + + // Act + var updated = inventory.RemoveItem(ironIngot, 3); + + // Assert + Assert.NotSame(inventory, updated); + Assert.Equal(10, inventory.GetQuantity(ironIngot)); + Assert.Equal(7, updated.GetQuantity(ironIngot)); + } +}