diff --git a/api/app/models/__init__.py b/api/app/models/__init__.py index 66b9419..512a304 100644 --- a/api/app/models/__init__.py +++ b/api/app/models/__init__.py @@ -9,6 +9,7 @@ from app.models.enums import ( EffectType, DamageType, ItemType, + ItemRarity, StatType, AbilityType, CombatStatus, @@ -53,6 +54,7 @@ __all__ = [ "EffectType", "DamageType", "ItemType", + "ItemRarity", "StatType", "AbilityType", "CombatStatus", diff --git a/api/app/models/enums.py b/api/app/models/enums.py index d9952eb..a2924cd 100644 --- a/api/app/models/enums.py +++ b/api/app/models/enums.py @@ -40,6 +40,16 @@ class ItemType(Enum): QUEST_ITEM = "quest_item" # Story-related, non-tradeable +class ItemRarity(Enum): + """Item rarity tiers affecting drop rates, value, and visual styling.""" + + COMMON = "common" # White/gray - basic items + UNCOMMON = "uncommon" # Green - slightly better + RARE = "rare" # Blue - noticeably better + EPIC = "epic" # Purple - powerful items + LEGENDARY = "legendary" # Orange/gold - best items + + class StatType(Enum): """Character attribute types.""" diff --git a/api/app/models/items.py b/api/app/models/items.py index 7bc1e6f..2365a6e 100644 --- a/api/app/models/items.py +++ b/api/app/models/items.py @@ -8,7 +8,7 @@ including weapons, armor, consumables, and quest items. from dataclasses import dataclass, field, asdict from typing import Dict, Any, List, Optional -from app.models.enums import ItemType, DamageType +from app.models.enums import ItemType, ItemRarity, DamageType from app.models.effects import Effect @@ -24,6 +24,7 @@ class Item: item_id: Unique identifier name: Display name item_type: Category (weapon, armor, consumable, quest_item) + rarity: Rarity tier (common, uncommon, rare, epic, legendary) description: Item lore and information value: Gold value for buying/selling is_tradeable: Whether item can be sold on marketplace @@ -49,7 +50,8 @@ class Item: item_id: str name: str item_type: ItemType - description: str + rarity: ItemRarity = ItemRarity.COMMON + description: str = "" value: int = 0 is_tradeable: bool = True @@ -158,6 +160,7 @@ class Item: """ data = asdict(self) data["item_type"] = self.item_type.value + data["rarity"] = self.rarity.value if self.damage_type: data["damage_type"] = self.damage_type.value if self.elemental_damage_type: @@ -178,6 +181,7 @@ class Item: """ # Convert string values back to enums item_type = ItemType(data["item_type"]) + rarity = ItemRarity(data.get("rarity", "common")) damage_type = DamageType(data["damage_type"]) if data.get("damage_type") else None elemental_damage_type = ( DamageType(data["elemental_damage_type"]) @@ -194,7 +198,8 @@ class Item: item_id=data["item_id"], name=data["name"], item_type=item_type, - description=data["description"], + rarity=rarity, + description=data.get("description", ""), value=data.get("value", 0), is_tradeable=data.get("is_tradeable", True), stat_bonuses=data.get("stat_bonuses", {}), diff --git a/api/tests/test_items.py b/api/tests/test_items.py new file mode 100644 index 0000000..893ed3e --- /dev/null +++ b/api/tests/test_items.py @@ -0,0 +1,387 @@ +""" +Unit tests for Item dataclass and ItemRarity enum. + +Tests item creation, rarity, type checking, and serialization. +""" + +import pytest +from app.models.items import Item +from app.models.enums import ItemType, ItemRarity, DamageType + + +class TestItemRarityEnum: + """Tests for ItemRarity enum.""" + + def test_rarity_values(self): + """Test all rarity values exist and have correct string values.""" + assert ItemRarity.COMMON.value == "common" + assert ItemRarity.UNCOMMON.value == "uncommon" + assert ItemRarity.RARE.value == "rare" + assert ItemRarity.EPIC.value == "epic" + assert ItemRarity.LEGENDARY.value == "legendary" + + def test_rarity_from_string(self): + """Test creating rarity from string value.""" + assert ItemRarity("common") == ItemRarity.COMMON + assert ItemRarity("uncommon") == ItemRarity.UNCOMMON + assert ItemRarity("rare") == ItemRarity.RARE + assert ItemRarity("epic") == ItemRarity.EPIC + assert ItemRarity("legendary") == ItemRarity.LEGENDARY + + def test_rarity_count(self): + """Test that there are exactly 5 rarity tiers.""" + assert len(ItemRarity) == 5 + + +class TestItemCreation: + """Tests for creating Item instances.""" + + def test_create_basic_item(self): + """Test creating a basic item with minimal fields.""" + item = Item( + item_id="test_item", + name="Test Item", + item_type=ItemType.QUEST_ITEM, + ) + + assert item.item_id == "test_item" + assert item.name == "Test Item" + assert item.item_type == ItemType.QUEST_ITEM + assert item.rarity == ItemRarity.COMMON # Default + assert item.description == "" + assert item.value == 0 + assert item.is_tradeable == True + + def test_item_default_rarity_is_common(self): + """Test that items default to COMMON rarity.""" + item = Item( + item_id="sword_1", + name="Iron Sword", + item_type=ItemType.WEAPON, + ) + + assert item.rarity == ItemRarity.COMMON + + def test_create_item_with_rarity(self): + """Test creating items with different rarity levels.""" + uncommon = Item( + item_id="sword_2", + name="Steel Sword", + item_type=ItemType.WEAPON, + rarity=ItemRarity.UNCOMMON, + ) + assert uncommon.rarity == ItemRarity.UNCOMMON + + rare = Item( + item_id="sword_3", + name="Mithril Sword", + item_type=ItemType.WEAPON, + rarity=ItemRarity.RARE, + ) + assert rare.rarity == ItemRarity.RARE + + epic = Item( + item_id="sword_4", + name="Dragon Sword", + item_type=ItemType.WEAPON, + rarity=ItemRarity.EPIC, + ) + assert epic.rarity == ItemRarity.EPIC + + legendary = Item( + item_id="sword_5", + name="Excalibur", + item_type=ItemType.WEAPON, + rarity=ItemRarity.LEGENDARY, + ) + assert legendary.rarity == ItemRarity.LEGENDARY + + def test_create_weapon(self): + """Test creating a weapon with all weapon-specific fields.""" + weapon = Item( + item_id="fire_sword", + name="Flame Blade", + item_type=ItemType.WEAPON, + rarity=ItemRarity.RARE, + description="A sword wreathed in flames.", + value=500, + damage=25, + damage_type=DamageType.PHYSICAL, + crit_chance=0.15, + crit_multiplier=2.5, + elemental_damage_type=DamageType.FIRE, + physical_ratio=0.7, + elemental_ratio=0.3, + ) + + assert weapon.is_weapon() == True + assert weapon.is_elemental_weapon() == True + assert weapon.damage == 25 + assert weapon.crit_chance == 0.15 + assert weapon.crit_multiplier == 2.5 + assert weapon.elemental_damage_type == DamageType.FIRE + assert weapon.physical_ratio == 0.7 + assert weapon.elemental_ratio == 0.3 + + def test_create_armor(self): + """Test creating armor with defense/resistance.""" + armor = Item( + item_id="plate_armor", + name="Steel Plate Armor", + item_type=ItemType.ARMOR, + rarity=ItemRarity.UNCOMMON, + description="Heavy steel armor.", + value=300, + defense=15, + resistance=5, + ) + + assert armor.is_armor() == True + assert armor.defense == 15 + assert armor.resistance == 5 + + def test_create_consumable(self): + """Test creating a consumable item.""" + potion = Item( + item_id="health_potion", + name="Health Potion", + item_type=ItemType.CONSUMABLE, + rarity=ItemRarity.COMMON, + description="Restores 50 HP.", + value=25, + ) + + assert potion.is_consumable() == True + assert potion.is_tradeable == True + + +class TestItemTypeMethods: + """Tests for item type checking methods.""" + + def test_is_weapon(self): + """Test is_weapon() method.""" + weapon = Item(item_id="w", name="W", item_type=ItemType.WEAPON) + armor = Item(item_id="a", name="A", item_type=ItemType.ARMOR) + + assert weapon.is_weapon() == True + assert armor.is_weapon() == False + + def test_is_armor(self): + """Test is_armor() method.""" + weapon = Item(item_id="w", name="W", item_type=ItemType.WEAPON) + armor = Item(item_id="a", name="A", item_type=ItemType.ARMOR) + + assert armor.is_armor() == True + assert weapon.is_armor() == False + + def test_is_consumable(self): + """Test is_consumable() method.""" + consumable = Item(item_id="c", name="C", item_type=ItemType.CONSUMABLE) + weapon = Item(item_id="w", name="W", item_type=ItemType.WEAPON) + + assert consumable.is_consumable() == True + assert weapon.is_consumable() == False + + def test_is_quest_item(self): + """Test is_quest_item() method.""" + quest = Item(item_id="q", name="Q", item_type=ItemType.QUEST_ITEM) + weapon = Item(item_id="w", name="W", item_type=ItemType.WEAPON) + + assert quest.is_quest_item() == True + assert weapon.is_quest_item() == False + + +class TestItemSerialization: + """Tests for Item serialization and deserialization.""" + + def test_to_dict_includes_rarity(self): + """Test that to_dict() includes rarity as string.""" + item = Item( + item_id="test", + name="Test", + item_type=ItemType.WEAPON, + rarity=ItemRarity.EPIC, + description="Test item", + ) + + data = item.to_dict() + + assert data["rarity"] == "epic" + assert data["item_type"] == "weapon" + + def test_from_dict_parses_rarity(self): + """Test that from_dict() parses rarity correctly.""" + data = { + "item_id": "test", + "name": "Test", + "item_type": "weapon", + "rarity": "legendary", + "description": "Test item", + } + + item = Item.from_dict(data) + + assert item.rarity == ItemRarity.LEGENDARY + assert item.item_type == ItemType.WEAPON + + def test_from_dict_defaults_to_common_rarity(self): + """Test that from_dict() defaults to COMMON if rarity missing.""" + data = { + "item_id": "test", + "name": "Test", + "item_type": "weapon", + "description": "Test item", + # No rarity field + } + + item = Item.from_dict(data) + + assert item.rarity == ItemRarity.COMMON + + def test_round_trip_serialization(self): + """Test serialization and deserialization preserve all data.""" + original = Item( + item_id="flame_sword", + name="Flame Blade", + item_type=ItemType.WEAPON, + rarity=ItemRarity.RARE, + description="A fiery blade.", + value=500, + damage=25, + damage_type=DamageType.PHYSICAL, + crit_chance=0.12, + crit_multiplier=2.5, + elemental_damage_type=DamageType.FIRE, + physical_ratio=0.7, + elemental_ratio=0.3, + defense=0, + resistance=0, + required_level=5, + stat_bonuses={"strength": 3}, + ) + + # Serialize then deserialize + data = original.to_dict() + restored = Item.from_dict(data) + + assert restored.item_id == original.item_id + assert restored.name == original.name + assert restored.item_type == original.item_type + assert restored.rarity == original.rarity + assert restored.description == original.description + assert restored.value == original.value + assert restored.damage == original.damage + assert restored.damage_type == original.damage_type + assert restored.crit_chance == original.crit_chance + assert restored.crit_multiplier == original.crit_multiplier + assert restored.elemental_damage_type == original.elemental_damage_type + assert restored.physical_ratio == original.physical_ratio + assert restored.elemental_ratio == original.elemental_ratio + assert restored.required_level == original.required_level + assert restored.stat_bonuses == original.stat_bonuses + + def test_round_trip_all_rarities(self): + """Test round-trip serialization for all rarity levels.""" + for rarity in ItemRarity: + original = Item( + item_id=f"item_{rarity.value}", + name=f"{rarity.value.title()} Item", + item_type=ItemType.CONSUMABLE, + rarity=rarity, + ) + + data = original.to_dict() + restored = Item.from_dict(data) + + assert restored.rarity == rarity + + +class TestItemEquippability: + """Tests for item equip requirements.""" + + def test_can_equip_level_requirement(self): + """Test level requirement checking.""" + item = Item( + item_id="high_level_sword", + name="Epic Sword", + item_type=ItemType.WEAPON, + required_level=10, + ) + + assert item.can_equip(character_level=5) == False + assert item.can_equip(character_level=10) == True + assert item.can_equip(character_level=15) == True + + def test_can_equip_class_requirement(self): + """Test class requirement checking.""" + item = Item( + item_id="mage_staff", + name="Mage Staff", + item_type=ItemType.WEAPON, + required_class="mage", + ) + + assert item.can_equip(character_level=1, character_class="warrior") == False + assert item.can_equip(character_level=1, character_class="mage") == True + + +class TestItemStatBonuses: + """Tests for item stat bonus methods.""" + + def test_get_total_stat_bonus(self): + """Test getting stat bonuses from items.""" + item = Item( + item_id="ring_of_power", + name="Ring of Power", + item_type=ItemType.ARMOR, + stat_bonuses={"strength": 5, "constitution": 3}, + ) + + assert item.get_total_stat_bonus("strength") == 5 + assert item.get_total_stat_bonus("constitution") == 3 + assert item.get_total_stat_bonus("dexterity") == 0 # Not in bonuses + + +class TestItemRepr: + """Tests for item string representation.""" + + def test_weapon_repr(self): + """Test weapon __repr__ output.""" + weapon = Item( + item_id="sword", + name="Iron Sword", + item_type=ItemType.WEAPON, + damage=10, + value=50, + ) + + repr_str = repr(weapon) + assert "Iron Sword" in repr_str + assert "weapon" in repr_str + + def test_armor_repr(self): + """Test armor __repr__ output.""" + armor = Item( + item_id="armor", + name="Leather Armor", + item_type=ItemType.ARMOR, + defense=5, + value=30, + ) + + repr_str = repr(armor) + assert "Leather Armor" in repr_str + assert "armor" in repr_str + + def test_consumable_repr(self): + """Test consumable __repr__ output.""" + potion = Item( + item_id="potion", + name="Health Potion", + item_type=ItemType.CONSUMABLE, + value=10, + ) + + repr_str = repr(potion) + assert "Health Potion" in repr_str + assert "consumable" in repr_str