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:
parent
56169a5ed6
commit
0fbd63a1f7
3 changed files with 152 additions and 0 deletions
34
src/mudlib/loot.py
Normal file
34
src/mudlib/loot.py
Normal 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
|
||||||
|
|
@ -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
102
tests/test_loot.py
Normal 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 == []
|
||||||
Loading…
Reference in a new issue