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.
This commit is contained in:
Jared Miller 2026-02-14 10:02:38 -05:00
parent 56169a5ed6
commit 0fbd63a1f7
Signed by: shmup
GPG key ID: 22B5C6D66A38B06C
3 changed files with 152 additions and 0 deletions

34
src/mudlib/loot.py Normal file
View file

@ -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

View file

@ -5,6 +5,7 @@ from dataclasses import dataclass, field
from pathlib import Path from pathlib import Path
from mudlib.entity import Mob from mudlib.entity import Mob
from mudlib.loot import LootEntry
from mudlib.zone import Zone from mudlib.zone import Zone
@ -18,6 +19,7 @@ class MobTemplate:
stamina: float stamina: float
max_stamina: float max_stamina: float
moves: list[str] = field(default_factory=list) moves: list[str] = field(default_factory=list)
loot: list[LootEntry] = field(default_factory=list)
# Module-level registries # Module-level registries
@ -29,6 +31,19 @@ def load_mob_template(path: Path) -> MobTemplate:
"""Parse a mob TOML file into a MobTemplate.""" """Parse a mob TOML file into a MobTemplate."""
with open(path, "rb") as f: with open(path, "rb") as f:
data = tomllib.load(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( return MobTemplate(
name=data["name"], name=data["name"],
description=data["description"], description=data["description"],
@ -36,6 +51,7 @@ def load_mob_template(path: Path) -> MobTemplate:
stamina=data["stamina"], stamina=data["stamina"],
max_stamina=data["max_stamina"], max_stamina=data["max_stamina"],
moves=data.get("moves", []), moves=data.get("moves", []),
loot=loot_entries,
) )

102
tests/test_loot.py Normal file
View file

@ -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 == []