From 0fbd63a1f75b77c47e2ce953a7db9d69c587687b Mon Sep 17 00:00:00 2001 From: Jared Miller Date: Sat, 14 Feb 2026 10:02:38 -0500 Subject: [PATCH] Add loot table system with LootEntry and roll_loot LootEntry defines probabilistic item drops with min/max counts. roll_loot takes a loot table and returns Thing instances. MobTemplate now has loot field, parsed from TOML [[loot]] sections. --- src/mudlib/loot.py | 34 +++++++++++++++ src/mudlib/mobs.py | 16 +++++++ tests/test_loot.py | 102 +++++++++++++++++++++++++++++++++++++++++++++ 3 files changed, 152 insertions(+) create mode 100644 src/mudlib/loot.py create mode 100644 tests/test_loot.py diff --git a/src/mudlib/loot.py b/src/mudlib/loot.py new file mode 100644 index 0000000..8996ab2 --- /dev/null +++ b/src/mudlib/loot.py @@ -0,0 +1,34 @@ +"""Loot table system for mob drops.""" + +import random +from dataclasses import dataclass + +from mudlib.thing import Thing + + +@dataclass +class LootEntry: + """A single loot table entry.""" + + name: str + chance: float # 0.0-1.0 + min_count: int = 1 + max_count: int = 1 + description: str = "" + + +def roll_loot(entries: list[LootEntry]) -> list[Thing]: + """Roll a loot table and return the resulting Things.""" + items: list[Thing] = [] + for entry in entries: + if random.random() < entry.chance: + count = random.randint(entry.min_count, entry.max_count) + for _ in range(count): + items.append( + Thing( + name=entry.name, + description=entry.description, + portable=True, + ) + ) + return items diff --git a/src/mudlib/mobs.py b/src/mudlib/mobs.py index 5ead000..2aaa57d 100644 --- a/src/mudlib/mobs.py +++ b/src/mudlib/mobs.py @@ -5,6 +5,7 @@ from dataclasses import dataclass, field from pathlib import Path from mudlib.entity import Mob +from mudlib.loot import LootEntry from mudlib.zone import Zone @@ -18,6 +19,7 @@ class MobTemplate: stamina: float max_stamina: float moves: list[str] = field(default_factory=list) + loot: list[LootEntry] = field(default_factory=list) # Module-level registries @@ -29,6 +31,19 @@ def load_mob_template(path: Path) -> MobTemplate: """Parse a mob TOML file into a MobTemplate.""" with open(path, "rb") as f: data = tomllib.load(f) + + loot_entries = [] + for entry_data in data.get("loot", []): + loot_entries.append( + LootEntry( + name=entry_data["name"], + chance=entry_data["chance"], + min_count=entry_data.get("min_count", 1), + max_count=entry_data.get("max_count", 1), + description=entry_data.get("description", ""), + ) + ) + return MobTemplate( name=data["name"], description=data["description"], @@ -36,6 +51,7 @@ def load_mob_template(path: Path) -> MobTemplate: stamina=data["stamina"], max_stamina=data["max_stamina"], moves=data.get("moves", []), + loot=loot_entries, ) diff --git a/tests/test_loot.py b/tests/test_loot.py new file mode 100644 index 0000000..e6309ec --- /dev/null +++ b/tests/test_loot.py @@ -0,0 +1,102 @@ +"""Tests for loot table system.""" + +from mudlib.loot import LootEntry, roll_loot +from mudlib.mobs import MobTemplate, load_mob_template +from mudlib.thing import Thing + + +class TestLootEntry: + def test_loot_entry_fields(self): + entry = LootEntry(name="gold coin", chance=0.5, min_count=1, max_count=3) + assert entry.name == "gold coin" + assert entry.chance == 0.5 + + +class TestRollLoot: + def test_empty_table_returns_empty(self): + assert roll_loot([]) == [] + + def test_guaranteed_drop(self): + """chance=1.0 always drops.""" + entry = LootEntry(name="gold coin", chance=1.0) + items = roll_loot([entry]) + assert len(items) == 1 + assert items[0].name == "gold coin" + + def test_zero_chance_never_drops(self): + """chance=0.0 never drops.""" + entry = LootEntry(name="rare gem", chance=0.0) + items = roll_loot([entry]) + assert len(items) == 0 + + def test_count_range(self): + """min_count/max_count controls number of items.""" + entry = LootEntry(name="coin", chance=1.0, min_count=3, max_count=3) + items = roll_loot([entry]) + assert len(items) == 3 + assert all(item.name == "coin" for item in items) + + def test_items_are_portable_things(self): + entry = LootEntry(name="sword", chance=1.0, description="a rusty sword") + items = roll_loot([entry]) + assert isinstance(items[0], Thing) + assert items[0].portable is True + assert items[0].description == "a rusty sword" + + def test_multiple_entries(self): + entries = [ + LootEntry(name="coin", chance=1.0, min_count=2, max_count=2), + LootEntry(name="gem", chance=1.0), + ] + items = roll_loot(entries) + assert len(items) == 3 # 2 coins + 1 gem + + +class TestMobTemplateLoot: + def test_template_default_empty_loot(self): + t = MobTemplate( + name="rat", description="a rat", pl=10, stamina=10, max_stamina=10 + ) + assert t.loot == [] + + def test_load_template_with_loot(self, tmp_path): + toml_content = """ +name = "goblin" +description = "a goblin" +pl = 50.0 +stamina = 40.0 +max_stamina = 40.0 +moves = ["punch right"] + +[[loot]] +name = "crude club" +chance = 0.8 +description = "a crude wooden club" + +[[loot]] +name = "gold coin" +chance = 0.5 +min_count = 1 +max_count = 3 +""" + f = tmp_path / "goblin.toml" + f.write_text(toml_content) + template = load_mob_template(f) + assert len(template.loot) == 2 + assert template.loot[0].name == "crude club" + assert template.loot[0].chance == 0.8 + assert template.loot[1].min_count == 1 + assert template.loot[1].max_count == 3 + + def test_load_template_without_loot(self, tmp_path): + toml_content = """ +name = "rat" +description = "a rat" +pl = 10.0 +stamina = 10.0 +max_stamina = 10.0 +""" + f = tmp_path / "rat.toml" + f.write_text(toml_content) + template = load_mob_template(f) + assert template.loot == []