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));
+ }
+}