scripts/item.pyΒΆ

Part of Dungeon Explorer.

  1"""Item class: base template + affixes + stat rolls.
  2
  3An Item is composed of:
  4- A base template (from items.toml)
  5- 0+ prefix affixes
  6- 0+ suffix affixes
  7- A rarity tier (common=0, uncommon=1, rare=2, epic=3, legendary=4)
  8- Stat rolls derived from the item level (ilvl)
  9"""
 10
 11from dataclasses import dataclass, field
 12from enum import IntEnum
 13
 14
 15class Rarity(IntEnum):
 16    COMMON = 0
 17    UNCOMMON = 1
 18    RARE = 2
 19    EPIC = 3
 20    LEGENDARY = 4
 21
 22
 23RARITY_NAMES = {
 24    Rarity.COMMON: "Common",
 25    Rarity.UNCOMMON: "Uncommon",
 26    Rarity.RARE: "Rare",
 27    Rarity.EPIC: "Epic",
 28    Rarity.LEGENDARY: "Legendary",
 29}
 30
 31RARITY_COLOURS = {
 32    Rarity.COMMON: (0.7, 0.7, 0.7, 1.0),
 33    Rarity.UNCOMMON: (0.3, 0.8, 0.3, 1.0),
 34    Rarity.RARE: (0.3, 0.5, 1.0, 1.0),
 35    Rarity.EPIC: (0.7, 0.3, 0.9, 1.0),
 36    Rarity.LEGENDARY: (1.0, 0.65, 0.0, 1.0),
 37}
 38
 39EQUIPMENT_SLOTS = ("weapon", "offhand", "head", "body", "feet", "ring", "neck")
 40
 41
 42@dataclass
 43class Affix:
 44    """A single prefix or suffix on an item."""
 45
 46    name: str  # e.g. "Keen", "of Fire"
 47    affix_type: str  # "prefix" or "suffix"
 48    stat: str  # e.g. "damage", "crit_chance"
 49    value: float  # Rolled value
 50
 51    def display_text(self) -> str:
 52        if isinstance(self.value, float) and self.value < 1:
 53            return f"+{self.value:.0%} {self.stat.replace('_', ' ')}"
 54        return f"+{int(self.value)} {self.stat.replace('_', ' ')}"
 55
 56
 57@dataclass
 58class Item:
 59    """A single item instance with template, affixes, and stats."""
 60
 61    template_key: str  # e.g. "sword"
 62    display_base: str  # e.g. "Iron Sword"
 63    slot: str  # e.g. "weapon", "head", "body"
 64    rarity: Rarity = Rarity.COMMON
 65    ilvl: int = 1
 66    prefixes: list[Affix] = field(default_factory=list)
 67    suffixes: list[Affix] = field(default_factory=list)
 68    base_stats: dict[str, float] = field(default_factory=dict)
 69    stack_size: int = 1
 70    stack_count: int = 1
 71    durability: int = 100  # Current durability (0 = broken)
 72    max_durability: int = 100  # Max durability
 73
 74    @property
 75    def display_name(self) -> str:
 76        """Full item name: '{Prefix} {Base} {Suffix}'."""
 77        parts = []
 78        if self.prefixes:
 79            parts.append(self.prefixes[0].name)
 80        parts.append(self.display_base)
 81        if self.suffixes:
 82            parts.append(self.suffixes[0].name)
 83        return " ".join(parts)
 84
 85    @property
 86    def colour(self) -> tuple:
 87        return RARITY_COLOURS[self.rarity]
 88
 89    @property
 90    def rarity_name(self) -> str:
 91        return RARITY_NAMES[self.rarity]
 92
 93    @property
 94    def is_equipment(self) -> bool:
 95        return self.slot in EQUIPMENT_SLOTS
 96
 97    @property
 98    def is_consumable(self) -> bool:
 99        return self.slot == "consumable"
100
101    @property
102    def is_stackable(self) -> bool:
103        return self.stack_size > 1
104
105    @property
106    def is_broken(self) -> bool:
107        """Equipment with 0 durability provides no stats."""
108        return self.is_equipment and self.durability <= 0
109
110    def degrade(self, amount: int = 1):
111        """Reduce durability. Only affects equipment."""
112        if self.is_equipment and self.durability > 0:
113            self.durability = max(0, self.durability - amount)
114
115    def repair(self, amount: int | None = None):
116        """Restore durability. None = full repair."""
117        if amount is None:
118            self.durability = self.max_durability
119        else:
120            self.durability = min(self.max_durability, self.durability + amount)
121
122    @property
123    def durability_ratio(self) -> float:
124        return self.durability / max(1, self.max_durability)
125
126    def total_stats(self) -> dict[str, float]:
127        """Compute total stats from base + all affixes. Broken items give nothing."""
128        if self.is_broken:
129            return {}
130        stats = dict(self.base_stats)
131        for affix in self.prefixes + self.suffixes:
132            stats[affix.stat] = stats.get(affix.stat, 0) + affix.value
133        return stats
134
135    def get_stat(self, key: str, default: float = 0) -> float:
136        return self.total_stats().get(key, default)
137
138    def to_dict(self) -> dict:
139        """Serialise to a JSON-compatible dict."""
140        return {
141            "template_key": self.template_key,
142            "display_base": self.display_base,
143            "slot": self.slot,
144            "rarity": int(self.rarity),
145            "ilvl": self.ilvl,
146            "prefixes": [
147                {"name": a.name, "type": a.affix_type, "stat": a.stat, "value": a.value} for a in self.prefixes
148            ],
149            "suffixes": [
150                {"name": a.name, "type": a.affix_type, "stat": a.stat, "value": a.value} for a in self.suffixes
151            ],
152            "base_stats": self.base_stats,
153            "stack_size": self.stack_size,
154            "stack_count": self.stack_count,
155            "durability": self.durability,
156            "max_durability": self.max_durability,
157        }
158
159    @classmethod
160    def from_dict(cls, d: dict) -> Item:
161        """Deserialise from a dict."""
162        prefixes = [
163            Affix(name=a["name"], affix_type=a["type"], stat=a["stat"], value=a["value"]) for a in d.get("prefixes", [])
164        ]
165        suffixes = [
166            Affix(name=a["name"], affix_type=a["type"], stat=a["stat"], value=a["value"]) for a in d.get("suffixes", [])
167        ]
168        return cls(
169            template_key=d["template_key"],
170            display_base=d["display_base"],
171            slot=d["slot"],
172            rarity=Rarity(d.get("rarity", 0)),
173            ilvl=d.get("ilvl", 1),
174            prefixes=prefixes,
175            suffixes=suffixes,
176            base_stats=d.get("base_stats", {}),
177            stack_size=d.get("stack_size", 1),
178            stack_count=d.get("stack_count", 1),
179            durability=d.get("durability", 100),
180            max_durability=d.get("max_durability", 100),
181        )