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 )