scripts/item_generator.pyΒΆ

Part of Dungeon Explorer.

  1"""Loot generator: roll random items from TOML data files.
  2
  3Usage:
  4    gen = ItemGenerator("data/items.toml", "data/affixes.toml")
  5    item = gen.roll_item(ilvl=15, rarity=Rarity.RARE)
  6"""
  7
  8import random
  9from pathlib import Path
 10
 11try:
 12    import tomllib
 13except ImportError:
 14    import tomli as tomllib  # type: ignore[no-redef]
 15
 16from .item import Affix, Item, Rarity
 17
 18
 19def _lerp(a: float, b: float, t: float) -> float:
 20    return a + (b - a) * max(0.0, min(1.0, t))
 21
 22
 23class ItemGenerator:
 24    """Generates random items from base templates and affix tables."""
 25
 26    def __init__(self, items_path: str | Path, affixes_path: str | Path):
 27        with open(items_path, "rb") as f:
 28            self._templates = tomllib.load(f)
 29        with open(affixes_path, "rb") as f:
 30            self._affixes = tomllib.load(f)
 31
 32    def roll_item(
 33        self,
 34        template_key: str | None = None,
 35        ilvl: int = 1,
 36        rarity: Rarity = Rarity.COMMON,
 37        rng: random.Random | None = None,
 38    ) -> Item:
 39        """Roll a random item.
 40
 41        Args:
 42            template_key: Specific template (e.g. "sword"). None = random.
 43            ilvl: Item level for stat scaling.
 44            rarity: Rarity tier.
 45            rng: Random instance for reproducibility.
 46        """
 47        rng = rng or random.Random()
 48
 49        # Pick template
 50        if template_key is None:
 51            template_key = rng.choice(list(self._templates.keys()))
 52        template = self._templates[template_key]
 53
 54        # Base stats
 55        base_stats: dict[str, float] = {}
 56        for key in ("damage", "defence", "speed", "heal", "speed_bonus", "block_chance"):
 57            val = template.get(key)
 58            if val is not None:
 59                if isinstance(val, list) and len(val) == 2:
 60                    base_stats[key] = rng.uniform(val[0], val[1])
 61                else:
 62                    base_stats[key] = float(val)
 63
 64        # Max affixes for this rarity
 65        max_affixes_table = template.get("max_affixes", [0, 1, 2, 3, 4])
 66        max_affixes = max_affixes_table[min(int(rarity), len(max_affixes_table) - 1)]
 67
 68        # Roll affixes
 69        slot = template.get("slot", "consumable")
 70        prefixes = self._roll_affixes("prefix", slot, ilvl, max_affixes, rng)
 71        suffixes = self._roll_affixes("suffix", slot, ilvl, max_affixes, rng)
 72
 73        # Limit total affixes
 74        total = prefixes + suffixes
 75        if len(total) > max_affixes:
 76            total = rng.sample(total, max_affixes)
 77            prefixes = [a for a in total if a.affix_type == "prefix"]
 78            suffixes = [a for a in total if a.affix_type == "suffix"]
 79
 80        return Item(
 81            template_key=template_key,
 82            display_base=template.get("display", template_key.title()),
 83            slot=slot,
 84            rarity=rarity,
 85            ilvl=ilvl,
 86            prefixes=prefixes,
 87            suffixes=suffixes,
 88            base_stats=base_stats,
 89            stack_size=template.get("stack_size", 1),
 90        )
 91
 92    def _roll_affixes(
 93        self,
 94        affix_type: str,
 95        slot: str,
 96        ilvl: int,
 97        max_count: int,
 98        rng: random.Random,
 99    ) -> list[Affix]:
100        """Roll affixes of a given type for a slot and ilvl."""
101        candidates = []
102        for key, data in self._affixes.items():
103            if data.get("type") != affix_type:
104                continue
105            if slot not in data.get("slots", []):
106                continue
107            ilvl_min = data.get("ilvl_min", 1)
108            ilvl_max = data.get("ilvl_max", 100)
109            if ilvl < ilvl_min:
110                continue
111            candidates.append((key, data, ilvl_min, ilvl_max))
112
113        if not candidates:
114            return []
115
116        # Weighted random selection (up to max_count // 2 of this type)
117        count = min(len(candidates), max(1, max_count // 2))
118        weights = [c[1].get("weight", 10) for c in candidates]
119        selected = _weighted_sample(candidates, weights, count, rng)
120
121        affixes = []
122        for key, data, ilvl_min, ilvl_max in selected:
123            stat_range = data.get("range", [0, 0])
124            t = (ilvl - ilvl_min) / max(1, ilvl_max - ilvl_min)
125            value = _lerp(stat_range[0], stat_range[1], t)
126            # Add some randomness (+-15%)
127            value *= rng.uniform(0.85, 1.15)
128            affixes.append(
129                Affix(
130                    name=data.get("display", key.title()),
131                    affix_type=affix_type,
132                    stat=data["stat"],
133                    value=round(value, 3),
134                )
135            )
136
137        return affixes
138
139    def roll_from_loot_table(
140        self,
141        loot_table: dict,
142        dungeon_level: int,
143        rng: random.Random | None = None,
144    ) -> list[Item]:
145        """Roll drops from a loot table entry.
146
147        Returns list of dropped items (may be empty).
148        """
149        rng = rng or random.Random()
150        drops = []
151        for entry in loot_table.get("drops", []):
152            if rng.random() > entry.get("chance", 0.1):
153                continue
154            # Roll rarity
155            rarity_weights = entry.get("rarity_weights", [60, 25, 10, 4, 1])
156            rarity = Rarity(_weighted_index(rarity_weights, rng))
157            item = self.roll_item(
158                template_key=entry["item"],
159                ilvl=dungeon_level,
160                rarity=rarity,
161                rng=rng,
162            )
163            drops.append(item)
164        return drops
165
166
167def _weighted_sample(items: list, weights: list[float], count: int, rng: random.Random) -> list:
168    """Weighted random sample without replacement."""
169    pool = list(zip(items, weights, strict=True))
170    result = []
171    for _ in range(min(count, len(pool))):
172        total = sum(w for _, w in pool)
173        if total <= 0:
174            break
175        r = rng.uniform(0, total)
176        cumulative = 0.0
177        for i, (item, w) in enumerate(pool):
178            cumulative += w
179            if cumulative >= r:
180                result.append(item)
181                pool.pop(i)
182                break
183    return result
184
185
186def _weighted_index(weights: list[float], rng: random.Random) -> int:
187    """Return a random index weighted by the given weights."""
188    total = sum(weights)
189    if total <= 0:
190        return 0
191    r = rng.uniform(0, total)
192    cumulative = 0.0
193    for i, w in enumerate(weights):
194        cumulative += w
195        if cumulative >= r:
196            return i
197    return len(weights) - 1